Merge pull request #4414 from deepseek-harness/worktree/plan-sidebar-cards

feat(web): keep plan artifacts and automatically open sidebar previews
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-09-17 17:49:08 +08:00
commit 3958f801e3
72 files changed
+1651 -213

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md
2026-09-17-persistent-plan-cards.md: 6959cf39f4e47dbd552ee14af7c1ebc09b52bef6
2026-09-17-persistent-plan-cards.zh.md: 0c40cb5caf4b77f5d4b5c30cead5567f21b45cfd
@@ -0,0 +1,31 @@
# Agent Note: Persistent plan cards and sidebar reading
Status: implemented
English | [中文](2026-09-17-persistent-plan-cards.zh.md)
## Problem
A plan review occupies the composer only until the user answers or dismisses it. A completed Turn also folds its tool calls into the process disclosure. Neither lifetime gives a submitted plan a discoverable place to read while implementation continues.
## Decision
`ui-plan` accumulates each Turn’s `exit_plan_mode` submissions, including native calls and PTC dispatches. Each invocation contributes a card to the completed Turn’s final artifact area. The `conversation.chat.turnTail` list admits file deliveries and plan cards together. Review dismissal, refusal, and approval do not delete the recorded plans.
The review intent may carry the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. When an invocation exists, both open the resource identified by its complete Session address and invocation. Subagent addresses retain the direct parent, child, and mode for snapshot reads, pagination, and reload. Sidebar layout retains that address; the resource provider reads the existing Session history, paging backwards when the invocation is older than the opening window. Its temporary follow closes after the opening snapshot.
A review without an invocation opens a temporary address keyed by browser lifetime, Session, and pending request. The sidebar’s existing navigation parameters hold its Markdown without adding another document store or persisting text in layout. Closing and reopening from the pending card supplies the text again; restored tabs without navigation parameters report an expired preview.
The document is read-only. The pending review shows its status, View full plan link, title, two-line plain-text summary, and Request changes / Approve buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation or unlogged request, so review remounts preserve manual closure. Historical cards never trigger automatic navigation. The cards use the file-delivery treatment with a Markdown icon, plan title, and Open action. Request changes cancels the pending wait and returns the composer for feedback. Approval stays in the pending-question owner; reopening a historical document does not revive a settled review or authorize implementation. Different submissions remain distinct even when they share a heading.
## Alternatives considered
**Keep only the pending review or an expandable tool row.** Both hide the document behind unrelated interaction or process state. The user needs a persistent artifact entry.
**Write a Markdown file or persist the document in sidebar layout.** The tool arguments already own the exact submitted text. Another durable copy would need synchronization and could disagree with the reviewed plan. The [plan-state decision](../simplification/2026-07-22-plan-specific-collaboration-state.md) remains the authority for that ownership.
**Select a single owner for the Turn tail.** Plans and file deliveries can occur in the same Turn. An additive list lets both plugins contribute without either knowing the other’s data or rendering.
## Consequences
Logged plans survive review closure and browser reload without a new Session event or file. Complete plan arguments retain a card even when the tool rejects the call, including calls outside plan mode; the card does not certify approval or execution. Reads of old plans may require several history pages. The provider validates saved addresses and logged arguments, and reports unavailable history explicitly. The recorded Web plan-review scenario covers both entry points, repeated opening, process collapse, approval, and reload; focused tests cover multiple submissions, PTC deduplication, cropped history, and resource failures.
@@ -0,0 +1,31 @@
# Agent Note: 常驻计划卡片与侧边栏阅读
Status: implemented
[English](2026-09-17-persistent-plan-cards.md) | 中文
## Problem
计划审批只在用户回答或关闭之前占据编辑器。已完成的回合也会把工具调用折叠到运行过程里。这两种生命周期都无法在实施继续时,为已提交的计划提供容易找到的阅读入口。
## Decision
`ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。
审批意图可以携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。存在调用标识时,两者都打开由完整会话地址和调用标识确定的资源。子会话地址保留直接父级、子会话及运行模式,用于快照读取、分页和刷新恢复。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。
没有调用标识的审批打开临时地址,由浏览器生命周期、会话和待处理请求共同标识。侧边栏已有的导航参数保存 Markdown,无需额外的文档 store,也不把正文持久化到布局中。关闭后从待处理卡片重新打开会再次提供正文;恢复的标签页若缺少导航参数,则显示预览已失效。
文档只读。待处理审批显示状态、“查看全文”链接、标题、两行纯文本摘要,以及“要求修改”和“同意执行”按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用或无调用标识的请求,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。“要求修改”取消待处理等待并恢复编辑器,供用户输入反馈。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。
## Alternatives considered
**只保留待处理审批或可展开的工具行。** 两者都会让文档受其他交互或运行过程状态控制。用户需要常驻的产物入口。
**写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。
**只选择一个回合末尾区域的属主。** 计划和文件产物可能出现在同一回合。可追加列表让两个插件都能贡献内容,无需彼此了解数据或渲染。
## Consequences
已记录计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。
@@ -0,0 +1,9 @@
- region "Temporary review":
- button "Copy":
- img
- heading "Temporary review" [level=1]
- paragraph: Review without a tool invocation.
- heading "Implementation" [level=2]
- list:
- listitem: Keep the complete document readable.
- listitem: Ask before implementation.
+142 -1
View File
@@ -14,6 +14,7 @@ import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria,
compareOrRefreshGolden, fixtureUserPrompts,
@@ -27,8 +28,10 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl')
// transcript the approval leaves behind — the state the card cannot see.
const REVIEW_EXPECTED = join(SNAPSHOT_DIR, 'review.expected.md')
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
const PREVIEW_EXPECTED = join(SNAPSHOT_DIR, 'preview.expected.md')
const APPROVED_EXPECTED = join(SNAPSHOT_DIR, 'approved.expected.md')
const APPROVED_EXPANDED_EXPECTED = join(SNAPSHOT_DIR, 'approved-expanded.expected.md')
const TEMPORARY_EXPECTED = fileURLToPath(new URL('./expected/plan-review/temporary.expected.md', import.meta.url))
const MODE = webSnapshotMode()
// One command line: /plan enters plan mode and submits the rest as the turn's
@@ -46,6 +49,7 @@ describe('web e2e: plan review takeover round trip', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
const sessionEvents: SessionEvent[] = []
let reviewedSession: SessionId
beforeAll(async () => {
scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15, compareReplaySession: true })
@@ -84,6 +88,10 @@ describe('web e2e: plan review takeover round trip', () => {
// The plan-review request must NOT land on the generic question flow.
expect(await page.locator('[data-question-key]').count()).toBe(0)
await expect.poll(() => card.getByText('Plan review').count(), { timeout: 10_000 }).toBeGreaterThan(0)
expect(await card.getByRole('heading').count()).toBe(1)
expect(await card.getByRole('heading').textContent()).toContain('--greeting')
expect(await card.getByText('View full plan', { exact: true }).isVisible()).toBe(true)
expect(await card.getByRole('list').count()).toBe(0)
const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]')
await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1)
@@ -96,6 +104,22 @@ describe('web e2e: plan review takeover round trip', () => {
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
}
const planCard = page.locator('[data-plan-card]')
expect(await planCard.count()).toBe(0)
const preview = page.locator('[data-plan-preview]')
await preview.waitFor({ state: 'visible' })
expect(await card.getByRole('button', { name: 'Approve' }).isVisible()).toBe(true)
expect(await preview.getByRole('heading', { level: 1 }).textContent()).toContain('--greeting')
await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
expect(await page.locator('[data-plan-preview]').count()).toBe(1)
if (MODE !== 'record') {
await compareOrRefreshGolden(PREVIEW_EXPECTED, await captureStableAria(page, '[data-plan-preview]', scaffold.workspaceCwd), MODE)
}
await page.locator('[data-sidebar-right-toggle]').click()
await preview.waitFor({ state: 'hidden' })
await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
await preview.waitFor({ state: 'visible' })
await card.getByRole('button', { name: 'Approve' }).click()
// Park the pointer: the card unmounts and the ContextMeter ring lands
// under the click position, whose 200ms hover delay would arm a tooltip
@@ -103,6 +127,7 @@ describe('web e2e: plan review takeover round trip', () => {
await page.mouse.move(0, 0)
const sessionId = await settled
reviewedSession = sessionId
if (MODE === 'record') {
await recordFixture(scaffold, sessionId, FIXTURE)
return
@@ -115,6 +140,16 @@ describe('web e2e: plan review takeover round trip', () => {
expect(await page.locator('[data-plan-review-key]').count()).toBe(0)
expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0)
await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true)
// Completed plans join the final artifacts after the Turn's closing reply.
await planCard.waitFor({ state: 'visible' })
expect(await planCard.locator('xpath=ancestor::*[@data-turn-tail]').count()).toBe(1)
await page.locator('[data-sidebar-right-toggle]').click()
await planCard.click()
await preview.waitFor({ state: 'visible' })
await page.reload({ waitUntil: 'load' })
await page.locator('[data-plan-card]').waitFor({ state: 'visible' })
await page.locator('[data-plan-preview]').waitFor({ state: 'visible' })
expect(await page.locator('[data-plan-preview]').getByRole('heading', { level: 1 }).textContent()).toContain('--greeting')
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(APPROVED_EXPECTED, snapshot, MODE)
const expanded = await captureExpandedTurnProcessAria(
@@ -127,10 +162,116 @@ describe('web e2e: plan review takeover round trip', () => {
expect(tripwire.warnings).toEqual([])
}, 200_000)
it.skipIf(MODE === 'record')('previews an unlogged review automatically and reopens it without deciding', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-temporary'))
const agent = scaffold.ctx.agents.get(reviewedSession)
if (agent === undefined) throw new Error('The reviewed Session has no active agent')
const controller = new AbortController()
// This public question request has no tool invocation; its detail is the only complete document.
const asked = scaffold.ctx.userQuestions.ask({
agent, signal: controller.signal,
questions: [{ id: 'temporary', question: 'Approve this temporary plan?',
detail: '# Temporary review\n\nReview without a tool invocation.\n\n## Implementation\n\n- Keep the complete document readable.\n- Ask before implementation.',
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' },
}],
})
let answered = false
const outcome = asked.then((value) => { answered = true; return value }, (error: unknown) => ({ error }))
try {
const card = page.locator('[data-plan-review-key]')
const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]')
await preview.waitFor({ state: 'visible' })
expect(await preview.getByText('Ask before implementation.').isVisible()).toBe(true)
await compareOrRefreshGolden(TEMPORARY_EXPECTED, await captureStableAria(page, '[data-plan-preview^="dsh-resource://plan-review/"]', scaffold.workspaceCwd), MODE)
const tab = page.locator('[data-dockkit-tab]').filter({ hasText: 'Temporary review' })
await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
expect(await tab.count()).toBe(1)
await tab.locator('[data-dockkit-tab-close]').click()
await preview.waitFor({ state: 'detached' })
await card.getByRole('button', { name: 'Open plan in sidebar' }).click()
await preview.waitFor({ state: 'visible' })
expect(answered).toBe(false)
const saved = await page.evaluate(() => Object.keys(localStorage)
.filter(key => key.startsWith('dsh.sidebar-right.v1.')).map(key => localStorage.getItem(key)).join('\n'))
expect(saved).toContain('dsh-resource://plan-review/')
expect(saved).not.toContain('Ask before implementation.')
await card.getByRole('button', { name: 'Approve', exact: true }).click()
expect(await outcome).toEqual({ answers: [{ id: 'temporary', selected: ['Approve'] }] })
await card.waitFor({ state: 'detached' })
expect(await preview.getByText('Ask before implementation.').isVisible()).toBe(true)
await page.reload({ waitUntil: 'load' })
await page.getByText('This temporary plan preview has expired. Reopen it from the pending review card.', { exact: true }).waitFor()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
controller.abort()
await outcome
}
}, 60_000)
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, [
'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md',
'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md',
'approved.expected.md', 'approved-expanded.expected.md',
])
})
})
describe('web e2e: dismissed plan history', () => {
it.skipIf(MODE === 'record')('reopens the permanent plan card after dismissing its review', async () => {
// Replay supplies a finite continuation; the real question rejection and retained document are the assertions.
const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false, paceMs: 15 })
let browser: Browser | undefined
const events: SessionEvent[] = []
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { events.push(event) })
try {
browser = await chromium.launch()
const page = await newEnglishPage(browser)
await page.setViewportSize({ width: 620, height: 900 })
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const input = page.locator('[data-composer-input]').first()
const settled = scaffold.whenTurnSettled(30_000)
await input.fill(LINE)
await input.press('Enter')
const review = page.locator('[data-plan-review-key]')
await review.waitFor({ state: 'visible' })
await page.locator('[data-plan-preview]').waitFor({ state: 'visible' })
await page.locator('[data-sidebar-right-toggle]').click()
await review.getByRole('button', { name: 'Request changes', exact: true }).click()
await settled
const call = events.find((event): event is SessionEvent<'tool/call'> =>
event.type === 'tool/call' && event.data.name === 'exit_plan_mode')
expect(call).toBeDefined()
const results = events.filter(event => event.type === 'tool/result')
const result = results.find(event => event.data.message.source.callId === call?.data.callId)
expect(result?.data.message.content[0]).toMatchObject({ type: 'tool-result', isError: true })
expect(JSON.stringify(result)).toContain('dismissed the plan review')
expect(results.some(event => JSON.stringify(event).includes('Plan approved'))).toBe(false)
const modes = events.filter(event => event.type === 'plan/mode')
expect(modes).toHaveLength(1)
expect(modes[0]).toMatchObject({ data: { active: true } })
expect(await review.count()).toBe(0)
const card = page.locator('[data-plan-card]')
await card.waitFor({ state: 'visible' })
await card.click()
await page.locator('[data-plan-preview]').waitFor({ state: 'visible' })
await page.locator('[data-sidebar-right-toggle]').click()
await card.click()
await page.reload({ waitUntil: 'load' })
await page.locator('[data-plan-card]').waitFor({ state: 'visible' })
await page.locator('[data-plan-preview]').waitFor({ state: 'visible' })
expect(await page.locator('[data-plan-preview]').getByRole('heading', { level: 1 }).textContent()).toContain('--greeting')
await page.locator('[data-sidebar-right-toggle]').click()
await page.reload({ waitUntil: 'load' })
await page.locator('[data-plan-card]').waitFor({ state: 'visible' })
expect(await page.locator('[data-plan-preview]').isVisible()).toBe(false)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
} finally {
await browser?.close()
await scaffold.close()
}
})
})
+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 docs/event-producer-consumer.md
event-producer-consumer.md: 7ddb75d71fffb1a416d693dc4b982565dab69d31
event-producer-consumer.zh.md: 418c8882495be5575655555b1973c2cd0b0c9ffc
event-producer-consumer.md: fcf95b4268cc41b84099af351f1d6fdb07c80fc4
event-producer-consumer.zh.md: bd466a2e39f4a268e4a8b1567ae5a5f22d250977
+1 -1
View File
@@ -74,7 +74,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
+1 -1
View File
@@ -76,7 +76,7 @@
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:183`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:191`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:88`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `ui-sidebar-documentpreview` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
+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 docs/subsystems/slots.md
slots.md: 177bbc5b8f56b7276f271a2ddb7ac71ac8888363
slots.zh.md: c746ac88704c2e22e5c50ef82a893fde9f091668
slots.md: a9f0ee6ab2ef37065ab965a7989f5989350bca8b
slots.zh.md: da6a38eab6aac1c2670b131fa49a832ce41f711e
+2 -1
View File
@@ -151,7 +151,8 @@ root
│ │ ├─ conversation.session.header.utilities
│ │ └─ conversation.session.header.corner
│ ├─ conversation.composer
│ │ └─ conversation.approval.detail
│ │ ├─ conversation.approval.detail
│ │ └─ conversation.plan-review.actions
│ ├─ conversation.composer.bar
│ │ ├─ conversation.input.attachments
│ │ ├─ conversation.input.permission
+2 -1
View File
@@ -151,7 +151,8 @@ root
│ │ ├─ conversation.session.header.utilities
│ │ └─ conversation.session.header.corner
│ ├─ conversation.composer
│ │ └─ conversation.approval.detail
│ │ ├─ conversation.approval.detail
│ │ └─ conversation.plan-review.actions
│ ├─ conversation.composer.bar
│ │ ├─ conversation.input.attachments
│ │ ├─ conversation.input.permission
+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 docs/subsystems/user-questions.md
user-questions.md: fbbfb1435586c7191e47a6eaa5b1783c9f172d48
user-questions.zh.md: 054ca06cd3c8a85cb35d99658c325848ea780cb5
user-questions.md: d6515464e562eb5b3d505fd3ff1dcb118c2a7ee3
user-questions.zh.md: 87536c22ad717ee5c25f4846aee6bd6af6068e8d
+2
View File
@@ -41,6 +41,8 @@ type AskUserQuestionIntent = {
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
/** Logged tool invocation whose arguments contain the reviewed plan. */
callId?: ToolCallId
}
```
+2
View File
@@ -41,6 +41,8 @@ type AskUserQuestionIntent = {
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
/** Logged tool invocation whose arguments contain the reviewed plan. */
callId?: ToolCallId
}
```
@@ -11,7 +11,7 @@ type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
export const TurnTailNodeView = memo(function TurnTailNodeView({
node, openFile, forkAt, renderSlot, renderSlotChain, t, useChat,
node, openFile, forkAt, renderSlot, t, useChat,
}: TurnTailNodeViewProps) {
const data = node.data
const hasLaterChatNode = useChat(snapshot =>
@@ -23,8 +23,8 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
if (turn === undefined) return null
const closing = data.closing
const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
const tail = renderSlotChain('conversation.chat.turnTail', owner)
if (closing === null) return tail === null ? null : <div className={css.root}>{tail}</div>
const tail = renderSlot('conversation.chat.turnTail', owner)
if (closing === null) return tail === null ? null : <div className={css.root} data-turn-tail={data.turn}>{tail}</div>
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
@@ -48,7 +48,7 @@ export function registerChatNodeRenderers(ctx: Context): void {
key: 'turn-tail',
locale: NS,
children: {
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
},
}, TurnTailNodeView))
@@ -206,11 +206,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
*/
'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps }
/**
* Selector-routed extension before a completed Turn's action row. The
* component receives the Turn, closing sequence, and file opener. The first
* selector that accepts the owner renders; an all-declined chain is empty.
* Ordered feature contributions before a completed Turn's action row. Each
* entry receives the Turn, closing sequence, and file opener. A fresh `id`
* adds an entry; entries without content return null.
*/
'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps }
'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps }
/**
* Ordered actions for one finalized assistant message. Each entry receives
* the durable message id; a fresh `id` adds an action and reusing one replaces
@@ -282,8 +282,6 @@ function makeHarness(
}> = []
const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) =>
opts?.fallback ?? null) as unknown as React.ComponentProps<typeof CommandNodeView>['renderSlot']
const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as
React.ComponentProps<typeof TurnTailNodeView>['renderSlotChain']
const renderTurnTailSlot = (() => null) as unknown as
React.ComponentProps<typeof TurnTailNodeView>['renderSlot']
let nodeSlotOverride: React.ComponentProps<typeof ChatNodeSeat>['renderSlot'] | undefined
@@ -335,7 +333,6 @@ function makeHarness(
<TurnTailNodeView
{...nodeProps<'turn-tail'>()}
renderSlot={renderTurnTailSlot}
renderSlotChain={renderTurnTail}
SessionProvider={props.SessionProvider}
/>
)
@@ -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-deliverables/README.md
README.md: 4d3cdb2c82327a485a318af9bbb2e398bd704d71
README.zh.md: 387fd678ba16bff42d944344a8ede940b44781d3
README.md: 46e289ddb572928baaee7ac1a370087fd789aebe
README.zh.md: 804ee5c7613510df96ac48cfe08f9b0fd39f7bbc
+2 -2
View File
@@ -54,7 +54,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
<details>
<summary>Implementation internals — click to expand</summary>
The Node half registers the static `ui:deliverable-file-references` system-prompt section described under [Model Experience](#model-experience). Explicit Markdown links use the shared [Markdown renderer](../ui-primitives/README.md); inline-code matching remains limited to produced or delivered files. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty chain at zero cost.
The Node half registers the static `ui:deliverable-file-references` system-prompt section described under [Model Experience](#model-experience). Explicit Markdown links use the shared [Markdown renderer](../ui-primitives/README.md); inline-code matching remains limited to produced or delivered files. The browser half registers a wrapper around the changed-files card and explicit deliveries into the chat view's `conversation.chat.turnTail` list alongside other feature artifacts. `deliverablesDefinition` folds the sequence of each Turn's latest validated `workspace/changes` announcement into `DeliverablesTurnData.changes`, whose summary the card reads from the Host and caches until the connection is replaced, its `deliverables/presented` events into deliveries, and the successful first-party mutation calls of `write`, `edit`, and mutating `str_replace_editor` commands into produced paths from their validated raw arguments; the produced paths feed only the prose mention resolver. Reads, deletes, unsupported tools, malformed calls, malformed events, and failed results contribute nothing. Each row opens `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>` through `ctx.sidebarRight.openResource` with the file's index as the `changes-review` navigation parameter; the package registers the `changes-review` tab type at the `builtin` band for that pattern and its body under the keyed `sidebar.right.pane.tab` seat with an exclusive store of per-tab choices, and the body reads the summary and the comparisons through the authenticated routes into stores cleared on connection replacement. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes every surface and leaves the view's empty list at zero cost.
Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index; the review tab's native open of a changed file uses the same coordinates. For a declaration the Host reads the viewed Session header with the event and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`; for a changed file it passes the working directory the served summary carries. This uses the same composed filesystem as Sidebar previews and does not activate an Agent, including for child Sessions. Native actions require the canonical process path to map from a Host path back to that same process path. Providers without this mapping return 422, after which the review tab hides its native open; a same-named Host file is insufficient. The same configured desktop availability governs metadata and execution. Edits affect subsequent opens; deletion returns an error. No file-content copy or attachment is created. Plugin disposal cancels and awaits pending native-open requests.
@@ -68,7 +68,7 @@ Native opening uses an authenticated POST addressed by the viewed Session, event
Read these pages when the deliverables surface is not enough. They move from the card to the Host recorder, the turn-tail hole, and the decisions behind the vocabulary.
- [workspace-changes](../../deliverables/workspace-changes/README.md) — the Host plugin that records and serves the summary the card renders.
- [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose.
- [ui-chat](../ui-chat/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose.
- [Turn changed-files card](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md) — the decision behind git-recorded summaries replacing the mutation-call row.
- [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the earlier produced-files row; its Host open path is superseded by the [right Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md).
- [Inline file mentions](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md) — the decision behind clickable mentions in the closing prose.
+2 -2
View File
@@ -54,7 +54,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
<details>
<summary>实现细节——点击展开</summary>
Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverable-file-references` 系统提示词段。显式 Markdown 链接使用共享的 [Markdown 渲染器](../ui-primitives/README.zh.md);行内代码匹配仍仅限产出或交付文件。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。
Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverable-file-references` 系统提示词段。显式 Markdown 链接使用共享的 [Markdown 渲染器](../ui-primitives/README.zh.md);行内代码匹配仍仅限产出或交付文件。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 列表,与其他功能产物并列。`deliverablesDefinition` 把每个轮次最新且通过校验的 `workspace/changes` 宣告的序号折叠进 `DeliverablesTurnData.changes`,卡片按它向 Host 读取摘要并缓存到连接被替换为止,把 `deliverables/presented` 事件折叠为交付,并根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数把成功的第一方修改调用折叠为产出路径;产出路径只供正文提及解析器使用。读取、删除、不受支持的工具、格式错误的调用、格式错误的事件和失败结果不贡献任何条目。每一行通过 `ctx.sidebarRight.openResource` 打开 `dsh-resource://changes-review/session/<sessionId>/<seq>/<turn>`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空列表以零成本留下。
原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明;review tab 对改动文件的原生打开使用同一组坐标。对声明,Host 读取事件及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`,未记录 cwd 时使用部署的工作目录;对改动文件,传的是所提供摘要携带的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,之后 review tab 隐藏原生打开;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。
@@ -68,7 +68,7 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl
当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。
- [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录并提供卡片所渲染摘要的 Host 插件。
- [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。
- [ui-chat](../ui-chat/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。
- [本轮改动文件卡片](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——用 git 记录的摘要取代修改调用行背后的决策。
- [工作区文件链接](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md)——早先产出文件行背后的决策;其 Host 打开路径已被[右侧 Sidebar](../../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)取代。
- [行内文件提及](../../../.agents/notes/archived/feature/2026-08-07-web-inline-file-mentions.md)——收尾正文可点击提及背后的决策。
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { Button, IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { GlobalStandardProps, InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PresentedOpenController } from './present-open.ts'
import type { ChangesSummaryStore } from './changes-summary.ts'
@@ -44,6 +44,16 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch
return changes === null && presented.length === 0 ? null : { changes, presented }
}
/**
* Contribute file deliveries alongside other completed-Turn artifacts.
* @param props - closing Turn, file actions, and localized copy.
* @returns file rows, or null when the Turn declares none.
*/
export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale<typeof NS> & InjectFace<DeliverablesInjected>) {
const matched = selectDeliverables(props)
return matched === null ? null : <Deliverables {...props} matched={matched} />
}
/**
* Render the changed-files card, once the Host has served the announced
* summary and it lists a file, and default-application buttons for declared
@@ -1,12 +1,12 @@
/**
* Deliverables plugin, browser half: registers the changed-files card and
* delivery cards into the chat view's turn-tail chain, the `changes-review`
* delivery cards into the chat view's turn-tail list, the `changes-review`
* right-Sidebar tab type that reviews one turn's changed files one comparison
* at a time, and provides the `chatFileMentions` service that links
* inline-code mentions of produced or delivered files in the closing prose.
* All policy lives here — the supported mutation calls, mention matching, row
* cap, and copy — so composing this plugin out of cordis.yml removes every
* surface; the owning view renders an empty chain and inert prose at zero cost.
* surface; the owning view renders an empty list and inert prose at zero cost.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-api-remotes/client'
@@ -21,7 +21,7 @@ import { ChangesDiffStore } from './changes-diff.ts'
import { ChangesSummaryStore } from './changes-summary.ts'
import { PresentedOpenController } from './present-open.ts'
import { PresentRow } from './PresentRow.tsx'
import { Deliverables, selectDeliverables, type DeliverablesInjected } from './Deliverables.tsx'
import { DeliverablesTail, type DeliverablesInjected } from './Deliverables.tsx'
import { ReviewTab, type ReviewInjected } from './ReviewTab.tsx'
import { CHANGES_REVIEW_ID, changesReviewDefinition } from './review-definition.ts'
import { createReviewStore } from './review-store.ts'
@@ -60,7 +60,7 @@ export function apply(ctx: ClientContext): void {
'conversation.chat.turnTail',
() => ctx.slots.register({
name: 'conversation.chat.turnTail',
select: selectDeliverables,
id: '@deepseek-ai/dsh-client-ui-deliverables',
locale: NS,
inject: (): DeliverablesInjected => ({
hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state },
@@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void {
ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } })
},
}),
}, Deliverables),
}, DeliverablesTail),
)
ctx.slots.inject('tool.call.toolview', () => ctx.slots.register(
{ name: 'tool.call.toolview', key: 'present', locale: NS }, PresentRow,
@@ -22,7 +22,7 @@ import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client'
import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import { Deliverables, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx'
import type { ReviewInjected } from '../src/client/ReviewTab.tsx'
import { ChangesSummaryStore } from '../src/client/changes-summary.ts'
import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts'
@@ -681,7 +681,7 @@ describe('plugin registration', () => {
ctx.slots.register({
name: 'root',
children: {
'conversation.chat.turnTail': { kind: 'chain', scope: 'session' },
'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
'tool.call.toolview': { kind: 'keyed', scope: 'session' },
'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' },
},
@@ -942,3 +942,13 @@ it('loads desktop information once the tail renders and not again while it is kn
view.rerender(<Deliverables {...shared} matched={{ changes: null, presented: [{ path: 'report.txt', seq: 2, index: 0 }] }} />)
expect(props.reloadPresentedHost).toHaveBeenCalledOnce()
})
it('contributes file artifacts to the tail list only for turns with deliveries', () => {
const owner = tailOwner(undefined, 3)
const props = { ...openProps(), ...owner, sessionId: SessionId('session'), t: makeTranslate(en) } as unknown as Parameters<typeof DeliverablesTail>[0]
const view = render(<DeliverablesTail {...props} />)
expect(view.container.innerHTML).toBe('')
const withFile = tailOwner({ produced: [], presented: [{ path: 'report.md', seq: 2, index: 0 }] }, 3)
view.rerender(<DeliverablesTail {...props} {...withFile} />)
expect(view.getByText('report.md')).toBeTruthy()
})
+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: 4ff275834ec069315580c205b8fd0bf0dee9cfeb
README.zh.md: 588698b273bc52b1e601c7010516c24a9248e543
README.md: f7e7aede2c76717228d101153e4d3d2f8d3f88c4
README.zh.md: 9ab37a25dd7cb406b42bf5c531445357d6725e2e
+9 -1
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
This package renders the plan-mode status chip in the Web GUI: when the host-computed projection's effective target is plan mode, the composer shows a warn-colored "Plan ×" button that turns plan mode off; otherwise the seat stays empty. Plan mode itself — the `/plan` command, the committed `plan/mode` state, the projection unit, and the policy section — belongs to `dsh-plan-mode`; this package only renders the projection and sends what a user could equally type. The model exits plan mode through the stable `exit_plan_mode` tool; its plan review uses the composed Web question channel.
Plan mode lets you review a plan before implementation. Enter with `/plan` and leave with the composer chip. Submitted plans open automatically in the right sidebar for review and remain available from cards in the completed Turn’s final artifact area after approval, rejection, or dismissal. Reopening a plan focuses its existing tab, and browser reload restores the document from Session history.
## Table of Contents
@@ -31,6 +31,12 @@ Mount this plugin alongside `ui-conversation` and `dsh-plan-mode`; the chip then
While the effective target is plan mode, the seat renders the warn-colored "Plan ×" status button, which executes `/plan off`. 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" — unless the owning surface supplies its own placeholder.
### Reading submitted plans
When a Turn ends, each submitted plan appears in its final artifact area, using the file-delivery card treatment with a Markdown icon, title, and Open action. A pending plan opens automatically once per submission in the current browser session. Closing it stays effective across review remounts; a new submission opens its own plan. Historical cards open only when clicked. Use the card or review strip’s View full plan link to read and copy the complete Markdown. Plan tabs show a document-and-pen icon. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin.
A review without a logged invocation also opens automatically. Its complete text lives only in the tab’s navigation memory, and the pending review card can reopen it. Reloading the page loses that text; an expired preview directs the user back to a pending review.
### Failures
Admission failures (`matched: false`, business errors, transport faults) surface as an inline error and the chip stays until the projection confirms the exit.
@@ -45,6 +51,8 @@ Admission failures (`matched: false`, business errors, transport faults) surface
The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint. The accessible description is "Plan mode on, press to turn off".
Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate.
</details>
-----
+9 -1
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
本包在 Web GUI 中渲染 plan 模式状态徽章:当宿主计算的投影有效目标为 plan 模式时,composer 显示一个 warn 色「Plan ×」按钮,可关闭 plan 模式;否则该座位保持为空。plan 模式本身——`/plan` 命令、已提交的 `plan/mode` 状态、投影单元与 policy 段——归 `dsh-plan-mode` 所有;本包只渲染投影并发送用户同样可以手敲的内容。模型经稳定的 `exit_plan_mode` 工具退出 plan 模式;其 plan 评审走已组合的 Web question 通道。
计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出。提交的计划自动在右侧边栏打开供审阅;批准、拒绝或关闭审批后,仍可通过已完成回合末尾的产物卡片查看。重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。
## 目录
@@ -31,6 +31,12 @@ kind: "package-reference"
当有效目标为 plan 模式时,该座位渲染 warn 色「Plan ×」状态按钮,执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。
### 查看已提交的计划
回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。计划标签页显示文档加笔的图标。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。
没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。
### 失败
准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,徽章保持显示直至投影确认退出。
@@ -45,6 +51,8 @@ kind: "package-reference"
徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。无障碍描述是「Plan mode on, press to turn off」。
计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。
</details>
-----
+18 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-plan",
"description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel",
"description": "Plan mode controls, persistent transcript plan cards, and sidebar Markdown previews",
"version": "0.1.6-alpha.1",
"publishConfig": {
"access": "public"
@@ -30,7 +30,12 @@
"inject": [
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation"
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-client-ui-sidebar-right",
"@deepseek-ai/dsh-client-ui-chat",
"@deepseek-ai/dsh-client-ui-user-questions",
"@deepseek-ai/dsh-client-resources",
"@deepseek-ai/dsh-api-session-controller"
],
"platform": "web"
}
@@ -44,6 +49,7 @@
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -56,7 +62,16 @@
"react": "^18.2.0",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^"
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
"@deepseek-ai/dsh-client-ui-user-questions": "workspace:^",
"@deepseek-ai/dsh-client-resources": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-crypto": "workspace:^"
},
"files": [
"lib/index.js",
@@ -0,0 +1,68 @@
/** Persistent transcript cards and pending-review sidebar navigation. */
import { useEffect } from 'react'
import { FileTypeIcon, IconChevronRightOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { shallowEqual } from '@deepseek-ai/dsh-client-store'
import type {} from './plan-definition.ts'
import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client'
import type { createPlanReviewStore } from './review-store.ts'
import css from './PlanPreview.module.css'
/** Session-bound navigation for logged plans. */
export interface PlanOpenInjected {
/** Open or focus the exact submitted plan. */
openPlan: (callId: ToolCallId) => void
}
/** Session-bound preview navigation for one pending review. */
export interface PlanReviewOpenInjected {
/** Open the logged plan, or the request's temporary document when no invocation exists. */
openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void
}
/**
* Render the completed Turn's submitted plans in invocation order.
* @param props - Logged plan, localized copy, and Session-bound navigation.
* @returns keyboard-accessible plan cards, or null for a Turn without plans.
*/
export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversation.chat.turnTail'> & InjectFace<PlanOpenInjected> & PropsLocale<'plan'>) {
const plans = useChat(snapshot => snapshot.nodes.values()
.filter((node): node is ChatNode<'submitted-plan'> => node.kind === 'submitted-plan'
&& (node.location.kind === 'turn' || node.location.kind === 'step')
&& node.location.turn.turn === turn.turn)
.sort((a, b) => a.anchorSeq - b.anchorSeq), shallowEqual)
if (plans.length === 0) return null
return (
<div className={css.cards} data-plan-artifacts>
{plans.map(({ data: plan }) => <button key={plan.callId} type="button" className={css.card} data-plan-card={plan.callId}
aria-label={t('preview.openNamed', { title: plan.title })}
onClick={() => { openPlan(plan.callId) }}>
<span className={css.cardIcon}><FileTypeIcon kind="markdown" size={20} /></span>
<span className={css.cardDetails}>
<span className={css.cardTitle}>{plan.title}</span>
<span className={css.cardDescription}>{t('preview.document')}</span>
</span>
<span className={css.cardOpen}>{t('preview.action')}</span>
</button>)}
</div>
)
}
/**
* Open each pending plan automatically and retain a manual opener without answering it.
* @param props - Review identity, Session store, localized copy, and navigation.
* @returns an opener for either logged or temporary plan text.
*/
export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace<PlanReviewOpenInjected> & PropsLocale<'plan'> & PropsStore<ReturnType<typeof createPlanReviewStore>>) {
const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}`
const opened = useStore(state => state.opened[identity] === true)
useEffect(() => {
if (opened) return
openReview(review, requestKey)
actions.markOpened(identity)
}, [identity, opened, openReview, review, requestKey, actions])
return <button type="button" className={css.reviewLink} title={t('preview.open')} aria-label={t('preview.open')}
onClick={() => { openReview(review, requestKey) }}>{t('preview.full')}<IconChevronRightOutline14 size={14} /></button>
}
@@ -0,0 +1,41 @@
.cards { display: flex; flex-direction: column; gap: 10px; }
.card {
--plan-card-fill: var(--dsw-static-neutral-50);
--plan-card-hover: var(--dsw-static-neutral-100);
box-sizing: border-box;
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
width: 100%;
height: 60px;
padding: 8px 10px;
margin: 0;
border: 0.5px solid var(--dsw-alias-border-l1);
border-radius: 18px;
background: var(--plan-card-fill);
color: var(--dsw-alias-label-primary);
font: inherit;
text-align: left;
cursor: pointer;
transition: background-color 120ms ease;
}
:global(body[data-ds-dark-theme]) .card { --plan-card-fill: var(--dsw-static-neutral-850); --plan-card-hover: var(--dsw-static-neutral-800); }
.card:hover { background: var(--plan-card-hover); }
.card:focus-visible, .iconButton:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
.cardIcon { box-sizing: border-box; flex: none; display: grid; place-items: center; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; background: var(--plan-card-fill); }
.cardDetails { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; }
.cardTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; font-weight: 500; line-height: 20px; }
.cardDescription { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 16px; }
.cardOpen { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; height: 28px; padding: 4px 8px; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); font-size: 12px; line-height: 18px; }
.reviewLink { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; }
.reviewLink:hover { color: var(--dsw-alias-label-primary); }
.reviewLink:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; }
.iconButton { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; }
.iconButton:hover { background: var(--dsw-alias-interactive-bg-hover); }
.preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; }
.toolbar { display: flex; justify-content: flex-end; margin-bottom: 8px; }
.document { color: var(--dsw-alias-label-primary); font-size: 14px; line-height: 1.75; overflow-wrap: anywhere; }
.message { padding: 24px; color: var(--dsw-alias-label-secondary); font-size: 14px; }
.titleIcon { display: inline-flex; flex: none; }
@media (max-width: 767px) { .preview { padding: 16px 18px 32px; } }
@@ -0,0 +1,56 @@
/** Read-only Markdown viewer for logged plans and temporary review documents. */
import { useMemo } from 'react'
import { IconCopyOutline16, IconPlanOutline14, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from './plan-resource.ts'
import { isReviewPreviewAddress } from './review-preview.ts'
import { planFailureLine } from './failure-line.ts'
import css from './PlanPreview.module.css'
type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'plan'>
/**
* Render the submitted plan with its complete Markdown and a copy action.
* @param props - Framework-bound tab identity, resource, and copy.
* @returns the plan document or a localized loading/failure state.
*/
export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) {
const tab = useTabInfo()
const resource = useResource<'plan'>(tab.tab.navigation.address)
const temporary = isReviewPreviewAddress(tab.tab.navigation.address)
const params = tab.tab.navigation.params
const plan = temporary ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value
const labels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
}), [t])
if (plan === undefined) return (
<div className={css.message} role="status">
{temporary ? t('preview.expired') : resource.status === 'none' ? t('preview.unavailable')
: resource.status === 'failed' ? t('preview.failed') : t('preview.loading')}
{!temporary && resource.failure !== undefined && <p>{planFailureLine(t, resource.failure)}</p>}
</div>
)
return (
<section className={css.preview} data-plan-preview={'callId' in plan ? plan.callId : tab.tab.navigation.address} aria-label={plan.title}>
<div className={css.toolbar}><button type="button" className={css.iconButton} aria-label={t('copy')}
onClick={() => { void writeClipboard(plan.markdown) }}><IconCopyOutline16 /></button></div>
<div className={css.document}><MarkdownText text={plan.markdown} labels={labels} /></div>
</section>
)
}
/**
* Display a plan icon and the heading in its tab after resource recovery.
* @param props - Framework-bound tab identity and resource reader.
* @returns a decorative plan icon followed by the recovered title or initial localized label.
*/
export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.right.pane.tab.title'>) {
const tab = useTabInfo()
const resource = useResource<'plan'>(tab.tab.navigation.address)
const params = tab.tab.navigation.params
const plan = isReviewPreviewAddress(tab.tab.navigation.address)
? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value
return <><span className={css.titleIcon} aria-hidden="true"><IconPlanOutline14 size={16} /></span>{plan?.title ?? tab.tab.title}</>
}
@@ -0,0 +1,19 @@
/** Localized plan-owned failures and unmodified external diagnostics. */
import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol'
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
/**
* Explain a failed plan read in the current locale.
* @param t - Plan namespace translator.
* @param failure - Failure reported by the resource provider.
* @returns localized plan copy, or the external failure's diagnostic.
*/
export function planFailureLine(t: TranslateNS<'plan'>, failure: RemoteFailure): string {
switch (failure.code) {
case 'plan/invalid-address': return t('preview.invalidAddress')
case 'plan/unavailable': return t('preview.historyUnavailable')
case 'plan/not-found': return t('preview.notFound')
// Transport failures, including plan/read-failed, retain the carrier's diagnostic.
default: return failure.message
}
}
+57 -12
View File
@@ -1,12 +1,4 @@
/**
* Plan control plugin, browser half: occupies the composer's named
* `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.
*/
/** Plan-mode control, persistent Chat cards, and Session-backed sidebar previews. */
import type {} from '@deepseek-ai/dsh-api-remotes/client'
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -18,6 +10,20 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-plan-mode/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/remote'
import type {} from '@deepseek-ai/dsh-client-ui-chat/client'
import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type {} from '@deepseek-ai/dsh-client-resources/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from './PlanCard.tsx'
import { PlanPreview, PlanTitle } from './PlanPreview.tsx'
import { planDefinition } from './plan-definition.ts'
import { planResourceProvider } from './plan-resource.ts'
import { planAddress, parsePlanAddress } from './plan.ts'
import { isReviewPreviewAddress, reviewPreviewAddress } from './review-preview.ts'
import { createPlanReviewStore } from './review-store.ts'
import { PlanChip } from './PlanModeControl.tsx'
import { en, zh, type PlanKey } from './locales.ts'
@@ -42,16 +48,55 @@ export interface PlanChipInjected {
exitPlanMode: () => Promise<string | null>
}
/** Required services: the seat's slot registry, commands Remote, and locale registry. */
export const inject = ['slots', 'remote', 'remote.commands', 'locale']
/** Services for plan controls, Conversation projection, and resource navigation. */
export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']
/**
* Client plugin body: register the plan chip over the command channel.
* Register plan controls, permanent Chat cards, and sidebar document reading.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-plan: dictionaries')
const previewId = '@deepseek-ai/dsh-client-ui-plan'
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.uiConversation.events.register(planDefinition), 'ui-plan: conversation definition')
ctx.effect(() => ctx.resources.register(planResourceProvider(ctx.remote.session)), 'ui-plan: resources')
ctx.effect(() => ctx.sidebarRightTabs.register({
id: previewId, kind: 'plan', patterns: ['dsh-resource://plan/**', 'dsh-resource://plan-review/**'], priority: 'builtin',
canOpen: address => parsePlanAddress(address) !== undefined || isReviewPreviewAddress(address),
title: () => t('preview.title'),
}), 'ui-plan: sidebar type')
const open = (sessionId: SessionId): PlanOpenInjected => ({
openPlan: (callId) => {
const child = ctx.sessions.subagentAddress(sessionId)
const session = child === undefined ? { kind: 'session' as const, sessionId } : { kind: 'subagent' as const, ...child }
ctx.sidebarRight.openResourceIn(sessionId, planAddress({ session, callId }))
},
})
const reviewWindow = randomUUID()
const reviewStore = createPlanReviewStore()
ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({
name: 'conversation.chat.turnTail', id: previewId, locale: NS, inject: open,
}, PlanCards))
ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({
name: 'conversation.plan-review.actions', id: previewId, locale: NS, store: reviewStore,
inject: (sessionId: SessionId): PlanReviewOpenInjected => ({
openReview: (review, requestKey) => {
if (review.callId !== undefined) { open(sessionId).openPlan(review.callId); return }
ctx.sidebarRight.openResourceIn(sessionId, reviewPreviewAddress(sessionId, `${reviewWindow}:${requestKey}`), {
params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } },
})
},
}),
}, PlanReviewOpen))
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
name: 'sidebar.right.pane.tab', key: previewId, locale: NS,
}, PlanPreview))
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
name: 'sidebar.right.pane.tab.title', key: previewId,
}, PlanTitle))
ctx.slots.inject('conversation.input.plan', () => ctx.slots.register({
name: 'conversation.input.plan',
locale: NS,
@@ -3,6 +3,19 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'chip.label': 'Plan',
'preview.title': '计划',
'preview.document': '计划 · Markdown',
'preview.action': '打开',
'preview.open': '在侧边栏打开计划',
'preview.full': '查看全文',
'preview.openNamed': '打开计划:{title}',
'preview.loading': '正在读取计划…',
'preview.failed': '无法读取计划',
'preview.invalidAddress': '计划地址无效',
'preview.historyUnavailable': '无法读取会话历史',
'preview.notFound': '未找到这份计划',
'preview.unavailable': '计划预览不可用',
'preview.expired': '临时计划预览已失效,请从仍在等待审批的卡片重新打开。',
'chip.on.aria': 'plan mode 已开启,按下关闭',
'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)',
'chip.off.aria': 'plan mode 已关闭,按下开启',
@@ -16,6 +29,19 @@ export type PlanKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'chip.label': 'Plan',
'preview.title': 'Plan',
'preview.document': 'Plan · Markdown',
'preview.action': 'Open',
'preview.open': 'Open plan in sidebar',
'preview.full': 'View full plan',
'preview.openNamed': 'Open plan: {title}',
'preview.loading': 'Loading plan…',
'preview.failed': 'Could not load plan',
'preview.invalidAddress': 'Invalid plan address',
'preview.historyUnavailable': 'Session history is unavailable',
'preview.notFound': 'This plan was not found',
'preview.unavailable': 'Plan preview is unavailable',
'preview.expired': 'This temporary plan preview has expired. Reopen it from the pending review card.',
'chip.on.aria': 'Plan mode on, press to turn off',
'chip.on.title': 'Plan mode on — click to turn off (/plan off)',
'chip.off.aria': 'Plan mode off, press to turn on',
@@ -0,0 +1,34 @@
/** Per-invocation plans use resolved Conversation locations for final Turn artifacts. */
import type {} from '@deepseek-ai/dsh-tools/types'
import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { submittedPlan, type SubmittedPlan } from './plan.ts'
declare module '@deepseek-ai/dsh-client-ui-chat/client' {
interface ChatNodeDataMap {
/** Complete plan submitted through exit_plan_mode, including rejected or dismissed reviews. */
'submitted-plan': SubmittedPlan
}
}
/** One card per invocation; a later PTC settlement retains the original card position. */
export const planDefinition: ConversationNodeDefinition<SubmittedPlan> = {
kind: 'submitted-plan',
target: 'chat',
match: (event) => {
const plan = submittedPlan(event)
return plan === undefined ? null : { id: plan.callId, role: event.type === 'tool/ptc-dispatch' ? 'update' : 'start' }
},
start: (_context, match) => submittedPlan(match.event) as SubmittedPlan,
update: context => context.state,
buildViewNode: (context) => {
const start = context.start ?? context.matches[0]
const data = context.state ?? (start === undefined ? undefined : submittedPlan(start.event))
if (data === undefined || start === undefined) return null
return {
key: context.key, kind: 'submitted-plan', id: context.id, target: 'chat',
anchorSeq: start.event.seq, location: start.location,
visibility: 'hidden', data,
} satisfies ChatNode<'submitted-plan'>
},
}
@@ -0,0 +1,82 @@
/** Read immutable plan arguments from a Session snapshot and earlier history pages. */
import type { Context } from '@deepseek-ai/cordis'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client'
import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { parsePlanAddress, submittedPlan, type SubmittedPlan } from './plan.ts'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** The resource URL does not identify a plan invocation. */
'plan/invalid-address': Record<string, never>
/** Session history ended before an opening snapshot. */
'plan/unavailable': Record<string, never>
/** The Session has no readable plan for this invocation. */
'plan/not-found': Record<string, never>
/** The Session history read failed. */
'plan/read-failed': Record<string, never>
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface ResourceProtocolMap {
/** Immutable Markdown from a logged exit_plan_mode invocation. */
plan: SubmittedPlan
}
}
/**
* Bind plan reads to the generated Session Remote face.
* Opening a follow reads projections and may activate a prepared Session on the Host.
* Generated Remote streams can throw carrier failures; the provider reports failed
* reads as resource failure frames and preserves Remote error codes.
* @param remote - Existing Session history API.
* @returns a provider whose reads stop after finding the exact invocation.
*/
export function planResourceProvider(remote: Pick<Context['remote']['session'], 'follow' | 'page'>): ResourceProvider<'plan'> {
return {
protocol: 'plan',
async *open(address, { signal }): AsyncIterable<RemoteResult<SubmittedPlan>> {
const aborted = (): boolean => signal.aborted
if (aborted()) return
const target = parsePlanAddress(address)
if (target === undefined) {
yield { ok: false, error: new RemoteError('plan/invalid-address', 'Invalid plan resource address.', {}) }
return
}
const sessionAddress = target.session
try {
// The snapshot supplies the page API's fixed log cut. Breaking closes the follow stream.
let snapshot: Extract<SessionFollowFrame, { type: 'snapshot' }> | undefined
for await (const frame of remote.follow({ address: sessionAddress }, signal)) {
if (frame.type === 'snapshot') { snapshot = frame; break }
}
if (aborted()) return
if (snapshot === undefined) throw new RemoteError('plan/unavailable', 'Session history ended before the plan could be read.', {})
let page = { records: snapshot.records, hasMore: snapshot.hasMore }
while (true) {
for (const entry of page.records) {
const plan = submittedPlan(entry.event)
if (plan?.callId === target.callId) {
yield { ok: true, value: plan }
return
}
}
const beforeSeq = page.records[0]?.event.seq
if (!page.hasMore || beforeSeq === undefined) break
const next = await remote.page({ address: sessionAddress, throughSeq: snapshot.cursor, beforeSeq }, signal)
if (aborted()) return
if (!next.ok) { yield next; return }
page = next.value
}
yield { ok: false, error: new RemoteError('plan/not-found', 'The submitted plan was not found in this Session.', {}) }
} catch (error) {
if (!aborted()) yield {
ok: false,
error: remoteErrorOf(error) ?? new RemoteError('plan/read-failed', error instanceof Error ? error.message : String(error), {}),
}
}
},
}
}
@@ -0,0 +1,86 @@
/** Plan text and resource identities derived from logged native or PTC calls. */
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionAddress } from '@deepseek-ai/dsh-api-session-controller/types'
/** Complete Markdown and the heading displayed by a plan preview. */
export interface PlanDocument {
readonly markdown: string
readonly title: string
}
/** One submitted plan, identified by its originating tool invocation. */
export interface SubmittedPlan extends PlanDocument {
readonly callId: ToolCallId
}
/** A saved sidebar resource names one invocation in one Session. */
export interface PlanAddress {
readonly session: SessionAddress
readonly callId: ToolCallId
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Read a complete plan from untrusted logged arguments.
* @param event - Native call or PTC dispatch event from Session history.
* @returns the submitted plan, or undefined for unrelated or malformed data.
*/
export function submittedPlan(event: { readonly type: string; readonly data: unknown }): SubmittedPlan | undefined {
if (event.type !== 'tool/call' && event.type !== 'tool/ptc-dispatch-start' && event.type !== 'tool/ptc-dispatch') return undefined
const data = event.data
if (!record(data) || data.name !== 'exit_plan_mode') return undefined
const callId = event.type === 'tool/call' ? data.callId : data.subCallId
if (typeof callId !== 'string' || callId === '') return undefined
let args: unknown = data.arguments
if (event.type === 'tool/call') {
if (typeof args !== 'string') return undefined
try { args = JSON.parse(args) as unknown }
catch (_error) { return undefined /* Malformed model JSON remains in the generic tool row. */ }
}
if (!record(args) || typeof args.plan !== 'string') return undefined
const markdown = args.plan
const title = /^#\s+(\S[^\r\n]*)/.exec(markdown.trim())?.[1]
return title === undefined ? undefined : { callId: callId as ToolCallId, markdown, title }
}
/**
* Encode the durable identity of a plan without retaining its text in layout storage.
* @param target - Session and tool-call identity.
* @returns the plan resource address.
*/
export function planAddress(target: PlanAddress): string {
const { session, callId } = target
const parts = session.kind === 'session'
? [session.sessionId, callId]
: ['subagent', session.parentSessionId, session.childSessionId, session.mode, callId]
return `dsh-resource://plan/${parts.map(encodeURIComponent).join('/')}`
}
/**
* Validate a saved or caller-supplied plan resource address.
* @param address - Address submitted to the sidebar or resource provider.
* @returns the decoded identity, or undefined for an unsupported address.
*/
export function parsePlanAddress(address: string): PlanAddress | undefined {
const match = /^dsh-resource:\/\/plan\/([^?#]+)$/.exec(address)
if (match === null) return undefined
try {
const parts = (match[1] as string).split('/').map(decodeURIComponent)
if (parts.some(part => part === '')) return undefined
if (parts.length === 2) return {
session: { kind: 'session', sessionId: parts[0] as SessionId }, callId: parts[1] as ToolCallId,
}
if (parts.length === 5 && parts[0] === 'subagent' && (parts[3] === 'one-shot' || parts[3] === 'continuable')) return {
session: { kind: 'subagent', parentSessionId: parts[1] as SessionId, childSessionId: parts[2] as SessionId, mode: parts[3] },
callId: parts[4] as ToolCallId,
}
return undefined
} catch (_error) {
// Invalid saved percent encoding cannot identify a Session or invocation.
return undefined
}
}
@@ -0,0 +1,29 @@
/** Temporary review previews carry their document in in-memory tab navigation. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { PlanDocument } from './plan.ts'
declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
interface SidebarRightResourceParamsMap {
/** Review text without a logged invocation; never persisted in sidebar layout. */
'plan-review': { planReview: PlanDocument }
}
}
/**
* Name one temporary review within its browser lifetime and Session.
* @param sessionId - Session displaying the review.
* @param requestKey - Browser-unique pending request identity.
* @returns the address used to focus or reopen its preview.
*/
export function reviewPreviewAddress(sessionId: SessionId, requestKey: string): string {
return `dsh-resource://plan-review/${encodeURIComponent(sessionId)}/${encodeURIComponent(requestKey)}`
}
/**
* Recognize temporary plan navigation without interpreting it as logged history.
* @param address - Saved or caller-supplied navigation address.
* @returns whether the address identifies a temporary review preview.
*/
export function isReviewPreviewAddress(address: string): boolean {
return /^dsh-resource:\/\/plan-review\/[^/?#]+\/[^/?#]+$/.test(address)
}
@@ -0,0 +1,23 @@
/** Session-owned memory of pending plans already opened automatically. */
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
interface PlanReviewState {
opened: Partial<Record<string, true>>
}
type PlanReviewActions = {
markOpened: (draft: PlanReviewState, reviewKey: string) => void
}
/**
* Keep manual sidebar closure effective across review component remounts.
* @returns a transient store handle whose instances belong to Session scopes.
*/
export function createPlanReviewStore(): EngineStoreHandle<PlanReviewState, PlanReviewActions> {
return defineStore({
init: (): PlanReviewState => ({ opened: {} }),
actions: {
markOpened: (draft, reviewKey) => { draft.opened[reviewKey] = true },
},
})
}
@@ -5,6 +5,7 @@
* outcomes into null (admitted) or a user-visible failure line; teardown
* empties the seat (HMR safety).
*/
import { ConversationEventRegistry } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -12,19 +13,45 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import { PlanChip } from '../src/client/PlanModeControl.tsx'
import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from '../src/client/PlanCard.tsx'
import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
import { submittedPlan } from '../src/client/plan.ts'
import type { PlanChipInjected } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
function providePreview(ctx: Context) {
const events = new ConversationEventRegistry(ctx)
ctx.provide('uiConversation', { events })
const removeResources = vi.fn()
const removeType = vi.fn()
const registerType = vi.fn((_definition: Parameters<Context['sidebarRightTabs']['register']>[0]) => removeType)
const openResourceIn = vi.fn<Context['sidebarRight']['openResourceIn']>()
const subagentAddress = vi.fn<Context['sessions']['subagentAddress']>(() => undefined)
ctx.provide('sessions', { subagentAddress })
ctx.provide('resources', { register: vi.fn(() => removeResources) })
ctx.provide('sidebarRightTabs', { register: registerType })
ctx.provide('sidebarRight', { openResourceIn })
ctx.provide('remote.session', {})
return { events, removeResources, removeType, registerType, openResourceIn, subagentAddress }
}
const SID = 's-plan' as SessionId
async function bench() {
const ctx = new Context()
const preview = providePreview(ctx)
await ctx.plugin(SlotRegistry).await()
const slots = ctx.get('slots') as SlotRegistry
slots.register({
name: 'root',
children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
children: {
'conversation.input.plan': { kind: 'single', scope: 'session' },
'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
'conversation.plan-review.actions': { kind: 'list', scope: 'session' },
'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' },
'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session' },
},
} as never, () => null)
const execute = vi.fn((_sessionId: SessionId, _line: string) =>
Promise.resolve({ ok: true, value: { commandId: 'c1', result: { kind: 'success' as const } } }))
@@ -32,12 +59,12 @@ async function bench() {
ctx.provide('remote', { commands: commandsRemote })
ctx.provide('remote.commands', commandsRemote)
ctx.provide('locale', new LocaleRuntime(ctx))
return { ctx, slots, execute }
return { ctx, slots, execute, ...preview }
}
describe('ui-plan browser apply', () => {
it('declares every service it binds', () => {
expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'locale'])
expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'])
})
it('node-half apply is an intentional no-op', () => {
@@ -46,6 +73,7 @@ describe('ui-plan browser apply', () => {
it('waits until conversation declares the plan seat', async () => {
const ctx = new Context()
providePreview(ctx)
await ctx.plugin(SlotRegistry).await()
ctx.provide('remote', { commands: {} })
ctx.provide('remote.commands', {})
@@ -83,7 +111,61 @@ describe('ui-plan browser apply', () => {
b.execute.mockResolvedValueOnce({ ok: true, value: undefined } as never)
await expect(injected.exitPlanMode()).resolves.toBe('unknown command: /plan off')
expect(b.events.entries().map(entry => entry.kind)).toEqual(['submitted-plan'])
await fiber.dispose()
expect(b.events.entries()).toEqual([])
expect(b.removeResources).toHaveBeenCalledOnce()
expect(b.removeType).toHaveBeenCalledOnce()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
})
it('binds both entry points to the same Session resource and removes the sidebar seats', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
try {
await fiber.await()
const removeFileEntry = b.slots.register({ name: 'conversation.chat.turnTail', id: 'test-file-deliveries' }, () => null)
expect(b.slots.entries('conversation.chat.turnTail')).toHaveLength(2)
removeFileEntry()
const address = 'dsh-resource://plan/s-plan/call'
const type = b.registerType.mock.calls[0]![0]
expect(type.canOpen!(address)).toBe(true)
expect(type.canOpen!('file:///plan.md')).toBe(false)
expect(type.title(address)).toBeTruthy()
const plan = submittedPlan({ type: 'tool/call', data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } })!
const card = b.slots.entries('conversation.chat.turnTail')[0]!
expect(card.component).toBe(PlanCards)
const injected = (card.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID)
injected.openPlan(plan.callId)
expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address)
const review = b.slots.entries('conversation.plan-review.actions')[0]!
expect(review.component).toBe(PlanReviewOpen)
const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID)
const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } }
reviewInjected.openReview(pending, 'question:1')
expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address)
b.subagentAddress.mockReturnValue({ parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' })
injected.openPlan(plan.callId)
expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call')
reviewInjected.openReview(pending, 'question:1')
expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, 'dsh-resource://plan/subagent/parent/s-plan/continuable/call')
const temporary = { id: 'review', question: 'Approve?', plan: '# Temporary\n\nComplete body', approve: { label: 'Approve' } }
reviewInjected.openReview(temporary, 'question:2')
const first = b.openResourceIn.mock.calls.at(-1)!
expect(type.canOpen!(first[1])).toBe(true)
expect(first).toEqual([SID, expect.stringMatching(/^dsh-resource:\/\/plan-review\/s-plan\//), {
params: { planReview: { title: 'Temporary', markdown: temporary.plan } },
}])
reviewInjected.openReview(temporary, 'question:2')
expect(b.openResourceIn).toHaveBeenLastCalledWith(...first)
reviewInjected.openReview(temporary, 'question:3')
expect(b.openResourceIn.mock.calls.at(-1)![1]).not.toBe(first[1])
expect(b.slots.entries('sidebar.right.pane.tab')[0]!.component).toBe(PlanPreview)
expect(b.slots.entries('sidebar.right.pane.tab.title')[0]!.component).toBe(PlanTitle)
await fiber.dispose()
expect(b.slots.entries('conversation.chat.turnTail')).toEqual([])
expect(b.slots.entries('conversation.plan-review.actions')).toEqual([])
expect(b.slots.entries('sidebar.right.pane.tab')).toEqual([])
expect(b.slots.entries('sidebar.right.pane.tab.title')).toEqual([])
} finally { await fiber.dispose() }
})
})
@@ -0,0 +1,262 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
import { chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { en, zh } from '../src/client/locales.ts'
import { PlanCards, PlanReviewOpen } from '../src/client/PlanCard.tsx'
import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx'
import { planAddress, parsePlanAddress, submittedPlan } from '../src/client/plan.ts'
import { reviewPreviewAddress, isReviewPreviewAddress } from '../src/client/review-preview.ts'
import { planDefinition } from '../src/client/plan-definition.ts'
import { createPlanReviewStore } from '../src/client/review-store.ts'
afterEach(cleanup)
const markdown = '# Keep this plan\n\n## Goal\n\n- Review\n- Implement'
const call = { type: 'tool/call', seq: 12, data: { turn: 1, name: 'exit_plan_mode', callId: 'call:1', arguments: JSON.stringify({ plan: markdown }) } }
const plan = submittedPlan(call)!
const target = { session: { kind: 'session' as const, sessionId: 'session / 中文' as SessionId }, callId: plan.callId }
const t = makeTranslate(en, commonEn)
function planNode(data: typeof plan) {
return { kind: 'submitted-plan', anchorSeq: 12, location: { kind: 'turn', turn: { turn: 1 } }, data }
}
function planHook(nodes: readonly unknown[]): Parameters<typeof PlanCards>[0]['useChat'] {
return ((select: (snapshot: unknown) => unknown) => select({ nodes: { values: () => nodes } })) as Parameters<typeof PlanCards>[0]['useChat']
}
describe('submitted plan identity', () => {
it('reads native, running PTC, and settled PTC calls', () => {
expect(plan).toEqual({ callId: 'call:1', title: 'Keep this plan', markdown })
for (const type of ['tool/ptc-dispatch-start', 'tool/ptc-dispatch']) {
expect(submittedPlan({ type, data: { name: 'exit_plan_mode', subCallId: 'call:1', arguments: { plan: markdown } } })).toEqual(plan)
}
})
it.each([
{ type: 'user/message', data: call.data },
{ type: 'tool/call', data: null },
{ type: 'tool/call', data: [] },
{ type: 'tool/call', data: { ...call.data, name: 'read' } },
{ type: 'tool/call', data: { ...call.data, callId: '' } },
{ type: 'tool/call', data: { ...call.data, arguments: {} } },
{ type: 'tool/call', data: { ...call.data, arguments: '{' } },
{ type: 'tool/call', data: { ...call.data, arguments: 'null' } },
{ type: 'tool/call', data: { ...call.data, arguments: '{"plan":42}' } },
{ type: 'tool/call', data: { ...call.data, arguments: '{"plan":"no title"}' } },
])('leaves malformed or unrelated input to its existing renderer: %j', (event) => {
expect(submittedPlan(event)).toBeUndefined()
})
it('round-trips opaque identifiers and refuses malformed addresses', () => {
expect(parsePlanAddress(planAddress(target))).toEqual(target)
for (const mode of ['one-shot', 'continuable'] as const) {
const child = { session: { kind: 'subagent' as const, parentSessionId: target.session.sessionId, childSessionId: 'child / 中文' as SessionId, mode }, callId: plan.callId }
expect(parsePlanAddress(planAddress(child))).toEqual(child)
}
for (const address of ['file:///plan.md', 'dsh-resource://plan/s/c/extra', 'dsh-resource://plan/s/%XX', 'dsh-resource://plan/s/c?text=x',
'dsh-resource://plan//c', 'dsh-resource://plan/subagent/p/c/invalid/call', 'dsh-resource://plan/subagent/p/c/one-shot']) {
expect(parsePlanAddress(address)).toBeUndefined()
}
})
it('keeps a settled PTC update correlated with its existing card', () => {
expect(planDefinition.match(call as never)).toEqual({ id: plan.callId, role: 'start' })
const event = { type: 'tool/ptc-dispatch', data: { name: 'exit_plan_mode', subCallId: plan.callId, arguments: { plan: markdown } } }
expect(planDefinition.match(event as never)).toEqual({ id: plan.callId, role: 'update' })
const context = { key: 'plan', id: plan.callId, state: plan, start: { event: { seq: 12 }, location: { kind: 'unresolved' } } }
expect(planDefinition.buildViewNode!(context as never)).toMatchObject({ visibility: 'hidden', anchorSeq: 12, data: plan })
expect(planDefinition.buildViewNode!({ ...context, state: undefined, start: undefined, matches: [] } as never)).toBeNull()
})
it('retains the submitted version and recovers a cropped PTC start from settlement', () => {
expect(planDefinition.match({ type: 'user/message', data: {} } as never)).toBeNull()
const state = planDefinition.start({} as never, { event: call } as never, {} as never)
expect(state).toEqual(plan)
expect(planDefinition.update({ state } as never, {} as never)).toBe(state)
const settled = { event: { type: 'tool/ptc-dispatch', seq: 21, data: { name: 'exit_plan_mode', subCallId: plan.callId, arguments: { plan: markdown } } }, location: { kind: 'unresolved' } }
expect(planDefinition.buildViewNode!({ key: 'plan', id: plan.callId, matches: [settled] } as never)).toMatchObject({ anchorSeq: 21, data: plan })
})
})
it('projects native and PTC submissions into their resolved turns without duplicate cards', () => {
const assembler = new ConversationNodeAssembler(
{ entries: () => [planDefinition], fallbackEntry: () => undefined },
{ entries: () => [chatViewDefinition] },
)
const events = [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'step/start', data: { turn: 1, step: 1 } },
call,
{ type: 'tool/call', data: { turn: 1, step: 1, callId: 'root', name: 'run_code', arguments: '{}' } },
{ type: 'tool/ptc-dispatch-start', data: {
rootCallId: 'root', parentCallId: 'root', subCallId: 'ptc', name: 'exit_plan_mode', arguments: { plan: markdown },
} },
{ type: 'tool/ptc-dispatch', data: {
rootCallId: 'root', parentCallId: 'root', subCallId: 'ptc', name: 'exit_plan_mode', arguments: { plan: markdown },
isError: false, content: [],
} },
{ type: 'turn/end', data: { turn: 1 } },
{ type: 'turn/start', data: { turn: 2 } },
{ ...call, data: { ...call.data, turn: 2, callId: 'next-turn' } },
{ type: 'turn/end', data: { turn: 2 } },
]
const entries = events.map((event, index) => ({
type: 'event', event: { ...event, seq: index + 1, time: index },
})) as SessionLiveEventEntry[]
assembler.replaceWindow(entries, false)
assembler.activateTarget('chat')
const snapshot = assembler.snapshot('chat') as ChatSnapshot
expect(snapshot.order).toEqual([])
expect(snapshot.nodes.values()).toHaveLength(3)
const props = { turn: { turn: 1 }, useChat: (select: (snapshot: ChatSnapshot) => unknown) => select(snapshot),
t, openPlan: vi.fn(),
} as unknown as Parameters<typeof PlanCards>[0]
const view = render(<PlanCards {...props} />)
expect(view.container.querySelectorAll('[data-plan-card]')).toHaveLength(2)
view.rerender(<PlanCards {...{ ...props, turn: { turn: 2 } } as unknown as Parameters<typeof PlanCards>[0]} />)
expect(view.container.querySelector('[data-plan-card]')?.getAttribute('data-plan-card')).toBe('next-turn')
assembler.replaceWindow(entries.filter((_, index) => index !== 4), true)
expect((assembler.snapshot('chat') as ChatSnapshot).nodes.values()).toHaveLength(3)
})
describe('plan entry points and document', () => {
it('opens the exact persistent card in either locale', () => {
for (const dictionary of [en, zh]) {
const openPlan = vi.fn()
const props = { turn: { turn: 1 }, useChat: planHook([planNode(plan)]), seq: 30,
t: makeTranslate(dictionary, commonEn), openPlan,
} as unknown as Parameters<typeof PlanCards>[0]
const view = render(<PlanCards {...props} />)
expect(openPlan).not.toHaveBeenCalled()
expect(screen.getByText(dictionary['preview.action'])).toBeTruthy()
expect(screen.queryByText('Implement')).toBeNull()
fireEvent.click(screen.getByRole('button'))
expect(openPlan).toHaveBeenCalledWith(plan.callId)
expect(screen.getByText(plan.title)).toBeTruthy()
view.unmount()
}
})
it('keeps only the current turn’s plans in invocation order', () => {
const revised = { ...plan, callId: 'call:2' as typeof plan.callId, title: 'Second plan' }
const nodes = [
{ ...planNode(revised), anchorSeq: 20 }, planNode(plan),
{ ...planNode(plan), location: { kind: 'turn', turn: { turn: 2 } } },
{ ...planNode(plan), location: { kind: 'unresolved' } },
{ ...planNode(plan), kind: 'tool-call' },
{ ...planNode({ ...revised, callId: 'call:3' as typeof plan.callId }),
anchorSeq: 30, location: { kind: 'step', turn: { turn: 1 } },
},
]
const openPlan = vi.fn()
const props = { turn: { turn: 1 }, useChat: planHook(nodes), t, openPlan } as unknown as Parameters<typeof PlanCards>[0]
const view = render(<PlanCards {...props} />)
expect(screen.getAllByRole('button').map(button => button.textContent)).toEqual([
expect.stringContaining(plan.title), expect.stringContaining(revised.title), expect.stringContaining(revised.title),
])
fireEvent.click(screen.getAllByRole('button')[1]!)
expect(openPlan).toHaveBeenCalledWith('call:2')
view.rerender(<PlanCards {...{ ...props, useChat: planHook([]) }} />)
expect(view.container.innerHTML).toBe('')
})
it.each([true, false])('opens once, preserves manual closure, and reopens a review (logged: %s)', (logged) => {
const openReview = vi.fn()
const store = createPlanReviewStore().create()
const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) }
const props = { review, requestKey: 'question:1', t, openReview, actions: store.actions,
useStore: (select: (state: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
} as unknown as Parameters<typeof PlanReviewOpen>[0]
const first = render(<PlanReviewOpen {...props} />)
expect(openReview).toHaveBeenCalledExactlyOnceWith(review, 'question:1')
first.rerender(<PlanReviewOpen {...props} />)
first.unmount()
const second = render(<PlanReviewOpen {...props} />)
expect(openReview).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' }))
expect(openReview).toHaveBeenCalledTimes(2)
const revised = { plan: '# Revised', ...(logged ? { callId: 'call:2' } : {}) }
second.rerender(<PlanReviewOpen {...{ ...props, review: revised, requestKey: 'question:2' } as unknown as Parameters<typeof PlanReviewOpen>[0]} />)
expect(openReview).toHaveBeenLastCalledWith(revised, 'question:2')
expect(openReview).toHaveBeenCalledTimes(3)
second.unmount()
const other = createPlanReviewStore().create()
render(<PlanReviewOpen {...{ ...props, actions: other.actions,
useStore: (select: (state: ReturnType<typeof other.getSnapshot>) => unknown) => select(other.getSnapshot()),
} as unknown as Parameters<typeof PlanReviewOpen>[0]} />)
expect(openReview).toHaveBeenCalledTimes(4)
})
it('renders temporary Markdown and reports expired navigation after reload', () => {
const address = reviewPreviewAddress(target.session.sessionId, 'window:question:1')
expect(isReviewPreviewAddress(address)).toBe(true)
expect(isReviewPreviewAddress(planAddress(target))).toBe(false)
const props = { t, useResource: () => ({ status: 'none' }),
useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address, params: { planReview: { title: plan.title, markdown } } } } }),
}
const view = render(<PlanPreview {...props as unknown as Parameters<typeof PlanPreview>[0]} />)
expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy()
expect(screen.getByText('Implement')).toBeTruthy()
view.rerender(<PlanTitle {...props as unknown as Parameters<typeof PlanTitle>[0]} />)
expect(screen.getByText(plan.title)).toBeTruthy()
view.rerender(<PlanPreview {...{ ...props,
useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address } } }),
} as unknown as Parameters<typeof PlanPreview>[0]} />)
expect(screen.getByRole('status').textContent).toBe(en['preview.expired'])
view.rerender(<PlanTitle {...{ ...props,
useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address } } }),
} as unknown as Parameters<typeof PlanTitle>[0]} />)
expect(screen.getByText('Plan')).toBeTruthy()
view.rerender(<PlanPreview {...{ ...props,
useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address, params: { line: 2 } } } }),
} as unknown as Parameters<typeof PlanPreview>[0]} />)
expect(screen.getByRole('status').textContent).toBe(en['preview.expired'])
})
it('shows restored Markdown and its heading as the tab title', () => {
const props = {
t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }),
useResource: () => ({ status: 'live', value: plan }),
}
render(<PlanPreview {...props as unknown as Parameters<typeof PlanPreview>[0]} />)
expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy()
expect(screen.getAllByRole('listitem')).toHaveLength(2)
cleanup()
render(<PlanTitle {...props as unknown as Parameters<typeof PlanTitle>[0]} />)
expect(screen.getByText(plan.title)).toBeTruthy()
})
it('copies the complete Markdown and keeps a tab label while history loads', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
try {
const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }), useResource: () => ({ status: 'live', value: plan }) }
const view = render(<PlanPreview {...props as unknown as Parameters<typeof PlanPreview>[0]} />)
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
await Promise.resolve()
expect(writeText).toHaveBeenCalledWith(markdown)
view.rerender(<PlanTitle {...{ ...props, useResource: () => ({ status: 'loading' }) } as unknown as Parameters<typeof PlanTitle>[0]} />)
expect(screen.getByText('Plan')).toBeTruthy()
} finally {
if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', clipboard)
}
})
it.each([en, zh])('localizes plan failures and unavailable providers', (dictionary) => {
const props = { t: makeTranslate(dictionary, commonEn),
useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }),
}
const view = render(<PlanPreview {...{ ...props, useResource: () => ({ status: 'loading' }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
expect(screen.getByRole('status').textContent).toBe(dictionary['preview.loading'])
view.rerender(<PlanPreview {...{ ...props, useResource: () => ({ status: 'none' }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
expect(screen.getByRole('status').textContent).toBe(dictionary['preview.unavailable'])
for (const [code, message] of [
['plan/invalid-address', dictionary['preview.invalidAddress']],
['plan/unavailable', dictionary['preview.historyUnavailable']],
['plan/not-found', dictionary['preview.notFound']],
['plan/read-failed', 'connection lost'],
['session/not-found', 'connection lost'],
] as const) {
view.rerender(<PlanPreview {...{ ...props, useResource: () => ({ status: 'failed', failure: { code, message: 'connection lost' } }) } as unknown as Parameters<typeof PlanPreview>[0]} />)
expect(screen.getByRole('status').textContent).toBe(dictionary['preview.failed'] + message)
}
})
})
@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import { planResourceProvider } from '../src/client/plan-resource.ts'
const address = 'dsh-resource://plan/session/call'
const event = { type: 'tool/call', seq: 1, data: { callId: 'call', name: 'exit_plan_mode', arguments: '{"plan":"# Saved plan"}' } }
const entry = { type: 'event', event }
function setup(records: unknown[], hasMore = false) {
const closed = vi.fn()
const follow = vi.fn(async function* () {
try { yield { type: 'snapshot', cursor: 100, records, hasMore } }
finally { closed() }
})
const page = vi.fn(async () => ({ ok: true, value: { records: [entry], hasMore: false } }))
const provider = planResourceProvider({ follow, page } as unknown as Parameters<typeof planResourceProvider>[0])
return { provider, follow, page, closed }
}
async function read(provider: ReturnType<typeof planResourceProvider>, url = address, signal = new AbortController().signal) {
const values = []
for await (const value of provider.open(url, { signal })) values.push(value)
return values
}
describe('plan history resource', () => {
it('restores the exact invocation and releases its snapshot stream', async () => {
const b = setup([entry])
expect(await read(b.provider)).toEqual([{ ok: true, value: { callId: 'call', markdown: '# Saved plan', title: 'Saved plan' } }])
expect(b.closed).toHaveBeenCalledOnce()
expect(b.page).not.toHaveBeenCalled()
})
it('pages older history with the fixed opening cursor without changing the chat window', async () => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
expect((await read(b.provider))[0]).toMatchObject({ ok: true })
expect(b.page).toHaveBeenCalledWith({ address: { kind: 'session', sessionId: 'session' }, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
expect(b.closed).toHaveBeenCalledOnce()
})
it.each(['one-shot', 'continuable'] as const)('restores a %s subagent plan with its complete parent address on every page', async (mode) => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
const address = `dsh-resource://plan/subagent/parent/child/${mode}/call`
const session = { kind: 'subagent', parentSessionId: 'parent', childSessionId: 'child', mode }
expect(await read(b.provider, address)).toMatchObject([{ ok: true, value: { title: 'Saved plan' } }])
expect(b.follow).toHaveBeenCalledWith({ address: session }, expect.any(AbortSignal))
expect(b.page).toHaveBeenCalledWith({ address: session, throughSeq: 100, beforeSeq: 80 }, expect.any(AbortSignal))
expect(b.closed).toHaveBeenCalledOnce()
})
it('reports missing plans and invalid saved addresses', async () => {
const b = setup([])
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/not-found' } }])
expect(await read(b.provider, 'dsh-resource://plan/s/%')).toMatchObject([{ ok: false, error: { code: 'plan/invalid-address' } }])
expect(b.follow).toHaveBeenCalledOnce()
})
it('does not publish a response after cancellation', async () => {
const b = setup([entry])
const controller = new AbortController()
controller.abort()
expect(await read(b.provider, address, controller.signal)).toEqual([])
expect(b.follow).not.toHaveBeenCalled()
})
it('reports stream failures and closes an empty stream', async () => {
const b = setup([])
b.follow.mockImplementationOnce(async function* () { throw new Error('connection lost') })
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'connection lost' } }])
b.follow.mockImplementationOnce(async function* () {})
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/unavailable' } }])
})
it('preserves Remote failures from paging and stream reads', async () => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
const error = new RemoteError('plan/unavailable', 'history unavailable', {})
b.page.mockResolvedValueOnce({ ok: false, error } as never)
expect(await read(b.provider)).toEqual([{ ok: false, error }])
b.follow.mockImplementationOnce(async function* () { throw error })
expect(await read(b.provider)).toEqual([{ ok: false, error }])
b.follow.mockImplementationOnce(async function* () { throw 'disconnected' })
expect(await read(b.provider)).toMatchObject([{ ok: false, error: { code: 'plan/read-failed', message: 'disconnected' } }])
})
it('waits for a snapshot and discards it when cancellation occurs during stream closure', async () => {
const b = setup([entry])
const controller = new AbortController()
b.follow.mockImplementationOnce(async function* () {
try {
yield entry as never
yield { type: 'snapshot', cursor: 100, records: [entry], hasMore: false }
} finally { b.closed(); controller.abort() }
})
expect(await read(b.provider, address, controller.signal)).toEqual([])
expect(b.closed).toHaveBeenCalledOnce()
expect(b.page).not.toHaveBeenCalled()
})
it.each(['resolve', 'reject'] as const)('discards a page that settles after cancellation: %s', async (settlement) => {
const b = setup([{ type: 'event', event: { type: 'user/message', seq: 80, data: {} } }], true)
const controller = new AbortController()
const started = Promise.withResolvers<undefined>()
const pending = Promise.withResolvers<Awaited<ReturnType<typeof b.page>>>()
b.page.mockImplementationOnce(() => { started.resolve(undefined); return pending.promise })
const result = read(b.provider, address, controller.signal)
await started.promise
controller.abort()
if (settlement === 'resolve') pending.resolve({ ok: true, value: { records: [entry], hasMore: false } })
else pending.reject(new Error('cancelled'))
expect(await result).toEqual([])
expect(b.closed).toHaveBeenCalledOnce()
})
})
+30
View File
@@ -8,6 +8,9 @@
"src"
],
"references": [
{
"path": "../store"
},
{
"path": "../../api/remotes/tsconfig.client.json"
},
@@ -34,6 +37,33 @@
},
{
"path": "../../plan/plan-mode"
},
{
"path": "../../api/session-controller/tsconfig.client.json"
},
{
"path": "../ui-chat"
},
{
"path": "../ui-sidebar-right"
},
{
"path": "../ui-user-questions"
},
{
"path": "../resources"
},
{
"path": "../../typert/protocol"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/tools"
},
{
"path": "../../util/crypto"
}
]
}
@@ -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-user-questions/README.md
README.md: ec20bbc1c16944eb3ffec6798455a544861fec61
README.zh.md: 9d8a2bf216be8e4d755a64e0b36fdcac8242c734
README.md: abbf855706b478857540b12712c5999082b0ab22
README.zh.md: 995035cbb08ff6c64e2b91b61ab49b2bd473b9b7
+5 -3
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
When an agent asks a question in the Web client, this package replaces the chat composer with an interactive question surface. Users can move through questions, choose one or multiple options, enter custom answers, skip items, and submit one structured answer batch. Single-choice selections advance immediately, while drafts survive Session navigation for the lifetime of the page. A single question with a supported presentation intent can use a dedicated surface, including the plan-review card with `Chat about it`, `Refuse`, and `Approve` actions.
When an agent asks a question in the Web client, this package replaces the chat composer with an interactive question surface. Users can move through questions, choose one or multiple options, enter custom answers, skip items, and submit one structured answer batch. Single-choice selections advance immediately, while drafts survive Session navigation for the lifetime of the page. A single question with a supported presentation intent can use a dedicated surface, including the plan-review card with `Request changes` and `Approve` actions.
## Table of Contents
@@ -33,7 +33,7 @@ A multi-select draft keeps its selected labels while the user opens or edits the
### The plan-review card
A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders the waiting-approval card layout: a `Plan review` strip, the plan as the scrolling markdown body, and one decision row of `Chat about it` / `Refuse` / `Approve`. Approve and Refuse answer with the asker's own option labels; `Chat about it` rejects the wait as `ASK_CANCELLED`, returning the composer so the user can say what they want instead.
A `plan-review` intent — set by `dsh-plan-mode` on the `exit_plan_mode` review — renders a compact approval card: a `Plan review` strip with a `View full plan` link, the plan title and a two-line plain-text summary, and `Request changes` / `Approve` actions. The complete plan opens in the sidebar through the link or its permanent Chat card. Approve answers with the asker's approval label; `Request changes` rejects the wait as `ASK_CANCELLED`, returning the composer for the user's feedback without submitting an approval. The card has no separate refusal button.
### Failure and recovery
@@ -51,12 +51,14 @@ The package is one ownership rule: rendering a question is a host UI capability,
### Intent surface election
The card claims a request only when it can send every answer that request allows: one question, the intent declared, the plan present as `detail`, the named approve label offered, and a binary single choice (at most one option besides approve, not multi-select). Anything else stays on the generic flow, which can express it. An intent changes the layout, never which answers are reachable.
The card accepts one question declaring the intent, carrying the plan as `detail`, and offering the named approve label, with at most one alternative and no multi-select. Its secondary action returns to the composer for change requests. Larger choices and multi-select questions remain in the generic flow.
### Copy and locale
Composer chrome copy (pager, buttons, placeholders, validation feedback) is bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry its bound translator plus the locale snapshot source through the inject face, so a locale switch re-renders a mounted composer. Question and option text arrives from the model and renders verbatim; carrier failure messages also display untranslated.
A plan review exposes `conversation.plan-review.actions` with its request key, full text, and optional invocation identity. The plan plugin opens logged plans from history and unlogged reviews as temporary sidebar previews. Opening a document does not answer or dismiss the review.
</details>
-----
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
当 agent(智能体)在 Web 客户端中提问时,本包会用交互式提问界面接管聊天编辑器。用户可以在问题之间导航、选择一个或多个选项、输入自定义答案、跳过问题,并提交一批结构化答案。选择单选项后会立即前进,而草稿会在当前页面的生命周期内跨会话导航保留。若唯一的问题声明了受支持的呈现意图,则可使用专用界面,包括带 `Chat about it`、`Refuse` 和 `Approve` 操作的 plan-review 卡片。
当 agent(智能体)在 Web 客户端中提问时,本包会用交互式提问界面接管聊天编辑器。用户可以在问题之间导航、选择一个或多个选项、输入自定义答案、跳过问题,并提交一批结构化答案。选择单选项后会立即前进,而草稿会在当前页面的生命周期内跨会话导航保留。若唯一的问题声明了受支持的呈现意图,则可使用专用界面,包括带 `Request changes` 和 `Approve` 操作的 plan-review 卡片。
## 目录
@@ -33,7 +33,7 @@ kind: "package-reference"
### plan-review 卡片
`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染等待审批卡片的布局:一条 `Plan review` 条带、计划作为可滚动的 markdown 主体,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。
`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染紧凑审批卡片:顶部为带“查看全文”链接的“计划待审”条带,正文显示计划标题和两行纯文本摘要,底部提供“要求修改”和“同意执行”。通过链接或聊天历史中的常驻卡片可在侧边栏阅读完整计划。“同意执行”用提问方的批准标签回答;“要求修改”以 `ASK_CANCELLED` 拒绝等待,让编辑器归位供用户输入反馈,不会提交批准。卡片不再单列拒绝按钮。
### 失败与恢复
@@ -51,12 +51,14 @@ kind: "package-reference"
### 意图界面选择
卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。
卡片接管声明了意图、以 `detail` 携带计划、提供了被指名的批准标签的单个问题,要求除批准外最多一个选项,且非多选。次要操作返回编辑器供用户提出修改要求。更多选项或多选问题仍由通用流程处理。
### 文案与 locale
编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。
计划审批通过 `conversation.plan-review.actions` 插槽提供请求键、完整正文和可选的调用标识。计划插件从历史中打开已记录计划,并把没有调用标识的审批作为临时侧边栏预览打开。打开文档不会回答或关闭审批。
</details>
-----
@@ -66,7 +66,8 @@
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"clsx": "^2.0.0"
"clsx": "^2.0.0",
"@deepseek-ai/dsh-llm": "workspace:^"
},
"files": [
"lib/index.js",
@@ -12,20 +12,12 @@
flex-direction: column;
width: 100%;
max-width: var(--dsh-chat-content-width);
/* Composer seat sits in a fixed-height conversation column (overflow
hidden): cap the card against the viewport and scroll the plan, so the
strip and the decision row stay reachable on a long plan. */
max-height: min(60vh, 520px);
border: 1px solid var(--dsw-alias-state-warn-secondary);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l2);
border: 0;
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
box-shadow: var(--dsw-elevation-panel);
color: var(--dsw-alias-label-primary);
/* Elevated surface in dark: the plan body inside scrolls once the card hits
the cap above, so the thumb takes the l2 pair (see ui-theme
styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.card,
@@ -39,11 +31,11 @@
align-items: center;
flex-shrink: 0;
gap: 8px;
padding: 10px 16px;
padding: 12px 16px;
background: var(--dsw-alias-state-warn-tertiary);
color: var(--dsw-alias-state-warn-primary);
font-size: 13px;
line-height: 18px;
font-size: 14px;
line-height: 20px;
}
.dot {
@@ -54,17 +46,6 @@
background: var(--dsw-alias-state-warn-primary);
}
/* The plan is the panel's message: it takes the whole body and the scroll. */
.body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px 16px 4px;
font-size: 14px;
line-height: 22px;
}
.footer {
display: flex;
align-items: center;
@@ -88,16 +69,34 @@
gap: 8px;
}
/* The discuss verb stays a quiet text button beside the two decision
capsules: 14px glyph against the 14px label with a slightly wider gap, so
the icon reads as a prefix rather than a peer-sized control. */
.discuss {
gap: 6px;
color: var(--dsw-alias-label-secondary);
.summary {
min-width: 0;
padding: 14px 16px 12px;
}
.discuss:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
.title {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 500;
line-height: 22px;
}
.description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
margin: 8px 0 0;
color: var(--dsw-alias-label-secondary);
font-size: 14px;
line-height: 24px;
}
.discuss {
gap: 6px;
}
@media (max-width: 720px) {
@@ -105,12 +104,14 @@
border-radius: 16px;
}
.body {
padding: 10px 12px 4px;
}
.footer {
align-items: flex-end;
padding: 8px 12px 10px;
}
}
.previewActions {
margin-left: auto;
display: flex;
align-items: center;
}
@@ -1,11 +1,11 @@
import { useMemo, useState } from 'react'
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, extractMarkdownPlainText, IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
import css from './PlanReviewPanel.module.css'
/** The panel's own props: the question domain face, the narrowed review, and the locale seat. */
export type PlanReviewPanelProps =
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't'>
{ pending: PendingQuestion; review: PlanReview } & Pick<QuestionComposerProps, 't' | 'renderSlot'>
/**
* Optional-prop spread for a decision button's tooltip: `title` is optional on
@@ -19,16 +19,12 @@ function tooltip(description: string | undefined): { title?: string } {
}
/**
* Render a plan review as a decision card.
* Render plan review controls; the submitted document opens in the sidebar.
*
* @param props - the question domain face, the narrowed plan review, and `t`.
* @returns The plan-review takeover for this request.
*/
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
const markdownLabels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
}), [t])
export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPanelProps) {
// The panel waits for the host's resolved frame before leaving, so repeated
// clicks must not resubmit. A failed send re-enables it and shows the error.
const [busy, setBusy] = useState(false)
@@ -44,7 +40,11 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
const decide = (label: string): void => {
settle(() => pending.answer({ answers: [{ id: review.id, selected: [label] }] }))
}
const decline = review.decline
const summary = useMemo(() => {
const title = extractMarkdownPlainText(review.plan, { mode: 'first-line' })
const description = extractMarkdownPlainText(review.plan, { mode: 'first-paragraph' })
return { title, description: description === title ? '' : description }
}, [review.plan])
return (
<div className={css.frame} data-plan-review-key={pending.key}>
@@ -52,27 +52,23 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
<div className={css.strip}>
<span className={css.dot} />
{t('plan.header')}
<div className={css.previewActions}>
{renderSlot('conversation.plan-review.actions', { review, requestKey: pending.key })}
</div>
</div>
<div className={css.body} data-plan-review-scroll>
<MarkdownText text={review.plan} labels={markdownLabels} />
<div className={css.summary}>
<h3 className={css.title}>{summary.title}</h3>
{summary.description !== '' && <p className={css.description}>{summary.description}</p>}
</div>
<div className={css.footer}>
<div className={css.feedback} role="status">{error}</div>
<div className={css.actions}>
<Button
variant="ghost" className={css.discuss} icon={<IconEditOutline16 size={14} />}
variant="outline" className={css.discuss} icon={<IconEditOutline16 size={14} />}
disabled={busy} onClick={() => { settle(() => pending.cancel()) }}
>
{t('plan.discuss')}
</Button>
{decline !== undefined && (
<Button
variant="outline" {...tooltip(decline.description)}
disabled={busy} onClick={() => { decide(decline.label) }}
>
{t('plan.decline')}
</Button>
)}
<Button
variant="primary" {...tooltip(review.approve.description)}
disabled={busy} onClick={() => { decide(review.approve.label) }}
@@ -123,7 +123,7 @@ export function QuestionComposer(props: QuestionComposerProps) {
actions={props.actions}
/>
)
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} />
: <PlanReviewPanel key={question.key} pending={question} review={review} t={props.t} renderSlot={props.renderSlot} />
}
type QuestionFlowProps =
@@ -1,6 +1,7 @@
/** Question composer props and one pending Remote waterfall response. */
import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// The client module declares the conversation.composer SlotMap entry required by PropsRuntime.
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type {
AskUserQuestionAnswer, AskUserQuestionItem,
@@ -14,6 +15,13 @@ declare module '@deepseek-ai/dsh-client-ui-session/client' {
}
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Actions for the exact plan under review; approval remains with the question composer. */
'conversation.plan-review.actions': { kind: 'list'; scope: 'session'; owner: { review: PlanReview; requestKey: PendingQuestion['key'] } }
}
}
/** One structured answer batch covering every question of the request. */
export type QuestionAnswer = AskUserQuestionAnswer
@@ -50,6 +58,8 @@ export interface PlanReview {
question: string
/** The plan markdown under review. */
plan: string
/** Logged tool invocation used to reopen this plan. */
callId?: ToolCallId
/** The option that approves the plan. */
approve: QuestionOption
/** The option that declines it; absent when the asker offered no other option. */
@@ -60,16 +70,10 @@ export interface PlanReview {
* Narrow a request to a renderable plan review, or return undefined to leave it
* to the generic question flow.
*
* The card is one decision over one plan, and it claims a request only when it
* can send every answer that request allows — an intent changes the layout,
* never which answers are reachable. So the batch must be a single question
* that declares the intent, carries the plan as its detail, offers the approve
* label the intent names, and is a binary single choice: at most one option
* besides approve, and not multi-select. A third option or a multi-select batch
* has answers two buttons cannot express, so the generic flow keeps it — as it
* keeps any request whose intent the asker's own service would have rejected,
* because the client sits downstream of a wire boundary and every request must
* stay answerable.
* The card offers approval and a return to the composer for change requests.
* It accepts one question carrying the plan as detail and the named approve
* option, with at most one alternative and no multi-select. Larger choices
* remain in the generic question flow.
*
* @param questions - the request's whole question batch.
* @returns The narrowed review, or undefined when the generic flow owns it.
@@ -90,6 +94,7 @@ export function planReviewOf(questions: readonly QuestionItem[]): PlanReview | u
id: question.id,
question: question.question,
plan: question.detail,
...(intent.callId === undefined ? {} : { callId: intent.callId }),
approve,
...(decline === undefined ? {} : { decline }),
}
@@ -220,5 +225,6 @@ export type QuestionWait = PendingQuestion
export type QuestionComposerProps =
PropsRuntime<'conversation.composer'>
& PropsStore<ReturnType<typeof createQuestionDraftStore>>
& PropsRenderSlots<'conversation.plan-review.actions'>
& { matched: QuestionWait }
& PropsLocale<'question'>
@@ -98,6 +98,7 @@ export function apply(ctx: ClientContext): void {
pendingInteraction instanceof PendingQuestion ? pendingInteraction : null,
locale: NS,
store: questionDraftStore,
children: { 'conversation.plan-review.actions': { kind: 'list', scope: 'session' } },
},
QuestionComposer,
))
@@ -14,9 +14,9 @@ export const zh = {
'action.skip': '跳过',
'action.next': '下一题',
'plan.header': '计划待审',
'plan.approve': '确认执行',
'plan.approve': '同意执行',
'plan.decline': '拒绝',
'plan.discuss': '去聊天里说',
'plan.discuss': '要求修改',
} satisfies Record<string, string>
/** The question namespace key union. */
@@ -38,5 +38,5 @@ export const en = {
'plan.header': 'Plan review',
'plan.approve': 'Approve',
'plan.decline': 'Refuse',
'plan.discuss': 'Chat about it',
'plan.discuss': 'Request changes',
} satisfies Record<QuestionKey, string>
@@ -3,6 +3,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import {
PendingQuestion, planReviewOf, type QuestionComposerProps, type QuestionWait,
} from '../src/client/contract/slots.ts'
@@ -108,6 +109,8 @@ const questionDraftStore = createQuestionDraftStore().create(SID)
/** Framework standard-kit stubs: the panel consumes only the locale seat. */
const kit: Omit<QuestionComposerProps, 'matched'> = {
renderSlot: () => null,
SessionProvider: ({ children }) => children,
sessionId: SID,
session: undefined,
pendingInteraction: undefined,
@@ -171,6 +174,16 @@ describe('planReviewOf', () => {
})
})
it('retains the logged invocation so the review can reopen its exact plan', () => {
const question = questions()[0]!
const callId = 'plan-call' as ToolCallId
const review = planReviewOf([{
...question,
intent: { kind: 'plan-review', approve: 'Approve', callId },
}])
expect(review).toEqual({ ...planReviewOf([question]), callId })
})
it('leaves the decline absent when the asker offered approve alone', () => {
const [question] = questions()
const review = planReviewOf([{ ...question as object, options: [{ label: 'Approve' }] } as never])
@@ -203,15 +216,34 @@ describe('planReviewOf', () => {
})
describe('PlanReviewPanel', () => {
it('renders the plan under a review strip, with none of the quiz affordances', () => {
it('passes distinct pending request identities to the preview action for unlogged reviews', () => {
const rendered = vi.fn<(key: string, owner: unknown) => void>()
const renderSlot: QuestionComposerProps['renderSlot'] = (key, owner) => { rendered(key, owner); return null }
const first = wait()
const second = wait()
const view = render(<QuestionComposer matched={first.carrier} {...kit} renderSlot={renderSlot} />)
expect(rendered).toHaveBeenLastCalledWith('conversation.plan-review.actions', {
review: planReviewOf(first.carrier.questions), requestKey: first.carrier.key,
})
view.rerender(<QuestionComposer matched={second.carrier} {...kit} renderSlot={renderSlot} />)
expect(rendered).toHaveBeenLastCalledWith('conversation.plan-review.actions', {
review: planReviewOf(second.carrier.questions), requestKey: second.carrier.key,
})
expect(first.carrier.key).not.toBe(second.carrier.key)
expect(first.answer).not.toHaveBeenCalled()
expect(second.answer).not.toHaveBeenCalled()
})
it('shows the plan title and summary above two review actions', () => {
const { carrier } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
expect(document.querySelector('[data-plan-review-key]')?.getAttribute('data-plan-review-key')).toBe(carrier.key)
expect(screen.getByText(zh['plan.header'])).toBeTruthy()
// The plan renders as markdown, so its heading is a heading.
expect(screen.getByRole('heading', { name: 'Ship the picker' })).toBeTruthy()
expect(screen.getByText('render the rows')).toBeTruthy()
expect(screen.getByText('read the store')).toBeTruthy()
expect(screen.getAllByRole('button')).toHaveLength(2)
expect(screen.queryByText('render the rows')).toBeNull()
expect(document.querySelector('[data-plan-review-scroll]')).toBeNull()
// The question text stays as the card's accessible name rather than a title
// that reads like a test item.
expect(screen.getByLabelText('Approve this plan and leave plan mode?')).toBeTruthy()
@@ -222,6 +254,23 @@ describe('PlanReviewPanel', () => {
expect(screen.queryByRole('textbox')).toBeNull()
})
it('extracts a plain summary and keeps a heading-only plan compact', () => {
const question = questions()[0]!
const detail = '# **Release** plan\n\nReview the [changes](https://example.com) before **shipping**.\n\n## Steps\n- Build'
const { carrier } = wait([{ ...question, detail }])
const view = render(<QuestionComposer matched={carrier} {...kit} />)
expect(screen.getByRole('heading', { name: 'Release plan' })).toBeTruthy()
expect(screen.getByText('Review the changes before shipping.')).toBeTruthy()
expect(screen.queryByRole('link')).toBeNull()
const onlyTitle = wait([{ ...question, detail: '# Plan without a summary' }])
view.rerender(<QuestionComposer matched={onlyTitle.carrier} {...kit} />)
expect(screen.getByRole('heading', { name: 'Plan without a summary' })).toBeTruthy()
expect(document.querySelector('[data-plan-review-key] p')).toBeNull()
const paragraph = wait([{ ...question, detail: 'A single paragraph plan.' }])
view.rerender(<QuestionComposer matched={paragraph.carrier} {...kit} />)
expect(screen.getAllByText('A single paragraph plan.')).toHaveLength(1)
})
it('answers with the asker\'s approve label and keeps its description as the tooltip', () => {
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
@@ -232,25 +281,19 @@ describe('PlanReviewPanel', () => {
expect(answer).toHaveBeenCalledWith(decision('Approve'))
// One-shot: every action locks until the host's resolved frame lands.
expect(approve.hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: zh['plan.discuss'] }).hasAttribute('disabled')).toBe(true)
fireEvent.click(approve)
expect(answer).toHaveBeenCalledTimes(1)
})
it('answers with the asker\'s decline label', () => {
const { carrier, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.decline'] }))
expect(answer).toHaveBeenCalledWith(decision('Keep planning'))
})
it('dismisses the request so the composer returns for a plain message', () => {
const { carrier, cancel } = wait()
const { carrier, cancel, answer } = wait()
render(<QuestionComposer matched={carrier} {...kit} />)
fireEvent.click(screen.getByRole('button', { name: zh['plan.discuss'] }))
expect(cancel).toHaveBeenCalledWith()
expect(answer).not.toHaveBeenCalled()
})
it('omits the tooltip for an option carrying no description', () => {
@@ -261,7 +304,6 @@ describe('PlanReviewPanel', () => {
render(<QuestionComposer matched={carrier} {...kit} />)
expect(screen.getByRole('button', { name: zh['plan.approve'] }).hasAttribute('title')).toBe(false)
expect(screen.getByRole('button', { name: zh['plan.decline'] }).hasAttribute('title')).toBe(false)
})
it('hides the decline action when the asker offered approve alone', () => {
@@ -305,7 +347,7 @@ describe('PlanReviewPanel', () => {
expect(screen.getByText('Plan review')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Approve' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Refuse' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Chat about it' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Refuse' })).toBeNull()
expect(screen.getByRole('button', { name: 'Request changes' })).toBeTruthy()
})
})
@@ -107,6 +107,8 @@ const inputState: InputState = {
* the composed props type mandates delivery of the rest (framework hooks are
* plain stubs per the client testing discipline). */
const kitBase: Omit<QuestionComposerProps, 'matched' | 'useStore' | 'actions'> = {
renderSlot: () => null,
SessionProvider: ({ children }) => children,
session: undefined,
sessionId: SID,
pendingInteraction: undefined,
@@ -46,6 +46,9 @@
},
{
"path": "../ui-session"
},
{
"path": "../../llm/llm"
}
]
}
@@ -297,16 +297,28 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
},
{
key: 'conversation.chat.turnTail',
kind: 'chain',
kind: 'list',
scope: 'session',
summary: 'Selector-routed extension before a completed Turn\'s action row.',
doc: 'Selector-routed extension before a completed Turn\'s action row. The\ncomponent receives the Turn, closing sequence, and file opener. The first\nselector that accepts the owner renders; an all-declined chain is empty.',
summary: 'Ordered feature contributions before a completed Turn\'s action row.',
doc: 'Ordered feature contributions before a completed Turn\'s action row. Each\nentry receives the Turn, closing sequence, and file opener. A fresh `id`\nadds an entry; entries without content return null.',
registerOptions: [
{
name: 'select',
name: 'id',
requirement: 'required',
type: '(owner) => unknown | null',
doc: 'Pure routing selector. Entries are tried in ascending order; the first non-null result wins and arrives as the component\'s `matched` prop. All-null falls through to the owner\'s fallback.',
type: 'string',
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
},
{
name: 'order',
requirement: 'optional',
type: 'number',
doc: 'Position among the entries, ascending (default 0).',
},
{
name: 'label',
requirement: 'optional',
type: 'string | (() => string)',
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
},
],
ownerProps: [
@@ -337,10 +349,11 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'conversation.chat.node\' (client-ui-chat), so it exists while that entry is mounted',
occupants: [
'client-ui-deliverables Deliverables',
'client-ui-deliverables DeliverablesTail id \'@deepseek-ai/dsh-client-ui-deliverables\'',
'client-ui-plan PlanCards',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:213',
},
{
@@ -1051,6 +1064,64 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:201',
},
{
key: 'conversation.plan-review.actions',
kind: 'list',
scope: 'session',
summary: 'Actions for the exact plan under review; approval remains with the question composer.',
doc: 'Actions for the exact plan under review; approval remains with the question composer.',
registerOptions: [
{
name: 'id',
requirement: 'required',
type: 'string',
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
},
{
name: 'order',
requirement: 'optional',
type: 'number',
doc: 'Position among the entries, ascending (default 0).',
},
{
name: 'label',
requirement: 'optional',
type: 'string | (() => string)',
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
},
],
ownerProps: [
'/**\n * A request narrowed to the `plan-review` presentation intent: everything the\n * decision card renders and answers with, so the panel never re-reads the\n * request fields. `approve` and `decline` are the asker\'s own options — an\n * answer must carry one of those labels verbatim — and `plan` is the markdown\n * body under review.\n */\nexport interface PlanReview {\n /** The reviewed question\'s id, echoed in the answer. */\n id: string\n /** The question text, kept as the card\'s accessible name. */\n question: string\n /** The plan markdown under review. */\n plan: string\n /** Logged tool invocation used to reopen this plan. */\n callId?: ToolCallId\n /** The option that approves the plan. */\n approve: QuestionOption\n /** The option that declines it; absent when the asker offered no other option. */\n decline?: QuestionOption\n}',
],
ownerPropsReferences: [],
standardProps: [
'useResource: UseResource',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'usePanelInfo: UsePanelInfo',
'useSessions: UseSessions',
'useSessionStatus: UseSessionStatus',
'useSessionRetainInfo: UseSessionRetainInfo',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'useChat: UseChat',
'useConversation: UseConversation',
'useInput: SnapshotSelectorHook<InputState>',
'inputActions: InputActions',
'useSession: SessionSnapshotSelector',
'sessionId: SessionId',
'useProjection: UseProjection',
'useTrajectory: UseTrajectory',
],
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'conversation.composer\' (client-ui-user-questions), so it exists while that entry is mounted',
occupants: [
'client-ui-plan PlanReviewOpen',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.plan-review.actions\', () => ctx.slots.register(\n { name: \'conversation.plan-review.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-user-questions/src/client/contract/slots.ts:21',
},
{
key: 'conversation.session',
kind: 'single',
@@ -2486,6 +2557,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
occupants: [
'client-ui-deliverables ReviewTab',
'client-ui-plan PlanPreview',
'client-ui-sidebar-browser BrowserBody',
'client-ui-sidebar-documentpreview TextPreview',
'client-ui-sidebar-files FilesBody',
@@ -2534,6 +2606,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: 'SidebarRightTabInjected',
declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
occupants: [
'client-ui-plan PlanTitle',
'client-ui-sidebar-browser BrowserTitle',
'client-ui-sidebar-documentpreview TextTitle',
'client-ui-sidebar-files FilesTitle',
@@ -4098,7 +4098,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AskUserQuestionIntent',
declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n};',
declaration: 'export type AskUserQuestionIntent = {\n kind: \'plan-review\';\n approve: string;\n callId?: ToolCallId;\n};',
},
{
name: 'AskUserQuestionItem',
@@ -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/interaction/user-questions/README.md
README.md: 5558c1483937982aad791f9c90e81ef9b02b9d83
README.zh.md: 17cf9c973c82e4854c2807d85ff09cb9751bca14
README.md: b7082f7b76ad974ecb75f48dc84f857784121fc9
README.zh.md: 81f1f3e72b569dcaac96add3f6ff73cbe9038965
@@ -74,6 +74,8 @@ No direct invalidation; the named consumer owns any request-prefix changes.
None.
The optional plan-review `callId` identifies the logged tool invocation for document navigation. It does not change the answer or its validation.
</details>
**Runtime invariant:** No companion is published. The answerer waterfall is resolved per request and returns directly to its caller; the seam publishes no independent request/answer audit stream.
@@ -74,6 +74,8 @@ kind: "package-reference"
无。
计划审批中可选的 `callId` 标识已记录的工具调用,供文档导航使用,不改变回答及其校验。
</details>
**运行时不变式:** 不发布伴生入口。answerer waterfall 按请求解析并把结果直接返回调用方;该 seam 不发布独立的请求/回答审计流。
@@ -2,6 +2,7 @@
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Agent } from '@deepseek-ai/dsh-agent/types'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
/** One selectable answer offered to the user. */
export interface AskUserQuestionOption {
@@ -27,6 +28,8 @@ export type AskUserQuestionIntent = {
* An `approve` naming no option of its own question is rejected at `ask()`.
*/
approve: string
/** Logged tool invocation whose arguments contain the reviewed plan. */
callId?: ToolCallId
}
/** One question in a user-questions request. */
+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/plan/plan-mode/README.md
README.md: 693273ab4bdfddebd6145046e8354a5a211c8b8b
README.zh.md: 4782eed381ee3bdffab22ca162696f8711b33b47
README.md: 642caf0c6db6470e93c7b0583d2bfe145ed99f29
README.zh.md: 4165d5b925083d10c76312d00293fd0b0ac84ba0
+2
View File
@@ -105,6 +105,8 @@ When `ctx.sessionProjections` is composed, the package registers the `plan` unit
| [`src/client.ts`](src/client.ts) | Client-namespace re-export of the types outlet |
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates the `plan/mode` payload shape |
The review intent carries the originating tool-call id, so the Web client can reopen the same submitted plan after the review closes. The complete Markdown remains in the existing native call or PTC dispatch log.
</details>
-----
+2
View File
@@ -105,6 +105,8 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
| [`src/client.ts`](src/client.ts) | types 出口的客户端命名空间再导出 |
| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验 `plan/mode` 载荷结构 |
审批意图携带发起工具调用的标识,让 Web 客户端在审批关闭后重新打开同一份计划。完整 Markdown 保留在已有的原生调用或 PTC dispatch 日志中。
</details>
-----
+1 -1
View File
@@ -312,7 +312,7 @@ export class PlanModeController extends Service {
// Presentation only: a capable UI renders the plan as a review
// decision instead of a generic question, and answers with one of
// the labels above either way.
intent: { kind: 'plan-review', approve: APPROVE_LABEL },
intent: { kind: 'plan-review', approve: APPROVE_LABEL, callId: exec.callId },
}],
agent,
signal: exec.signal,
@@ -1074,7 +1074,7 @@ describe('exit_plan_mode', () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
await callExit(ctx, agent)
const question = asked[0]?.questions[0]
expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' })
expect(question?.intent).toEqual({ kind: 'plan-review', approve: 'Approve', callId: `call-exit-${callCounter}` })
// The named label is one this same question offers, so a UI honouring the
// intent answers a choice this tool accepts.
expect(question?.options?.map(option => option.label)).toContain(question?.intent?.approve)
+33
View File
@@ -3653,12 +3653,24 @@ importers:
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
'@deepseek-ai/dsh-api-session-controller':
specifier: workspace:^
version: link:../../api/session-controller
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale
'@deepseek-ai/dsh-client-resources':
specifier: workspace:^
version: link:../resources
'@deepseek-ai/dsh-client-store':
specifier: workspace:^
version: link:../store
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../../test-support/client-runtime
'@deepseek-ai/dsh-client-ui-chat':
specifier: workspace:^
version: link:../ui-chat
'@deepseek-ai/dsh-client-ui-conversation':
specifier: workspace:^
version: link:../ui-conversation
@@ -3671,15 +3683,33 @@ importers:
'@deepseek-ai/dsh-client-ui-session':
specifier: workspace:^
version: link:../ui-session
'@deepseek-ai/dsh-client-ui-sidebar-right':
specifier: workspace:^
version: link:../ui-sidebar-right
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-client-ui-user-questions':
specifier: workspace:^
version: link:../ui-user-questions
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-plan-mode':
specifier: workspace:^
version: link:../../plan/plan-mode
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:^
version: link:../../typert/protocol
'@deepseek-ai/dsh-util-crypto':
specifier: workspace:^
version: link:../../util/crypto
'@types/react':
specifier: ~18.3.1
version: 18.3.31
@@ -5024,6 +5054,9 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:^
version: link:../ui-slots
'@deepseek-ai/dsh-llm':
specifier: workspace:^
version: link:../../llm/llm
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
@@ -5,8 +5,6 @@
- text: Standard mode
- button "More actions":
- img
- button "Open right sidebar":
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -47,6 +45,7 @@
- img
- text: "Think The plan was approved. The user's last instruction says: \"Once the plan is approved, reply with the single word DONE and stop.\" So I should just reply with DONE and stop."
- paragraph: DONE
- 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open"
- button "Copy":
- img
- button "Good response":
@@ -5,8 +5,6 @@
- text: Standard mode
- button "More actions":
- img
- button "Open right sidebar":
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
@@ -27,6 +25,7 @@
- img
- text: System prompt
- paragraph: DONE
- 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open"
- button "Copy":
- img
- button "Good response":
@@ -0,0 +1,39 @@
- 'region "Add `--greeting` flag to CLI"':
- button "Copy":
- img
- heading "Add --greeting flag to CLI" [level=1]:
- text: Add
- code: "--greeting"
- text: flag to CLI
- list:
- listitem:
- strong: Locate the CLI entry point
- text: (e.g.,
- code: cli.py
- text: ","
- code: main.go
- text: ","
- code: index.js
- text: etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).
- listitem:
- strong: Register a new optional string argument
- text: named
- code: "--greeting"
- text: with a short alias (
- code: "-g"
- text: if available) and a sensible default value (e.g.,
- code: "\"Hello\""
- text: ).
- listitem:
- strong: Thread the parsed value
- text: through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).
- listitem:
- strong: Update the help text
- text: so
- code: "--help"
- text: or
- code: "-h"
- text: shows the new flag with its description.
- listitem:
- strong: No tests or config changes
- text: unless they already exist and directly validate the flag's presence.
+8 -40
View File
@@ -1,44 +1,12 @@
- region "Approve this plan and leave plan mode?":
- text: Plan review
- heading "Add --greeting flag to CLI" [level=1]:
- text: Add
- code: "--greeting"
- text: flag to CLI
- list:
- listitem:
- strong: Locate the CLI entry point
- text: (e.g.,
- code: cli.py
- text: ","
- code: main.go
- text: ","
- code: index.js
- text: etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).
- listitem:
- strong: Register a new optional string argument
- text: named
- code: "--greeting"
- text: with a short alias (
- code: "-g"
- text: if available) and a sensible default value (e.g.,
- code: "\"Hello\""
- text: ).
- listitem:
- strong: Thread the parsed value
- text: through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).
- listitem:
- strong: Update the help text
- text: so
- code: "--help"
- text: or
- code: "-h"
- text: shows the new flag with its description.
- listitem:
- strong: No tests or config changes
- text: unless they already exist and directly validate the flag's presence.
- status
- button "Chat about it":
- button "Open plan in sidebar":
- text: View full plan
- img
- text: Chat about it
- button "Refuse"
- heading "Add --greeting flag to CLI" [level=3]
- paragraph: Locate the CLI entry point (e.g., cli.py, main.go, index.js etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).
- status
- button "Request changes":
- img
- text: Request changes
- button "Approve"