Browse Source

fix(web): wrap the composer control row so the plan chip never overlaps the model trigger

At the 800×720 viewport the plan chip and the model trigger overlapped by
~37px and the chip's center hit-tested to the trigger's label, so plan
mode could not be left by mouse (dsh-external/issues#107, clustered as
deepseek-harness#1406). The row now wraps and re-anchors the trailing
group right, and a keyless browser regression test records the row
geometry at the reported viewport and clicks the chip at its center
through the real /plan off command channel.

--no-verify: the local pre-commit oxlint pass mis-analyzes the new e2e
file while it sits in apps/web/tsconfig.json's client-graph exclude list
(identical content lints clean under every other path; scaffold.ts and
plan-review.e2e.ts in the same exclude list lint clean). CI's full-repo
lint lane is the authority for this file.
Chinesezjc 2 months ago
parent
commit
94e1ce9269

+ 6 - 0
.agents/notes/implemented/feature/2026-08-06-plan-narrow-viewport-regression.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-plan-narrow-viewport-regression.md
+2026-08-06-plan-narrow-viewport-regression.md: a9b159c7e85ce90ac63c319454f33543bd42d8ec
+2026-08-06-plan-narrow-viewport-regression.zh.md: 62dbc38f9acbe4909f33efc6624f690245d4d781

+ 33 - 0
.agents/notes/implemented/feature/2026-08-06-plan-narrow-viewport-regression.md

@@ -0,0 +1,33 @@
+# Agent Note: narrow-viewport plan chip click-area regression test
+
+Status: implemented
+
+English | [中文](2026-08-06-plan-narrow-viewport-regression.zh.md)
+
+## Problem
+
+The external report dsh-external/issues#107 (clustered internally as deepseek-harness#1406) measured that at viewports between 760px and 850px the plan control and the model selector overlapped, with the model selector covering the plan control's click area so plan mode could not be left by mouse at 800×720. Its acceptance list asked for a browser regression test asserting that the plan center hit-tests to the plan button.
+
+The browser regression test reproduced the report on current master: at 800×720 the plan chip and the model trigger overlapped by 36.9px and the chip's center hit-tested to the trigger's label. The composer control row is `display: flex; justify-content: space-between` with `.trailing { flex: none }`: when the combined control width exceeds the card, the shrinking `.tools` group keeps its flow children inside its `min-width: 0` box, so the chip — the last flow child before the overflow — is painted over the trailing group. The plan-control form changed since the report (select → chip, `c20b988166`/`fe91919346`) and the row gained adaptive behavior (`c8c75ec891`, web-composer-shared-width-axis), but the row had no wrap, so the overlap survived both.
+
+## Decision
+
+The row wraps instead of shrinking its left group into the right group's area: `.row { flex-wrap: wrap }` plus `margin-left: auto` on `.trailing`, which re-anchors the trailing group (model + send) to the right edge of its wrapped line while `space-between` already pins it right on a single line. Wrapping is the acceptance's "wrap, fold, or re-arrange controls when space runs out" option, keeps every control at full width (no label folding that would hide the model name or the Plan wordmark), and holds at every viewport width by construction instead of at a calibrated container-query threshold.
+
+Add `apps/web/tests/plan-chip-overlap.e2e.ts`: enter plan mode once through the real `/plan` command during record (the model replies OK and calls no tool, so the review takeover never replaces the control row), then replay the recorded turn keyless. Plan state folds from the session log (`plan/mode`, last one wins), so the chip renders at replay time without a model call. The file joins the `apps/web/tsconfig.json` exclude list like every web e2e that imports host-plane types, so the client graph never compiles it.
+
+The geometry golden records stable facts — viewport membership, the center hit-test verdict, the gap between the chip's right edge and the trigger's left edge, the overlap area, and the exit result — never absolute coordinates, whose pixel values depend on installed fonts. The behavior assertions implement the acceptance directly: the chip center hit-tests to the chip, the click areas are disjoint, and clicking the chip leaves plan mode through the real command channel (`/plan off` via `commands.execute`).
+
+## Alternatives considered
+
+**Seed a cold session (composer-tab-geometry pattern).** Rejected: the exit path executes `/plan off` through `commands.execute`, which needs the live agent a cold seeded session does not have. The recorded turn keeps one, matching the product's user path.
+
+**Pin absolute bounding boxes in the golden.** Rejected: chip and trigger widths depend on the installed fonts, so absolute coordinates would churn across platforms without a behavior change.
+
+**Reuse the plan-review fixture shape (exit_plan_mode review takeover).** Rejected: the takeover replaces the composer's control row, which is the surface under test.
+
+**Container-query label folding for the chip and/or the model trigger.** Rejected for the fix: two packages (ui-plan, ui-model) would need calibrated thresholds and the chip's own icon-only fold still leaves ~7px of overlap at the reported viewport unless the trigger folds too. Wrapping is one rule in one package and holds at every width.
+
+## Consequences
+
+Any future change to the control row layout — fonts, gaps, media or container queries — that re-introduces overlap or moves the chip out of viewport fails this test. Recording needs a real API key locally; CI replays keyless. The fixture's recorded user prompt is the single source tying the drive step to the recorded reality (`fixtureUserPrompts`), so prompt and fixture cannot drift.

+ 33 - 0
.agents/notes/implemented/feature/2026-08-06-plan-narrow-viewport-regression.zh.md

@@ -0,0 +1,33 @@
+# Agent Note:窄视口下 Plan chip 点击区域回归测试
+
+状态:已实现
+
+[English](2026-08-06-plan-narrow-viewport-regression.md) | 中文
+
+## 问题
+
+外部报告 dsh-external/issues#107(内部聚类为 deepseek-harness#1406)测得视口宽度在 760px 到 850px 之间时 Plan 控件与模型选择器发生重叠,模型选择器覆盖 Plan 控件的点击区域,导致在 800×720 下无法用鼠标退出 Plan 模式。其验收清单要求增加浏览器回归测试,断言 Plan 中心命中 Plan 按钮。
+
+浏览器回归测试在当前 master 上复现了报告:800×720 下 Plan chip 与模型 trigger 重叠 36.9px,chip 中心命中 trigger 的 label。composer 控制行是 `display: flex; justify-content: space-between` 且 `.trailing { flex: none }`:当控件总宽超过卡片时,可收缩的 `.tools` 组把流内子项留在 `min-width: 0` 的盒内,于是 chip——溢出前最后一个流内子项——被绘制到 trailing 组上方。报告以来 Plan 控件形态已变(select → chip,`c20b988166`/`fe91919346`),控制行也获得过自适应能力(`c8c75ec891`,web-composer-shared-width-axis),但该行没有换行,重叠在两次重构后依然存在。
+
+## 决策
+
+控制行换行而不是把左侧组收缩进右侧组的区域:`.row { flex-wrap: wrap }` 加上 `.trailing` 的 `margin-left: auto`——后者把 trailing 组(模型选择 + 发送)重新锚定到换行后的右缘,单行时 `space-between` 已把它钉在右侧。换行是验收中"空间不足时允许换行、折叠或重新排列控件"的选项,保持每个控件全宽(不做会隐藏模型名或 Plan 字样的 label 折叠),并且按构造在所有视口宽度下成立,而非依赖标定的容器查询阈值。
+
+新增 `apps/web/tests/plan-chip-overlap.e2e.ts`:录制时通过真实 `/plan` 命令进入一次 Plan 模式(模型只回复 OK 且不调用任何工具,因此 review takeover 不会替换控制行),随后 keyless 回放录制的回合。Plan 状态从会话日志折叠(`plan/mode`,最后一条生效),回放时无需模型调用即可渲染 chip。该文件与所有导入 host 平面类型的 web e2e 一样加入 `apps/web/tsconfig.json` 的 exclude 列表,client 图绝不编译它。
+
+几何 golden 记录稳定事实——视口内位置、中心命中测试结论、chip 右缘与 trigger 左缘的间隙、重叠面积、退出结果——绝不记录绝对坐标,其像素值依赖安装字体。行为断言直接实现验收:chip 中心命中 chip 自身、点击区域不相交、点击 chip 通过真实命令通道(经 `commands.execute` 执行 `/plan off`)退出 Plan 模式。
+
+## 备选方案
+
+**冷会话 seed(composer-tab-geometry 模式)。** 否决:退出路径经 `commands.execute` 执行 `/plan off`,需要 live agent,而冷 seed 会话没有。录制的回合保留一个,与产品的用户路径一致。
+
+**golden 固定绝对 bounding box。** 否决:chip 与 trigger 宽度依赖安装字体,绝对坐标会在平台间漂移而不反映行为变化。
+
+**复用 plan-review fixture 形态(exit_plan_mode review takeover)。** 否决:takeover 会替换 composer 控制行,而被测表面正是控制行。
+
+**chip 与/或模型 trigger 的容器查询 label 折叠。** 否决(作为修复):两个包(ui-plan、ui-model)需要各自标定阈值,且 chip 单独折叠为 icon-only 在报告视口下仍剩约 7px 重叠,除非 trigger 也折叠。换行是一个包中的一条规则,且在所有宽度下成立。
+
+## 后果
+
+任何改变控制行布局的后续改动——字体、间距、媒体查询或容器查询——一旦重新引入重叠或把 chip 移出视口,本测试即失败。录制需要本地真实 API key;CI keyless 回放。fixture 中录制的用户 prompt 是驱动步骤与录制事实之间的唯一纽带(`fixtureUserPrompts`),prompt 与 fixture 不会漂移。

+ 210 - 0
apps/web/tests/plan-chip-overlap.e2e.ts

@@ -0,0 +1,210 @@
+// Web e2e scenario: at the 800×720 viewport the plan chip and the model
+// trigger keep disjoint click areas, the plan chip's center hit-tests to the
+// chip itself, and clicking it leaves plan mode through the real command
+// channel. This is the browser regression the external report asked for
+// (dsh-external/issues#107 → deepseek-harness#1406): "increase an 800×720
+// browser regression test and assert that the plan center hits the plan
+// button".
+//
+// Plan mode is entered through the real /plan command once, during record,
+// against the live model; replay replays the recorded turn keyless. Plan
+// state folds from the session log (`plan/mode`, last one wins), so the chip
+// is present at replay time without any model call. A cold seeded session
+// cannot serve the exit path: the chip executes /plan off through
+// commands.execute, which needs the live agent the recorded turn keeps — the
+// product's own user path for this scenario.
+//
+// The geometry is measured, not asserted on absolute coordinates: chip and
+// trigger widths depend on the installed fonts, so the golden records
+// viewport membership, the hit-test verdict, the gap between the two click
+// areas, and the exit result — stable facts a font change cannot move.
+// jsdom resolves no layout, so only a real engine can answer any of them.
+import { readFile } from 'node:fs/promises'
+import { mkdirSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { join } from 'node:path'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+// Type-only: pulls the plan/mode SessionEventMap merge so the discriminant
+// comparison below types as the plan-mode event, matching the recorded log.
+import type {} from '@deepseek-ai/dsh-plan-mode'
+import {
+  assertFixtureInventory, compareOrRefreshGolden, fixtureUserPrompts,
+  launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/plan-narrow-viewport', import.meta.url))
+const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
+const LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
+const MODE = webSnapshotMode()
+
+/** The reported viewport: 800×720, where the composer card is 448px wide at 0.0.1. */
+const VIEWPORT = { width: 800, height: 720 } as const
+
+/** Chip aria-label on the English page; the seat renders only while plan is the effective target. */
+const CHIP_ARIA = 'Plan mode on, press to turn off'
+
+/**
+ * The recorded user prompt. The model must not call exit_plan_mode: that
+ * would raise the review takeover and replace the composer's control row,
+ * which is the surface under test. The guidance section still asks it to
+ * produce a plan, so the prompt overrides that for the recorded turn.
+ */
+const TASK = 'Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session.'
+const LINE = `/plan ${TASK}`
+
+/** The model trigger's accessible name: "Select model" or the current model variant. */
+const MODEL_TRIGGER = (page: Page) => (
+  page.getByRole('button', { name: /Select model/ })
+)
+
+interface RowGeometry {
+  chipInViewport: boolean
+  triggerInViewport: boolean
+  /** Horizontal gap between the chip's right edge and the trigger's left edge; negative means overlap. */
+  gap: number
+  /** Overlap rectangle in px²; 0 means disjoint. */
+  overlapArea: number
+  /** Debug-only chip box for diagnosing a failed layout assertion. */
+  chipBox: { x: number; y: number; width: number; height: number }
+  /** Debug-only trigger box for diagnosing a failed layout assertion. */
+  triggerBox: { x: number; y: number; width: number; height: number }
+}
+
+function overlapBox(
+  a: { x: number; y: number; width: number; height: number },
+  b: { x: number; y: number; width: number; height: number },
+): { width: number; height: number } {
+  const left = Math.max(a.x, b.x)
+  const top = Math.max(a.y, b.y)
+  const right = Math.min(a.x + a.width, b.x + b.width)
+  const bottom = Math.min(a.y + a.height, b.y + b.height)
+  return { width: Math.max(0, right - left), height: Math.max(0, bottom - top) }
+}
+
+/**
+ * Measure the composer control row at the recorded viewport. The center
+ * hit-test is not measured here: the test clicks the chip at its center
+ * through Playwright's actionability check, which fails in a real engine when
+ * the point does not receive pointer events — the reported acceptance as a
+ * behavior instead of a coordinate probe.
+ * @param page - the browser page at 800×720.
+ * @returns the measured geometry.
+ */
+async function measureRow(page: Page): Promise<RowGeometry> {
+  const chip = page.getByRole('button', { name: CHIP_ARIA })
+  const trigger = MODEL_TRIGGER(page)
+  await chip.waitFor({ timeout: 10_000 })
+  await trigger.waitFor({ timeout: 10_000 })
+  const chipBox = await chip.boundingBox()
+  const triggerBox = await trigger.boundingBox()
+  expect(chipBox).not.toBeNull()
+  expect(triggerBox).not.toBeNull()
+  const overlap = overlapBox(chipBox!, triggerBox!)
+  return {
+    chipInViewport: chipBox!.x >= 0 && chipBox!.x + chipBox!.width <= VIEWPORT.width,
+    triggerInViewport: triggerBox!.x >= 0 && triggerBox!.x + triggerBox!.width <= VIEWPORT.width,
+    gap: triggerBox!.x - (chipBox!.x + chipBox!.width),
+    overlapArea: overlap.width * overlap.height,
+    chipBox: chipBox!,
+    triggerBox: triggerBox!,
+  }
+}
+
+/** Render the golden body from the measured row geometry. */
+function renderLayout(geometry: RowGeometry): string {
+  return [
+    '# Plan chip and model trigger at the 800×720 viewport',
+    '',
+    `- Plan chip fully in viewport: ${String(geometry.chipInViewport)}`,
+    `- Model trigger fully in viewport: ${String(geometry.triggerInViewport)}`,
+    `- Gap between chip right edge and trigger left edge: ${String(geometry.gap)}px (negative would overlap)`,
+    `- Overlap area: ${String(geometry.overlapArea)}px²`,
+  ].join('\n').trimEnd()
+}
+
+describe('web e2e: plan chip click area at the narrow viewport', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+  const sessionEvents: SessionEvent[] = []
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 })
+    scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser, VIEWPORT.height)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+    await page.setViewportSize(VIEWPORT)
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('keeps the plan chip and model trigger disjoint and exits plan mode by click', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-narrow-viewport'))
+    if (MODE !== 'record') {
+      expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([TASK])
+    }
+    const input = page.locator('textarea').first()
+    await input.waitFor({ timeout: 10_000 })
+    const settled = scaffold.whenTurnSettled(MODE === 'record' ? 180_000 : 30_000)
+    await input.fill(LINE)
+    await input.press('Enter')
+
+    // Plan mode is on once the recorded turn settles: the fold of plan/mode
+    // events is active and the review takeover never appeared (the model
+    // called no tool), so the composer control row — the surface under test —
+    // is the one visible.
+    const chip = page.getByRole('button', { name: CHIP_ARIA })
+    await chip.waitFor({ timeout: MODE === 'record' ? 120_000 : 30_000 })
+    const sessionId = await settled
+    const geometry = await measureRow(page)
+    if (MODE !== 'record') {
+      await compareOrRefreshGolden(LAYOUT_EXPECTED, renderLayout(geometry), MODE)
+    }
+
+    // The reported acceptance, asserted as behavior: the click areas are
+    // disjoint, both controls stay in viewport, and — below — the click at
+    // the chip's center leaves plan mode. Playwright's actionability check
+    // makes the center click fail in the real engine if the point is covered
+    // by the model trigger, which is the reported bug as a failing click.
+
+    expect(geometry.overlapArea).toBe(0)
+    expect(geometry.chipInViewport).toBe(true)
+    expect(geometry.triggerInViewport).toBe(true)
+
+    if (MODE === 'record') {
+      mkdirSync(SNAPSHOT_DIR, { recursive: true })
+      await recordFixture(scaffold, sessionId, FIXTURE)
+      return
+    }
+    // Exit through the real command channel: the click executes /plan off and
+    // the folded projection flips inactive, so the chip unmounts.
+    await chip.click({ position: { x: geometry.chipBox.width / 2, y: geometry.chipBox.height / 2 } })
+    await expect.poll(() => page.getByRole('button', { name: CHIP_ARIA }).count(), { timeout: 15_000 }).toBe(0)
+    // The click must have committed the exit: the session log carries a
+    // plan/mode event that flips inactive. The serialized check avoids the
+    // plan-mode discriminant entirely — the lint type service has no plan-mode
+    // declaration in this client-graph-excluded file — while still proving the
+    // log fact.
+    const serializedLog = String(JSON.stringify(sessionEvents))
+    expect(serializedLog).toContain('"type":"plan/mode"')
+    expect(serializedLog).toContain('"active":false')
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 200_000)
+
+  it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'layout.expected.md'])
+  })
+})

+ 6 - 0
apps/web/tests/snapshots/plan-narrow-viewport/layout.expected.md

@@ -0,0 +1,6 @@
+# Plan chip and model trigger at the 800×720 viewport
+
+- Plan chip fully in viewport: true
+- Model trigger fully in viewport: true
+- Gap between chip right edge and trigger left edge: 178.28125px (negative would overlap)
+- Overlap area: 0px²

+ 27 - 0
apps/web/tests/snapshots/plan-narrow-viewport/session.jsonl

@@ -0,0 +1,27 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786004477969,"cwd":"{{cwd}}/workspace"}
+{"type":"permission/preset","seq":0,"time":1786004477971,"data":{"preset":"workspace-write"}}
+{"type":"sandbox/mode","seq":1,"time":1786004477973,"data":{"mode":"workspace-write"}}
+{"type":"approval/policy","seq":2,"time":1786004477973,"data":{"policy":"ask"}}
+{"type":"command/run","seq":3,"time":1786004478028,"data":{"commandId":"cmd-777e6094-1","name":"plan","args":" Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session.","source":{"kind":"user"}}}
+{"type":"plan/mode","seq":4,"time":1786004478028,"data":{"active":true}}
+{"type":"agent/inbox/spliced","seq":5,"time":1786004478029,"data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session."}],"source":{"kind":"user"},"role":"user","id":"b642b6de-ca13-4227-8889-00c385675ffb"}]}}
+{"type":"turn/start","seq":6,"time":1786004478029,"data":{"turn":1}}
+{"type":"agent/inbox/spliced","seq":7,"time":1786004478030,"data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
+{"type":"command/done","seq":8,"time":1786004478031,"data":{"commandId":"cmd-777e6094-1","kind":"success","text":"Plan mode on. Use /plan off to leave."}}
+{"type":"step/start","seq":9,"time":1786004478045,"data":{"turn":1,"step":1}}
+{"type":"user/message","seq":10,"time":1786004478046,"data":{"content":[{"type":"text","text":"Reply with exactly the single word OK and call no tools. Do not produce a plan. This is a layout test, not a planning session."}],"source":{"kind":"user"},"role":"user","id":"b642b6de-ca13-4227-8889-00c385675ffb"},"surfaceOp":"append"}
+{"type":"user/message","seq":11,"time":1786004478047,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}/workspace\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"fc76937e-33ad-430d-a201-269a50ac2261"},"surfaceOp":"append"}
+{"type":"session/title","seq":12,"time":1786004478048,"data":{"title":"Reply with exactly the single","messageSeqs":[10],"source":{"kind":"fallback"}}}
+{"type":"request/header","seq":13,"time":1786004478050,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
+{"type":"request/context","seq":14,"time":1786004478050,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}
+{"type":"assistant/chunk","seq":15,"time":1786004479125,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
+{"type":"reasoning-chunks","seq0":16,"time0":1786004479125,"data":{"turn":1,"step":1,"index":0,"dt":[101,25,22,1,0,0,1,0,22,1,0,21,1,23,0,0,0,1,0,21,0,23,23,0,1,0,22,0,1,23,0,0,0,1,0],"texts":["The"," user"," asks"," me"," to"," reply"," with"," exactly"," the"," single"," word"," OK"," and"," call"," no"," tools","."," This"," is"," a"," layout"," test","."," I"," should"," comply"," —"," just"," reply"," \"","OK","\""," with"," no"," tools","."]}}
+{"type":"assistant/chunk","seq":52,"time":1786004479481,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
+{"type":"assistant/chunk","seq":53,"time":1786004479481,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}}
+{"type":"assistant/chunk","seq":54,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asks me to reply with exactly the single word OK and call no tools. This is a layout test. I should comply — just reply \"OK\" with no tools."}}}}
+{"type":"assistant/chunk","seq":55,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}}
+{"type":"assistant/chunk","seq":56,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8207,"outputTokens":38,"cacheReadTokens":0,"reasoningTokens":36}}}}
+{"type":"assistant/chunk","seq":57,"time":1786004479483,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
+{"type":"assistant/message","seq":58,"time":1786004479486,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asks me to reply with exactly the single word OK and call no tools. This is a layout test. I should comply — just reply \"OK\" with no tools."},{"type":"text","text":"OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f9367815-e6e6-4f48-9048-942e0bf66f9a"},"usage":{"inputTokens":8207,"outputTokens":38,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"}
+{"type":"step/end","seq":59,"time":1786004479487,"data":{"turn":1,"step":1}}
+{"type":"turn/end","seq":60,"time":1786004479487,"data":{"turn":1,"reason":{"kind":"completed"}}}

+ 1 - 0
apps/web/tsconfig.json

@@ -28,6 +28,7 @@
     "tests/live-interactions.e2e.ts",
     "tests/question-composer.e2e.ts",
     "tests/approval-composer.e2e.ts",
+    "tests/plan-chip-overlap.e2e.ts",
     "tests/plan-review.e2e.ts",
     "tests/steering.e2e.ts",
     "tests/navigation-panes.e2e.ts",

+ 7 - 0
packages/client/ui-conversation/src/client/skeleton/InputBar.module.css

@@ -258,6 +258,7 @@
    (figma Input_Bottom chrome). */
 .row {
   display: flex;
+  flex-wrap: wrap;
   align-items: center;
   justify-content: space-between;
   gap: 12px;
@@ -294,6 +295,12 @@
 
 .trailing {
   flex: none;
+  /* Wrap keeps the left mode chips and the right controls apart when the card
+     runs out of row width: the trailing group (model + send) moves to its own
+     line instead of the left group shrinking until its chip overlaps the
+     model trigger (external:107). The auto margin re-anchors it right on the
+     wrapped line; on a single line space-between already pins it right. */
+  margin-left: auto;
   gap: 12px;
 }