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
@@ -2,5 +2,5 @@
# 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/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.zh.md: e3a35c4e55525fedd835eace973f114bd15da37b
2026-07-25-web-input-machine-and-slash-pipeline.md: c3deadb34d3a633525dde701c92bcc98c05e5d6e
2026-07-25-web-input-machine-and-slash-pipeline.zh.md: 7a6988423dcdffebb0a28735146439c8ade0a862
@@ -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.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.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.
### 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 |
| 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 |
| 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 |
| 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 |
@@ -101,7 +101,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
- `conversation.input.dock`——输入上方堆叠条(QueueDock 的队列只读列表落此),order 定序。
- `conversation.composer.dock`——composer 上沿统计带。
- `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 pickerpick 经 `connectWorkspace` 复用或创建目标 blank session,必要时搬运 draft 后切 current。
### 测试纪律
@@ -122,6 +122,7 @@ skill/@subagent 引用不走占位符 + occurrence 身份链——pick 直接把
| 空格裁决也认领即执行型命令 | 误触发防线:空格后整行是普通 prompt;不可逆副作用只留显式入口 |
| 通用 tokenPattern 装饰机制 | 结构化 occurrence 记录取代模式扫描 |
| 占位 select 常驻工具行 | 具名坑位空到注册为止;占位件与真实现冲突时是双真相源 |
| 始终可见的 Plan 开/关切换 | 入口已归共享 Command source 所有;第二个入口会把状态 seat 变成冗余的 mode chrome |
| 第二套加号菜单组件/controller,或在 Command 上方增加 Add/File 分组 | 这会重复异步候选、键盘高亮、焦点保留与 pick 状态;加号控件只是既有 MenuView 按 source 过滤的 launcher,且此 scope 没有文件能力 |
| 引用一律走 U+FFFC chip(决策 21 前旧线) | 纯文本 + 派生装饰零身份状态;原文即模型投影,undo/剪贴板免特判;chip 链保留给需要不可分原子性的场景 |
+56 -1
View File
@@ -26,6 +26,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', impor
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.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
// persistence + history — byte-equal rendering is exactly the recovery claim.
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)
})
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 () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send'))
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 () => {
expect(tripwire.warnings).toEqual([])
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',
])
})
})
@@ -36,7 +36,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -50,7 +50,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -33,7 +33,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -29,7 +29,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -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: 点击消息流中的工具行查看详情
@@ -25,7 +25,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -22,7 +22,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -15,7 +15,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -25,7 +25,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -36,7 +36,6 @@
- button "Commands":
- img
- '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":
- text: deepseek-v4-flash
- img
@@ -33,7 +33,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -29,7 +29,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -23,7 +23,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
@@ -39,7 +39,6 @@
- button "Commands":
- img
- '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":
- text: deepseek-v4-flash
- img
@@ -33,7 +33,6 @@
- button "Commands":
- img
- '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":
- text: DeepSeek-V4-Flash
- img
+2 -2
View File
@@ -2,5 +2,5 @@
# 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 packages/client/ui-plan/README.md
README.md: 1d22c057b439ff337bf9daadcdba96dd4cca4540
README.zh.md: 183b8ef7776b60c1f0afa630e04627a474d40391
README.md: 2f83dd738e11ada5b24a55d1eec97e154ffd2aea
README.zh.md: a026390191179f6492a55ee931a3406d8e1953e8
+2 -2
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 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.
@@ -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.
- **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.
+2 -2
View File
@@ -4,7 +4,7 @@
Plan mode 状态徽章,纯浏览器 surface 插件。浏览器侧占据会话声明的 `conversation.input.plan` 单座(位于 access 模式控件右侧);node 侧是空 applyroster 行)。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" chiphover 出现的 × `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 保持显示直至投影确认退出。
@@ -22,4 +22,4 @@ chip 携带无障碍描述 "Plan mode on, press to turn off"。准入失败(`m
- **Plan mode 是引导而非执行沙箱**——需要强制只读规划的部署必须组合独立的沙箱与审批策略。
- **chip 属于默认编辑器**——待处理的整编辑器交互(如 plan 评审)会临时取代 InputBar 及其 chip。
- **无 UI 进入点**——plan mode 靠敲 `/plan` 进入;有能力但未激活的会话在工具行不显示任何入口。
- **无未激活态 plan 控件**——入口使用共享 Command source;有能力但 mode 未激活的会话在工具行不显示 plan 入口。
+2
View File
@@ -38,6 +38,7 @@
"@deepseek-ai/dsh-client-connection": "^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-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^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-runtime": "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-web-react": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
@@ -1,5 +1,4 @@
/* Plan-mode toggle chip: quiet while off; the pressed state takes the
business accent pair (same token pairing as the trajectory user badge). */
/* Active plan status follows Figma's warn-state pill. */
.wrap {
display: inline-flex;
@@ -10,30 +9,25 @@
.chip {
display: inline-flex;
align-items: center;
padding: 4px 8px;
gap: 4px;
min-width: 34px;
padding: 2px 8px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
border-radius: 999px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-label);
font-size: 13px;
font-weight: 500;
line-height: 20px;
cursor: pointer;
}
.chip:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
/* 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);
color: var(--dsw-alias-state-warn-primary);
}
.chip:focus-visible {
outline: 2px solid var(--dsw-alias-label-secondary);
outline: 2px solid var(--dsw-alias-state-warn-label);
outline-offset: 2px;
}
@@ -42,6 +36,12 @@
cursor: default;
}
.close {
display: inline-flex;
align-items: center;
color: currentColor;
}
.error {
color: var(--dsw-alias-state-error-primary);
font-size: 12px;
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
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
// its {locked} owner share).
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
@@ -11,16 +12,14 @@ export type PlanChipProps =
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
/**
* Plan-mode toggle over the host-computed `plan` projection. The chip renders
* whenever the capability is present and reflects the effective target as its
* pressed state (`pending ? !active : active` — a folded host value, not
* client optimism, so an arriving frame corrects it). Clicking executes
* /plan or /plan off toward the opposite target.
* Plan-mode status over the host-computed `plan` projection. The chip renders
* only while the effective target is plan mode (`pending ? !active : active`
* — a folded host value, not client optimism) and executes /plan off.
*/
export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
const plan = useProjection('plan')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<{ text: string; detail: string } | null>(null)
const [leaving, setLeaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const aliveRef = useRef(true)
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
const target = plan.pending ? !plan.active : plan.active
if (!target) return null
const toggle = (): void => {
// No busy/locked guard: both disable the button, so no click arrives.
const on = !target
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
setBusy(true)
const off = (): void => {
setLeaving(true)
setError(null)
void setPlanMode(on).then((failure) => {
void exitPlanMode().then((failure) => {
if (!aliveRef.current) return
setBusy(false)
setError(failure === null ? null : { text: failText, detail: failure })
setLeaving(false)
setError(failure)
}, (reason: unknown) => {
if (!aliveRef.current) return
setBusy(false)
setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
setLeaving(false)
setError(reason instanceof Error ? reason.message : String(reason))
})
}
@@ -57,17 +52,17 @@ export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps)
<button
type="button"
className={css.chip}
aria-pressed={target}
aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
title={target
? 'Plan mode on — click to turn off (/plan off)'
: 'Plan mode off — click to turn on (/plan)'}
disabled={locked || busy}
onClick={toggle}
aria-label="Plan mode on, press to turn off"
title="Plan mode on — click to turn off (/plan off)"
disabled={locked || leaving}
onClick={off}
>
Plan { target ? 'on' : 'off' }
Plan
<span className={css.close} aria-hidden>
<IconCloseFill14 size={12} />
</span>
</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>
)
}
+11 -13
View File
@@ -1,11 +1,11 @@
/**
* Plan control plugin, browser half: occupies the composer's named
* `conversation.input.plan` seat with a plan-mode toggle chip. While the
* `plan` projection is present the chip renders in both states and executes
* /plan or /plan off through `command.execute` toward the opposite target;
* an absent projection (no capability) leaves the seat empty. Reads ride the
* generic projection pair through the standard-kit `useProjection` (an absent
* key is capability absence); zero client-side plan state.
* `conversation.input.plan` seat with an active-state status chip. Plan mode
* is entered through the command source; while the projection's effective
* target is plan mode the chip renders and executes /plan off through
* `command.execute`, otherwise the seat stays empty. Reads ride the generic
* projection pair through the standard-kit `useProjection`; zero client-side
* plan state.
*/
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/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. */
export interface PlanChipInjected {
/**
* Switch plan mode by executing /plan (on) or /plan off.
* @param on - desired target: true enters plan mode, false leaves it.
* Leave plan mode by executing /plan off.
* @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({
name: 'conversation.input.plan',
inject: (sessionId: SessionId): PlanChipInjected => ({
setPlanMode: async (on) => {
const line = on ? '/plan' : '/plan off'
exitPlanMode: async () => {
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.value.matched) return `未知命令:${line}`
if (!result.value.matched) return '未知命令:/plan off'
return null
},
}),
@@ -1,9 +1,9 @@
/**
* ui-plan browser half on a real SlotsService: the plugin occupies the
* conversation-declared `conversation.input.plan` single seat with the plan
* toggle chip; the injected face executes /plan or /plan off by direction and
* folds admission outcomes into null (admitted) or a user-visible failure
* line; teardown empties the seat (HMR safety).
* conversation-declared `conversation.input.plan` single seat with the active
* plan status chip; the injected face executes /plan off and folds admission
* outcomes into null (admitted) or a user-visible failure line; teardown
* empties the seat (HMR safety).
*/
import { Context } from 'cordis'
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/)
})
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 fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
@@ -57,22 +57,20 @@ describe('ui-plan browser apply', () => {
expect(entry.component).toBe(PlanChip)
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' })
await expect(injected.setPlanMode(true)).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
// Business failure folds to the composer-visible line.
b.execute.mockResolvedValueOnce({
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
} as never)
await expect(injected.setPlanMode(false)).resolves.toBe('gonesession-not-found')
await expect(injected.exitPlanMode()).resolves.toBe('gonesession-not-found')
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({
result: { ok: true as const, value: { matched: false as const } },
} as never)
await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan')
await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
await fiber.dispose()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
@@ -1,11 +1,9 @@
// @vitest-environment jsdom
/**
* PlanChip over the `plan` projection: nothing renders while the capability
* is absent; with the capability present the chip renders in both states with
* aria-pressed following the effective target (pending folds — /plan shows
* pressed immediately, /plan off unpressed immediately); clicking executes
* the command toward the opposite target and surfaces direction-specific
* failures while the projection still owns the displayed state.
* is absent or the effective target is the default mode; while plan mode is
* the target, the chip executes /plan off and remains visible through failures
* until the projection confirms the exit.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -18,98 +16,74 @@ afterEach(cleanup)
function setup(
plan: PlanProjection | undefined,
setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
locked = false,
) {
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
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} />)
return { store, setPlanMode, view }
return { store, exitPlanMode, view }
}
const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
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)
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', () => {
setup({ active: false, pending: false })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
cleanup()
it('renders the Plan status for active and pending-entry targets', () => {
setup({ active: true, pending: false })
expect(onChip().getAttribute('aria-pressed')).toBe('true')
expect(chip().textContent).toBe('Plan')
cleanup()
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
setup({ active: false, pending: true })
expect(onChip().getAttribute('aria-pressed')).toBe('true')
cleanup()
// Active with a pending exit: the target is default — already unpressed.
setup({ active: true, pending: true })
expect(offChip().getAttribute('aria-pressed')).toBe('false')
expect(chip().textContent).toBe('Plan')
})
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
const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: false, pending: false }, setPlanMode)
fireEvent.click(offChip())
expect(setPlanMode).toHaveBeenCalledTimes(1)
expect(setPlanMode).toHaveBeenLastCalledWith(true)
// Busy while its own call is in flight.
fireEvent.click(offChip())
expect(setPlanMode).toHaveBeenCalledTimes(1)
const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
const { store } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
expect(exitPlanMode).toHaveBeenCalledTimes(1)
fireEvent.click(chip())
expect(exitPlanMode).toHaveBeenCalledTimes(1)
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 } })
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', () => {
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 () => {
const exitFailing = vi.fn()
it('surfaces admission and transport failures while staying visible', async () => {
const exitPlanMode = vi.fn()
.mockResolvedValueOnce('host said no')
.mockRejectedValueOnce(new Error('network down'))
.mockRejectedValueOnce('socket closed')
setup({ active: true, pending: false }, exitFailing)
fireEvent.click(onChip())
setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
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()
fireEvent.click(onChip())
fireEvent.click(chip())
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', () => {
@@ -118,14 +92,14 @@ describe('PlanChip', () => {
{ active: true, pending: false },
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
)
fireEvent.click(onChip())
fireEvent.click(chip())
successful.view.unmount()
expect(() => { resolve(null) }).not.toThrow()
let reject!: (reason: unknown) => void
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, setPlanMode)
fireEvent.click(onChip())
const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
const { view } = setup({ active: true, pending: false }, exitPlanMode)
fireEvent.click(chip())
view.unmount()
expect(() => { reject(new Error('late')) }).not.toThrow()
})
+3
View File
@@ -20,6 +20,9 @@
{
"path": "../ui-conversation"
},
{
"path": "../ui-primitives"
},
{
"path": "../ui-slots"
},
+3
View File
@@ -1367,6 +1367,9 @@ importers:
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
'@deepseek-ai/dsh-client-ui-primitives':
specifier: workspace:^
version: link:../ui-primitives
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots