Просмотр исходного кода

Merge pull request #1787 from deepseek-harness/feat/plan-narrow-viewport-regression

fix(web): wrap the composer control row so the plan chip never overlaps the model trigger
Chinesezjc 1 месяц назад
Родитель
Сommit
657f52eb21

+ 6 - 0
.agents/notes/implemented/bug-fix/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/bug-fix/2026-08-06-plan-narrow-viewport-regression.md
+2026-08-06-plan-narrow-viewport-regression.md: 945d014e0c51cbaf4080e72f50ee60763d851698
+2026-08-06-plan-narrow-viewport-regression.zh.md: 37060129364c65b02cc1729331fc7334797863db

+ 33 - 0
.agents/notes/implemented/bug-fix/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](../feature/2026-08-04-web-composer-shared-width-axis.md)), 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-control-row.e2e.ts`: enter plan mode with the real `/plan` command (no argument — the command handler commits plan/mode active without a model round, the lifecycle-chrome precedent), so the test needs no model call in any mode and no API key in replay/refresh; a providers-only fixture mounts the model catalog without a script to consume. The file joins the host-plane e2e pairing like every sibling: excluded from the client graph in `apps/web/tsconfig.json` (it imports host-plane types) AND included in the host aggregate in `tsconfig.host.json`, so exactly one TypeScript program owns it — the pairing that also gives the lint type service its program.
+
+The geometry golden records stable facts — viewport membership on both axes and disjoint click areas — never absolute coordinates, whose pixel values depend on installed fonts and differ between macOS and Linux. The behavior assertions implement the acceptance directly: the click areas are disjoint, the click at the chip's center (Playwright's actionability check) leaves plan mode through the real command channel (`/plan off` via `commands.execute`), and the last `plan/mode` event in the session log flips inactive.
+
+## 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; `connectFreshWorkspace` 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 the viewport on either axis fails this test. The test needs no API key in replay/refresh modes: plan mode toggles through the command handler without a model round, and a providers-only replay fixture (no recorded script, consumption check skipped) mounts the model directory so the trigger renders its real long label — the width that made the reported overlap measurable; the test asserts that label before measuring. The golden is compared in replay and record modes and rewritten in refresh mode.

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

@@ -0,0 +1,33 @@
+# Agent Note: 窄视口下 Plan chip 点击区域回归测试
+
+Status: implemented
+
+[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](../feature/2026-08-04-web-composer-shared-width-axis.md)),但该行没有换行,重叠在两次重构后依然存在。
+
+## 决策
+
+控制行换行而不是把左侧组收缩进右侧组的区域:`.row { flex-wrap: wrap }` 加上 `.trailing` 的 `margin-left: auto`——后者把 trailing 组(模型选择 + 发送)重新锚定到换行后的右缘,单行时 `space-between` 已把它钉在右侧。换行是验收中"空间不足时允许换行、折叠或重新排列控件"的选项,保持每个控件全宽(不做会隐藏模型名或 Plan 字样的 label 折叠),并且按构造在所有视口宽度下成立,而非依赖标定的容器查询阈值。
+
+新增 `apps/web/tests/plan-control-row.e2e.ts`:通过真实 `/plan` 命令(无参数——命令 handler 不经模型回合即提交 plan/mode active,lifecycle-chrome 先例)进入 Plan 模式,因此测试在任何模式下都无需模型调用,仅在 replay/refresh 下无需 API key;providers-only fixture 挂载模型目录而无脚本可消费。该文件与所有同类 host 平面 e2e 一样采用成对登记:在 `apps/web/tsconfig.json` 的 exclude 列表(它导入 host 平面类型,client 图绝不编译它),同时在 `tsconfig.host.json` 的 host 聚合 include 中——恰好一个 TypeScript 程序拥有它,这也是 lint 类型服务获得程序的配对方式。
+
+几何 golden 记录稳定事实——两个轴上的视口内位置与点击区域不相交——绝不记录绝对坐标,其像素值依赖安装字体且在 macOS 与 Linux 间不同。行为断言直接实现验收:点击区域不相交、点击 chip 中心(Playwright 的可操作性检查)经真实命令通道(`commands.execute` 执行 `/plan off`)退出 Plan 模式,且会话日志中最后一条 `plan/mode` 事件翻转为 inactive。
+
+## 备选方案
+
+**冷会话 seed(composer-tab-geometry 模式)。** 否决:退出路径经 `commands.execute` 执行 `/plan off`,需要 live agent,而冷 seed 会话没有;`connectFreshWorkspace` 保留一个,与产品的用户路径一致。
+
+**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 沿任一轴移出视口,本测试即失败。测试在 replay/refresh 模式下无需 API key:Plan 模式经命令 handler 切换,不经模型回合;providers-only replay fixture(无录制脚本,跳过消费检查)挂载模型目录,使触发器渲染真实的长标签——正是使报告重叠可测量的宽度;测试在测量前断言该标签。golden 在 replay 与 record 模式下比较,在 refresh 模式下重写。

+ 148 - 0
apps/web/tests/plan-control-row.e2e.ts

@@ -0,0 +1,148 @@
+// Web e2e scenario: at the 800×720 viewport the plan chip and the model
+// trigger keep disjoint click areas, and clicking the chip at its center
+// 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 with no argument:
+// the command handler commits plan/mode active on the live agent without a
+// model round (the lifecycle-chrome precedent), so the test needs no model
+// call in any mode and no API key in replay/refresh; a providers-only
+// fixture mounts the model catalog without a script to consume. Plan state
+// folds from the session log (`plan/mode`, last one wins); the chip executes
+// /plan off through commands.execute, which needs the live agent
+// connectFreshWorkspace keeps.
+//
+// The geometry golden records stable facts — viewport membership on both
+// axes for the chip and the trigger, and disjoint click areas — never
+// absolute coordinates, whose pixel values depend on installed fonts and
+// differ between macOS and Linux. The center hit-test is Playwright's
+// actionability check: clicking the chip fails in a real engine when the
+// element center does not receive pointer events. jsdom resolves no layout,
+// so only a real engine can answer any of these facts.
+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'
+// Type-only: pulls the plan/mode SessionEventMap merge so the discriminant
+// filter below types as the plan-mode event in the host aggregate.
+import type {} from '@deepseek-ai/dsh-plan-mode'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import {
+  assertFixtureInventory, compareOrRefreshGolden,
+  launchWebScaffold, 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'
+
+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 () => {
+    // replayProvidersOnly mounts the provider catalog without any recorded
+    // script to consume (no model call happens — the /plan command never
+    // steers a message), so the model trigger renders its real long label,
+    // which is what made the reported overlap measurable.
+    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayProvidersOnly: true })
+    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'))
+    const input = page.locator('textarea').first()
+    await input.waitFor({ timeout: 10_000 })
+    await input.fill('/plan ')
+    await input.press('Enter')
+
+    // The command handler commits plan/mode active immediately (no model
+    // round), so the chip renders and the composer control row — the surface
+    // under test — is the one visible.
+    const chip = page.getByRole('button', { name: CHIP_ARIA })
+    const trigger = page.getByRole('button', { name: /Select model/ })
+    await chip.waitFor({ timeout: 30_000 })
+    await trigger.waitFor({ timeout: 10_000 })
+    // The regression depends on the real model label width: a bare fallback
+    // trigger would fit beside the chip even on the pre-fix layout. The
+    // directory loads asynchronously, so poll for the real label.
+    await expect.poll(() => trigger.getAttribute('aria-label'), { timeout: 10_000 }).toContain('DeepSeek-V4-Flash')
+    const chipBox = await chip.boundingBox()
+    const triggerBox = await trigger.boundingBox()
+    expect(chipBox).not.toBeNull()
+    expect(triggerBox).not.toBeNull()
+
+    // The reported acceptance as numbers: both controls in viewport and
+    // disjoint click areas (a non-zero overlap would fail), and — in the
+    // click below — the chip center receiving the pointer.
+    const chipInViewport = chipBox!.x >= 0 && chipBox!.x + chipBox!.width <= VIEWPORT.width
+      && chipBox!.y >= 0 && chipBox!.y + chipBox!.height <= VIEWPORT.height
+    const triggerInViewport = triggerBox!.x >= 0 && triggerBox!.x + triggerBox!.width <= VIEWPORT.width
+      && triggerBox!.y >= 0 && triggerBox!.y + triggerBox!.height <= VIEWPORT.height
+    const overlapLeft = Math.max(chipBox!.x, triggerBox!.x)
+    const overlapTop = Math.max(chipBox!.y, triggerBox!.y)
+    const overlapRight = Math.min(chipBox!.x + chipBox!.width, triggerBox!.x + triggerBox!.width)
+    const overlapBottom = Math.min(chipBox!.y + chipBox!.height, triggerBox!.y + triggerBox!.height)
+    const overlapArea = Math.max(0, overlapRight - overlapLeft) * Math.max(0, overlapBottom - overlapTop)
+
+    const golden = [
+      '# Plan chip and model trigger at the 800×720 viewport',
+      '',
+      '- Plan chip fully in viewport: ' + (chipInViewport ? 'true' : 'false'),
+      '- Model trigger fully in viewport: ' + (triggerInViewport ? 'true' : 'false'),
+      '- Click areas disjoint: ' + (overlapArea === 0 ? 'true' : 'false'),
+    ].join('\n').trimEnd()
+    await compareOrRefreshGolden(LAYOUT_EXPECTED, golden, MODE)
+    expect(overlapArea).toBe(0)
+    expect(chipInViewport).toBe(true)
+    expect(triggerInViewport).toBe(true)
+
+    // Exit through the real command channel: the click at the chip's center
+    // executes /plan off and the folded projection flips inactive, so the chip
+    // unmounts. Playwright's click() targets the element center by default and
+    // its actionability check fails the click when that point is covered by
+    // the model trigger — the reported bug as a failing click rather than a
+    // coordinate probe.
+    await chip.click()
+    await expect.poll(() => page.getByRole('button', { name: CHIP_ARIA }).count(), { timeout: 15_000 }).toBe(0)
+    // The click must have committed the exit: the last plan/mode event flips
+    // inactive (the /plan command's entry event stays active:true earlier in
+    // the log, so the pair proves the exit and not just the entry).
+    const planModes = sessionEvents.filter(
+      (event): event is SessionEvent<'plan/mode'> => event.type === 'plan/mode',
+    )
+    expect(planModes.at(-1)?.data.active).toBe(false)
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+  }, 200_000)
+
+  it('keeps the snapshot inventory closed', async () => {
+    await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'layout.expected.md'])
+  })
+})

+ 56 - 8
apps/web/tests/scaffold.ts

@@ -22,7 +22,7 @@
 // llm seam post-boot with installLlmReplay on the settled root ctx
 // (the plugin-row path discards the ReplayHandle; the direct install keeps
 // assertConsumed for the teardown fixture-consumption check).
-import { existsSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
 import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
@@ -179,7 +179,11 @@ export interface WebScaffold {
   harnessHome: string
   /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */
   whenTurnSettled(timeoutMs?: number): Promise<SessionId>
-  /** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */
+  /**
+   * Tear everything down; asserts the replay fixture was fully consumed first
+   * (replay/refresh), unless booted with replayProvidersOnly (whose fixture
+   * is validated call-free at boot).
+   */
   close(): Promise<void>
 }
 
@@ -196,9 +200,20 @@ export interface LaunchOptions {
    * in replay/refresh modes; ignored in record mode (the real adapter
    * answers). Omit for scenarios issuing no model calls — a stray stream then
    * fails loud with NO_ADAPTER (llm-deepseek is disabled and no replay row
-   * mounts).
+   * mounts). With {@link replayProvidersOnly}, the fixture must record no
+   * model calls (its header alone mounts the catalog).
    */
   replayFixture?: string
+  /**
+   * Mount the replay provider catalog (the model directory the UI shows)
+   * without consuming any recorded script: for scenarios that never call a
+   * model but need the real provider/model labels rendered. Requires
+   * {@link replayFixture} whose log records no model calls, and rejects
+   * {@link replayOverride} and {@link replayChildFixtures}; the teardown
+   * consumption check is skipped for this mode. `replayFixture` without this
+   * flag keeps the consumption check.
+   */
+  replayProvidersOnly?: boolean
   /**
    * Recorded child logs assigned in child creation order. Each child owns its
    * own positional replay cursor across initial and continuation turns.
@@ -547,6 +562,36 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
     // disable llm-deepseek; the first-run lane keeps it mounted but has no
     // replay fixture and never streams. The direct install, unlike the plugin
     // row, returns the ReplayHandle for the teardown consumption check.
+    if (options.replayProvidersOnly) {
+      if (options.replayFixture === undefined) {
+        throw new Error('replayProvidersOnly requires replayFixture (its file supplies the header)')
+      }
+      const fixtureText = readFileSync(options.replayFixture, 'utf8')
+      // The consumption check is skipped for this mode, so no script source
+      // may carry callable entries: reject override/child sources outright
+      // and any call-bearing fixture.
+      if (options.replayOverride !== undefined || options.replayChildFixtures !== undefined) {
+        throw new Error('replayProvidersOnly cannot combine with replayOverride or replayChildFixtures')
+      }
+      // A fixture without a session header row must not mount the catalog
+      // silently: the consumption-skip assumes the header-only shape.
+      let headerType: unknown
+      try {
+        headerType = (JSON.parse(fixtureText.trimStart().split('\n', 1)[0] ?? '') as { type?: unknown }).type
+      } catch {
+        headerType = undefined
+      }
+      if (headerType !== 'session') {
+        throw new Error('replayProvidersOnly fixture must open with a session header row')
+      }
+      const recorded = parseSessionLog(fixtureText)
+      const hasModelCall = recorded.some(event => (
+        event.type === 'assistant/chunk' || event.type === 'request/header' || event.type === 'tool/call'
+      ))
+      if (hasModelCall) {
+        throw new Error('replayProvidersOnly fixture must record no model calls')
+      }
+    }
     if (mode !== 'record' && options.replayFixture !== undefined) {
       replayHandle = installLlmReplay(ctx, {
         file: options.replayFixture,
@@ -608,11 +653,14 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
       const failures: unknown[] = []
       // Fixture-consumption check first, while the run's binding state is
       // still authoritative — a scenario that drove fewer model calls than
-      // recorded fails here instead of drifting green.
-      try {
-        replayHandle?.assertConsumed()
-      } catch (error) {
-        failures.push(error)
+      // recorded fails here instead of drifting green. Skipped for
+      // replayProvidersOnly, whose fixture is validated call-free at boot.
+      if (!options.replayProvidersOnly) {
+        try {
+          replayHandle?.assertConsumed()
+        } catch (error) {
+          failures.push(error)
+        }
       }
       try {
         failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot))

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

@@ -0,0 +1,5 @@
+# Plan chip and model trigger at the 800×720 viewport
+
+- Plan chip fully in viewport: true
+- Model trigger fully in viewport: true
+- Click areas disjoint: true

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

@@ -0,0 +1 @@
+{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}

+ 1 - 0
apps/web/tsconfig.json

@@ -30,6 +30,7 @@
     "tests/live-interactions.e2e.ts",
     "tests/question-composer.e2e.ts",
     "tests/approval-composer.e2e.ts",
+    "tests/plan-control-row.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

@@ -275,6 +275,7 @@
    (figma Input_Bottom chrome). */
 .row {
   display: flex;
+  flex-wrap: wrap;
   align-items: center;
   justify-content: space-between;
   gap: 12px;
@@ -311,6 +312,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;
 }
 

+ 1 - 0
tsconfig.host.json

@@ -19,6 +19,7 @@
     "apps/web/tests/live-interactions.e2e.ts",
     "apps/web/tests/question-composer.e2e.ts",
     "apps/web/tests/approval-composer.e2e.ts",
+    "apps/web/tests/plan-control-row.e2e.ts",
     "apps/web/tests/plan-review.e2e.ts",
     "apps/web/tests/steering.e2e.ts",
     "apps/web/tests/navigation-panes.e2e.ts",