fix(web): show plan status only while active

This commit is contained in:
kingwl
2026-07-30 22:42:25 +08:00
parent 2e466dc649
commit 1986de9ac1
30 changed files with 212 additions and 157 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
2026-07-25-web-input-machine-and-slash-pipeline.md: 2793e9045fe5a3c82f52c65503dd4a8cdf6a0596 2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b 2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862

View File

@@ -101,7 +101,7 @@ skill/@subagent references skip the placeholder + occurrence identity chain —
- `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`. - `conversation.input.dock` — the stacked strip above the input (QueueDock's read-only queue list lands here), ordered by `order`.
- `conversation.composer.dock` — the stats band on the composer's top edge. - `conversation.composer.dock` — the stats band on the composer's top edge.
- `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions. - `conversation.input.left` / `conversation.input.right` — the tool-row left and right regions.
- `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. - `conversation.input.plan` / `conversation.input.model` (single) — the tool row's two named control seats; the bar passes only `locked` (owner props), each stays empty until its owning plugin registers, no placeholder fallback. The plan seat stays empty while inactive because the shared Command source owns entry; an effective plan target renders the warn-state `Plan ×` status button, whose only action is `/plan off`.
- `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current. - `conversation.hero.workspace` (root scope) — the Workspace picker shared by the no-session and blank Hero; a pick reuses or creates the target blank session through `connectWorkspace`, moving the draft where necessary before switching current.
### Testing discipline ### Testing discipline
@@ -122,6 +122,7 @@ The state machine's entire behavior is covered by pure-JS unit tests (event sequ
| Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only | | Space adjudication also claiming execute-kind commands | The misfire defense: after a space the whole line is an ordinary prompt; irreversible side effects keep explicit entry points only |
| A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning | | A generic tokenPattern decoration mechanism | Structured occurrence records replace pattern scanning |
| A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth | | A placeholder select resident in the tool row | Named seats stay empty until registration; a placeholder clashing with the real implementation is two sources of truth |
| An always-visible Plan on/off toggle | The shared Command source already owns entry; a second entry point turns a status seat into redundant mode chrome |
| A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability | | A second plus-menu component/controller, or an Add/File group above Command | It would duplicate async candidates, keyboard highlight, focus retention, and pick state; the plus control is only a source-filtered launcher for the existing MenuView, and this scope has no file capability |
| All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity | | All references through U+FFFC chips (the pre-Decision-21 line) | Plain text + derived decoration carries zero identity state; the literal text IS the model projection, sparing undo/clipboard any special cases; the chip chain is kept for scenarios needing indivisible atomicity |

View File

@@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
- `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。 - `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
- `conversation.composer.dock`——composer 上沿统计带。 - `conversation.composer.dock`——composer 上沿统计带。
- `conversation.input.left` / `conversation.input.right`——工具行左右区。 - `conversation.input.left` / `conversation.input.right`——工具行左右区。
- `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。 - `conversation.input.plan` / `conversation.input.model`(single)——工具行两具名控制位;bar 只传 `locked`(owner props),空到 owning 插件注册为止,无占位 fallback。plan seat 未激活时保持为空,因为入口归共享 Command source 所有;有效 plan 目标会渲染 warn 状态的 `Plan ×` 状态按钮,其唯一动作是 `/plan off`。
- `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。 - `conversation.hero.workspace`(root scope)——无 session / blank Hero 共用的 Workspace picker;pick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。
### 测试纪律 ### 测试纪律
@@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
| 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 | | 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 |
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 | | 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
| 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 | | 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 |
| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome |
| 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 | | 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 |
| 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 | | 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 |

View File

@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md')
const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md')
const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md')
// Post-reload golden: the same settled conversation rebuilt purely from // Post-reload golden: the same settled conversation rebuilt purely from
// persistence + history — byte-equal rendering is exactly the recovery claim. // persistence + history — byte-equal rendering is exactly the recovery claim.
const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md') const RELOADED_EXPECTED = join(SNAPSHOT_DIR, 'reloaded.expected.md')
@@ -85,6 +86,60 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await expect.poll(() => menu.count()).toBe(0) await expect.poll(() => menu.count()).toBe(0)
}) })
it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => {
const activeScaffold = await launchWebScaffold()
const activePage = await newEnglishPage(browser)
const activeTripwire = watchConsole(activePage)
try {
await activePage.goto(activeScaffold.baseUrl, { waitUntil: 'load' })
await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(activePage)
const input = activePage.locator('textarea').first()
await activePage.getByRole('button', { name: 'Commands' }).click()
const menu = activePage.getByRole('listbox', { name: 'Trigger suggestions' })
await menu.waitFor({ timeout: 10_000 })
await menu.getByRole('option', { name: 'plan Enter or leave plan mode' }).click()
await expect.poll(() => input.inputValue()).toBe('/plan ')
await input.press('Enter')
const planButton = activePage.getByRole('button', { name: 'Plan mode on, press to turn off' })
await planButton.waitFor({ timeout: 10_000 })
const planSnapshot = await captureStableAria(activePage, '[class*="frame"]', activeScaffold.workspaceCwd)
await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE)
const planStyle = await planButton.evaluate((element) => {
const probe = document.createElement('span')
probe.style.color = 'var(--dsw-alias-state-warn-label)'
probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)'
document.body.append(probe)
const actual = getComputedStyle(element)
const reference = getComputedStyle(probe)
const result = {
color: actual.color,
backgroundColor: actual.backgroundColor,
borderRadius: actual.borderRadius,
fontSize: actual.fontSize,
referenceColor: reference.color,
referenceBackgroundColor: reference.backgroundColor,
}
probe.remove()
return result
})
expect(planStyle.color).toBe(planStyle.referenceColor)
expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor)
expect(planStyle.borderRadius).toBe('999px')
expect(planStyle.fontSize).toBe('13px')
await planButton.click()
await expect.poll(() => planButton.count()).toBe(0)
expect(activeTripwire.pageErrors).toEqual([])
expect(activeTripwire.warnings).toEqual([])
} catch (error) {
await saveFailureShot(activePage, 'web-e2e-plan-active').catch(() => undefined)
throw error
} finally {
await activePage.close()
await activeScaffold.close()
}
})
it('sends the first prompt from the empty-state hero (all modes)', async () => { it('sends the first prompt from the empty-state hero (all modes)', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
if (MODE !== 'record') { if (MODE !== 'record') {
@@ -182,7 +237,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
expect(tripwire.warnings).toEqual([]) expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, [ await assertFixtureInventory(SNAPSHOT_DIR, [
'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'reloaded.expected.md', 'session.jsonl', 'command-menu.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md',
]) ])
}) })
}) })

View File

@@ -36,7 +36,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -50,7 +50,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -33,7 +33,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -29,7 +29,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -0,0 +1,39 @@
- button "New session"
- button "Collapse sidebar":
- img
- button "New session":
- img
- text: New Session
- text: Workspaces
- button "Group by":
- img
- button "Create workspace":
- img
- button "Search sessions":
- img
- textbox "Search name, keywords..."
- tree "Sessions":
- treeitem "workspace 1 session" [expanded]:
- img
- text: workspace 1 session
- treeitem "New Session now" [selected]
- button "Settings":
- img
- text: Settings
- text: Let's start building
- button "Choose workspace":
- img
- text: workspace
- img
- textbox "Describe what you want to build"
- button "Commands":
- img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode on, press to turn off": Plan
- button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash
- img
- button "Send message" [disabled]
- text: 详情
- button "关闭详情"
- text: 点击消息流中的工具行查看详情

View File

@@ -25,7 +25,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -22,7 +22,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -15,7 +15,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -25,7 +25,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -36,7 +36,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash": - button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash - text: deepseek-v4-flash
- img - img

View File

@@ -33,7 +33,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -29,7 +29,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -23,7 +23,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -39,7 +39,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current deepseek-v4-flash": - button "Select model, current deepseek-v4-flash":
- text: deepseek-v4-flash - text: deepseek-v4-flash
- img - img

View File

@@ -33,7 +33,6 @@
- button "Commands": - button "Commands":
- img - img
- 'button "Access mode, current: Danger Full Access"': Danger Full Access - 'button "Access mode, current: Danger Full Access"': Danger Full Access
- button "Plan mode off, press to turn on": Plan off
- button "Select model, current DeepSeek-V4-Flash": - button "Select model, current DeepSeek-V4-Flash":
- text: DeepSeek-V4-Flash - text: DeepSeek-V4-Flash
- img - img

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md # pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540 README.md: 2f83dd738e11ada5b24a55d1eec97e154ffd2aea
README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391 README.zh.md: a026390191179f6492a55ee931a3406d8e1953e8

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster. Plan-mode status chip, a pure browser surface plugin. The browser half occupies the conversation-declared `conversation.input.plan` single seat (to the right of the access-mode control); the node half is an empty apply (the roster row). Plan behavior itself — the `/plan` command, the boundary-or-idle-committed `plan/mode` state, the `plan` projection unit, and the policy section — is owned by [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md), composed independently on the host roster.
Plan mode is entered through the `/plan` command only; there is no UI control that turns it on. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders a read-only "Plan" chip whose hover × executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win). Plan mode is entered through the `/plan` command path: users can choose Plan from the composer's `+` Command menu or type `/plan`, while this package renders no inactive plan control. While the host-computed `plan` projection's effective target is plan mode (`pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way), the seat renders the warn-colored "Plan ×" status button, which executes `/plan off` through `command.execute`; otherwise the seat stays empty — a host without plan-mode (or a Draft with no session) shows nothing. While plan mode is the effective target, the composer textarea's placeholder switches to the plan-task hint — "describe your task to generate plan", localized through ui-conversation's `command.hint` locale namespace and shared verbatim with the claimed `/plan` command hint (rendered by the composer from the same projection; owner-supplied placeholders win).
The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit. The chip carries the accessible description "Plan mode on, press to turn off". Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
@@ -22,4 +22,4 @@ Entering or leaving plan mode changes the active `plan:policy` system-prompt sec
- **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies. - **Plan mode is guidance, not an execution sandbox** — deployments that require enforced read-only planning must compose the independent sandbox and approval policies.
- **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip. - **The chip belongs to the default composer** — a pending whole-composer interaction such as plan review temporarily replaces the InputBar and its chip.
- **No UI entry point** — plan mode is entered by typing `/plan`; a session with the capability but inactive mode shows no affordance in the tool row. - **No inactive plan control** — entry uses the shared Command source; a session with the capability but inactive mode shows no plan affordance in the tool row.

View File

@@ -4,7 +4,7 @@
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。 Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 apply(roster 行)。plan 行为本身——`/plan` 命令、边界或空闲即时提交的 `plan/mode` 状态、`plan` 投影单元与 policy 段——归 [`@deepseek-ai/dsh-plan-mode`](../../plan/plan-mode/README.md) 所有,由 host roster 独立组合。
plan mode 只经 `/plan` 命令进入;UI 上没有打开它的控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染一个只读 "Plan" chip,hover 出现的 × 经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。 plan mode 经 `/plan` 命令路径进入:用户可以从 composer 的 `+` Command 菜单选择 Plan,也可以输入 `/plan`,而本包(package)不渲染未激活态 plan 控件。当 host 计算的 `plan` 投影有效目标为 plan mode 时(`pending ? !active : active`——折叠的 host 值而非客户端乐观态,帧到达即自动纠正),座位渲染 warn 色的 "Plan ×" 状态按钮,该按钮经 `command.execute` 执行 `/plan off`;否则座位保持为空——未组合 plan-mode 的 host(或尚无会话的 Draft)不显示任何内容。plan mode 为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——"describe your task to generate plan"(中文「描述你的任务以生成计划」),经 ui-conversation 的 `command.hint` locale 命名空间本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(由 composer 从同一投影渲染;owner 提供的 placeholder 优先)。
chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。 chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,chip 保持显示直至投影确认退出。
@@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m
- **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。 - **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。
- **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。 - **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。
- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。 - **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。

View File

@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-client-connection": "^0.0.1", "@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1", "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-plan-mode": "^0.0.1", "@deepseek-ai/dsh-plan-mode": "^0.0.1",
@@ -48,6 +49,7 @@
"@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^",

View File

@@ -1,5 +1,4 @@
/* Plan-mode toggle chip: quiet while off; the pressed state takes the /* Active plan status follows Figma's warn-state pill. */
business accent pair (same token pairing as the trajectory user badge). */
.wrap { .wrap {
display: inline-flex; display: inline-flex;
@@ -10,30 +9,25 @@
.chip { .chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 8px; gap: 4px;
min-width: 34px;
padding: 2px 8px;
border: none; border: none;
border-radius: 8px; border-radius: 999px;
background: transparent; background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-label-secondary); color: var(--dsw-alias-state-warn-label);
font-size: 14px; font-size: 13px;
font-weight: 500;
line-height: 20px; line-height: 20px;
cursor: pointer; cursor: pointer;
} }
.chip:hover:not(:disabled) { .chip:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-warn-primary);
}
/* Hovering keeps the pressed accent: the higher-specificity hover rule above
would otherwise swap it back to the neutral hover wash. */
.chip[aria-pressed='true'],
.chip[aria-pressed='true']:hover:not(:disabled) {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
} }
.chip:focus-visible { .chip:focus-visible {
outline: 2px solid var(--dsw-alias-label-secondary); outline: 2px solid var(--dsw-alias-state-warn-label);
outline-offset: 2px; outline-offset: 2px;
} }
@@ -42,6 +36,12 @@
cursor: default; cursor: default;
} }
.close {
display: inline-flex;
align-items: center;
color: currentColor;
}
.error { .error {
color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary);
font-size: 12px; font-size: 12px;

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and
// its {locked} owner share). // its {locked} owner share).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -11,16 +12,14 @@ export type PlanChipProps =
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected> PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
/** /**
* Plan-mode toggle over the host-computed `plan` projection. The chip renders * Plan-mode status over the host-computed `plan` projection. The chip renders
* whenever the capability is present and reflects the effective target as its * only while the effective target is plan mode (`pending ? !active : active`
* pressed state (`pending ? !active : active` — a folded host value, not * — a folded host value, not client optimism) and executes /plan off.
* client optimism, so an arriving frame corrects it). Clicking executes
* /plan or /plan off toward the opposite target.
*/ */
export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) { export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
const plan = useProjection('plan') const plan = useProjection('plan')
const [busy, setBusy] = useState(false) const [leaving, setLeaving] = useState(false)
const [error, setError] = useState<{ text: string; detail: string } | null>(null) const [error, setError] = useState<string | null>(null)
const aliveRef = useRef(true) const aliveRef = useRef(true)
useEffect(() => { useEffect(() => {
@@ -30,25 +29,21 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps)
} }
}, []) }, [])
// Absent capability (no plan-mode host plugin / no session yet): no seat
// content — without the capability there is nothing to toggle.
if (plan === undefined) return null if (plan === undefined) return null
const target = plan.pending ? !plan.active : plan.active const target = plan.pending ? !plan.active : plan.active
if (!target) return null
const toggle = (): void => { const off = (): void => {
// No busy/locked guard: both disable the button, so no click arrives. setLeaving(true)
const on = !target
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
setBusy(true)
setError(null) setError(null)
void setPlanMode(on).then((failure) => { void exitPlanMode().then((failure) => {
if (!aliveRef.current) return if (!aliveRef.current) return
setBusy(false) setLeaving(false)
setError(failure === null ? null : { text: failText, detail: failure }) setError(failure)
}, (reason: unknown) => { }, (reason: unknown) => {
if (!aliveRef.current) return if (!aliveRef.current) return
setBusy(false) setLeaving(false)
setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) }) setError(reason instanceof Error ? reason.message : String(reason))
}) })
} }
@@ -57,17 +52,17 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps)
<button <button
type="button" type="button"
className={css.chip} className={css.chip}
aria-pressed={target} aria-label="Plan mode on, press to turn off"
aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'} title="Plan mode on — click to turn off (/plan off)"
title={target disabled={locked || leaving}
? 'Plan mode on — click to turn off (/plan off)' onClick={off}
: 'Plan mode off — click to turn on (/plan)'}
disabled={locked || busy}
onClick={toggle}
> >
Plan { target ? 'on' : 'off' } Plan
<span className={css.close} aria-hidden>
<IconCloseFill14 size={12} />
</span>
</button> </button>
{error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>} {error !== null && <span className={css.error} role="status" title={error}>退出 plan mode 失败</span>}
</span> </span>
) )
} }

View File

@@ -1,11 +1,11 @@
/** /**
* Plan control plugin, browser half: occupies the composer's named * Plan control plugin, browser half: occupies the composer's named
* `conversation.input.plan` seat with a plan-mode toggle chip. While the * `conversation.input.plan` seat with an active-state status chip. Plan mode
* `plan` projection is present the chip renders in both states and executes * is entered through the command source; while the projection's effective
* /plan or /plan off through `command.execute` toward the opposite target; * target is plan mode the chip renders and executes /plan off through
* an absent projection (no capability) leaves the seat empty. Reads ride the * `command.execute`, otherwise the seat stays empty. Reads ride the generic
* generic projection pair through the standard-kit `useProjection` (an absent * projection pair through the standard-kit `useProjection`; zero client-side
* key is capability absence); zero client-side plan state. * plan state.
*/ */
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
@@ -18,11 +18,10 @@ import { PlanChip } from './PlanModeControl.tsx'
/** Injected business face of the composer plan seat. */ /** Injected business face of the composer plan seat. */
export interface PlanChipInjected { export interface PlanChipInjected {
/** /**
* Switch plan mode by executing /plan (on) or /plan off. * Leave plan mode by executing /plan off.
* @param on - desired target: true enters plan mode, false leaves it.
* @returns null on admitted execution; a user-visible failure line otherwise. * @returns null on admitted execution; a user-visible failure line otherwise.
*/ */
setPlanMode: (on: boolean) => Promise<string | null> exitPlanMode: () => Promise<string | null>
} }
/** /**
@@ -39,12 +38,11 @@ export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.slots.register({ ctx.effect(() => ctx.slots.register({
name: 'conversation.input.plan', name: 'conversation.input.plan',
inject: (sessionId: SessionId): PlanChipInjected => ({ inject: (sessionId: SessionId): PlanChipInjected => ({
setPlanMode: async (on) => { exitPlanMode: async () => {
const line = on ? '/plan' : '/plan off'
const connection = ctx.get('connection') as ConnectionHandle const connection = ctx.get('connection') as ConnectionHandle
const { result } = await connection.api.commands.execute({ sessionId, line }) const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
if (!result.ok) return `${result.error.message}(${result.error.code})` if (!result.ok) return `${result.error.message}(${result.error.code})`
if (!result.value.matched) return `未知命令:${line}` if (!result.value.matched) return '未知命令:/plan off'
return null return null
}, },
}), }),

View File

@@ -1,9 +1,9 @@
/** /**
* ui-plan browser half on a real SlotsService: the plugin occupies the * ui-plan browser half on a real SlotsService: the plugin occupies the
* conversation-declared `conversation.input.plan` single seat with the plan * conversation-declared `conversation.input.plan` single seat with the active
* toggle chip; the injected face executes /plan or /plan off by direction and * plan status chip; the injected face executes /plan off and folds admission
* folds admission outcomes into null (admitted) or a user-visible failure * outcomes into null (admitted) or a user-visible failure line; teardown
* line; teardown empties the seat (HMR safety). * empties the seat (HMR safety).
*/ */
import { Context } from 'cordis' import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
@@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => {
.rejects.toThrow(/slot "conversation.input.plan" is not declared/) .rejects.toThrow(/slot "conversation.input.plan" is not declared/)
}) })
it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => { it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
const b = await bench() const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply }) const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await() await fiber.await()
@@ -57,22 +57,20 @@ describe('ui-plan browser apply', () => {
expect(entry.component).toBe(PlanChip) expect(entry.component).toBe(PlanChip)
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID) const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
await expect(injected.setPlanMode(false)).resolves.toBeNull() await expect(injected.exitPlanMode()).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' }) expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
await expect(injected.setPlanMode(true)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
// Business failure folds to the composer-visible line. // Business failure folds to the composer-visible line.
b.execute.mockResolvedValueOnce({ b.execute.mockResolvedValueOnce({
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } }, result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
} as never) } as never)
await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)') await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)')
// Unmatched admission (plan-mode not composed host-side) is also a failure line. // Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({ b.execute.mockResolvedValueOnce({
result: { ok: true as const, value: { matched: false as const } }, result: { ok: true as const, value: { matched: false as const } },
} as never) } as never)
await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan') await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
await fiber.dispose() await fiber.dispose()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0) expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)

View File

@@ -1,11 +1,9 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* PlanChip over the `plan` projection: nothing renders while the capability * PlanChip over the `plan` projection: nothing renders while the capability
* is absent; with the capability present the chip renders in both states with * is absent or the effective target is the default mode; while plan mode is
* aria-pressed following the effective target (pending folds — /plan shows * the target, the chip executes /plan off and remains visible through failures
* pressed immediately, /plan off unpressed immediately); clicking executes * until the projection confirms the exit.
* the command toward the opposite target and surfaces direction-specific
* failures while the projection still owns the displayed state.
*/ */
import { afterEach, describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -18,98 +16,74 @@ afterEach(cleanup)
function setup( function setup(
plan: PlanProjection | undefined, plan: PlanProjection | undefined,
setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)), exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
locked = false, locked = false,
) { ) {
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan }) const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
const useProjection = (_key: string, selector?: (v: unknown) => unknown) => const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value)) bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps
const view = render(<PlanChip {...props} />) const view = render(<PlanChip {...props} />)
return { store, setPlanMode, view } return { store, exitPlanMode, view }
} }
const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' }) const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
describe('PlanChip', () => { describe('PlanChip', () => {
it('renders nothing while the capability is absent', () => { it('renders nothing for an absent capability or a default-mode target', () => {
const absent = setup(undefined) const absent = setup(undefined)
expect(absent.view.container.innerHTML).toBe('') expect(absent.view.container.innerHTML).toBe('')
cleanup()
const inactive = setup({ active: false, pending: false })
expect(inactive.view.container.innerHTML).toBe('')
cleanup()
const leaving = setup({ active: true, pending: true })
expect(leaving.view.container.innerHTML).toBe('')
}) })
it('reflects the effective target as the pressed state, folding pending', () => { it('renders the Plan status for active and pending-entry targets', () => {
setup({ active: false, pending: false })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
cleanup()
setup({ active: true, pending: false }) setup({ active: true, pending: false })
expect(onChip().getAttribute('aria-pressed')).toBe('true') expect(chip().textContent).toBe('Plan')
cleanup() cleanup()
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
setup({ active: false, pending: true }) setup({ active: false, pending: true })
expect(onChip().getAttribute('aria-pressed')).toBe('true') expect(chip().textContent).toBe('Plan')
cleanup()
// Active with a pending exit: the target is default — already unpressed.
setup({ active: true, pending: true })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
}) })
it('unpressed chip executes /plan (on) once and follows the projection up', async () => { it('executes /plan off once and follows the projection down', async () => {
let resolve!: (value: string | null) => void let resolve!: (value: string | null) => void
const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done })) const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: false, pending: false }, setPlanMode) const { store } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(offChip()) fireEvent.click(chip())
expect(setPlanMode).toHaveBeenCalledTimes(1) expect(exitPlanMode).toHaveBeenCalledTimes(1)
expect(setPlanMode).toHaveBeenLastCalledWith(true) fireEvent.click(chip())
// Busy while its own call is in flight. expect(exitPlanMode).toHaveBeenCalledTimes(1)
fireEvent.click(offChip())
expect(setPlanMode).toHaveBeenCalledTimes(1)
resolve(null) resolve(null)
// The command's run record folds: target flips, the chip presses.
store.set({ value: { active: false, pending: true } })
await waitFor(() => {
expect(onChip().getAttribute('aria-pressed')).toBe('true')
})
})
it('pressed chip executes /plan off and follows the projection down', async () => {
const setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null))
const { store } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(onChip())
expect(setPlanMode).toHaveBeenLastCalledWith(false)
store.set({ value: { active: true, pending: true } }) store.set({ value: { active: true, pending: true } })
await waitFor(() => { await waitFor(() => {
expect(offChip().getAttribute('aria-pressed')).toBe('false') expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull()
}) })
}) })
it('disables under the locked owner prop', () => { it('disables under the locked owner prop', () => {
setup({ active: true, pending: false }, vi.fn(), true) setup({ active: true, pending: false }, vi.fn(), true)
expect((onChip() as HTMLButtonElement).disabled).toBe(true) expect((chip() as HTMLButtonElement).disabled).toBe(true)
}) })
it('surfaces direction-specific admission and transport failures while staying visible', async () => { it('surfaces admission and transport failures while staying visible', async () => {
const exitFailing = vi.fn() const exitPlanMode = vi.fn()
.mockResolvedValueOnce('host said no') .mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down')) .mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed') .mockRejectedValueOnce('socket closed')
setup({ active: true, pending: false }, exitFailing) setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(onChip()) fireEvent.click(chip())
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
expect(onChip()).toBeTruthy() expect(chip()).toBeTruthy()
fireEvent.click(onChip()) fireEvent.click(chip())
expect(await screen.findByTitle('network down')).toBeTruthy() expect(await screen.findByTitle('network down')).toBeTruthy()
fireEvent.click(onChip()) fireEvent.click(chip())
expect(await screen.findByTitle('socket closed')).toBeTruthy() expect(await screen.findByTitle('socket closed')).toBeTruthy()
cleanup()
const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
setup({ active: false, pending: false }, enterFailing)
fireEvent.click(offChip())
expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
expect(offChip()).toBeTruthy()
}) })
it('ignores in-flight fulfillment and rejection after unmount', () => { it('ignores in-flight fulfillment and rejection after unmount', () => {
@@ -118,14 +92,14 @@ describe('PlanChip', () => {
{ active: true, pending: false }, { active: true, pending: false },
vi.fn(() => new Promise<string | null>((done) => { resolve = done })), vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
) )
fireEvent.click(onChip()) fireEvent.click(chip())
successful.view.unmount() successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow() expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void let reject!: (reason: unknown) => void
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail })) const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, setPlanMode) const { view } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(onChip()) fireEvent.click(chip())
view.unmount() view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow() expect(() => { reject(new Error('late')) }).not.toThrow()
}) })

View File

@@ -20,6 +20,9 @@
{ {
"path": "../ui-conversation" "path": "../ui-conversation"
}, },
{
"path": "../ui-primitives"
},
{ {
"path": "../ui-slots" "path": "../ui-slots"
}, },

3
pnpm-lock.yaml generated
View File

@@ -1367,6 +1367,9 @@ importers:
'@deepseek-ai/dsh-client-ui-conversation': '@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^ specifier: workspace:^
version: link:../ui-conversation version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots': '@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^ specifier: workspace:^
version: link:../ui-slots version: link:../ui-slots