From 10029b995fc0236c8880e836c2853094ad36ce79 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 10 Sep 2026 17:18:38 +0800 Subject: [PATCH 001/225] feat(web): add compact details to recorded tool rows --- ...client-derived-tool-presentation.i18n.yaml | 4 +- ...-08-23-client-derived-tool-presentation.md | 4 +- ...-23-client-derived-tool-presentation.zh.md | 4 +- .../2026-09-10-compact-tool-details.i18n.yaml | 6 + .../2026-09-10-compact-tool-details.md | 27 + .../2026-09-10-compact-tool-details.zh.md | 27 + apps/web/tests/tool-details.e2e.ts | 83 ++ apps/web/tsconfig.json | 1 + .../ui-conversation/src/client/locales.ts | 372 +++++++++ packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 + packages/client/ui-tool/README.zh.md | 2 + packages/client/ui-tool/src/client/apply.ts | 2 + .../tool/components/ToolDetails.module.css | 257 ++++++ .../client/tool/components/ToolDetails.tsx | 134 +++ .../client/tool/components/ToolRow.module.css | 11 + .../src/client/tool/components/ToolRow.tsx | 71 +- .../tool/models/control-details-model.ts | 182 ++++ .../client/tool/models/detail-model-shared.ts | 151 ++++ .../client/tool/models/details-card-model.ts | 145 ++++ .../tool/models/inspection-details-model.ts | 173 ++++ .../src/client/tool/models/todo-diff-model.ts | 76 ++ .../src/client/tool/models/todo-history.ts | 89 ++ .../src/client/tool/toolviews/details-row.tsx | 117 +++ .../src/client/tool/toolviews/todo-row.tsx | 28 +- .../tests/assembly-surfaces.client.spec.tsx | 2 +- .../ui-tool/tests/details-row.client.spec.tsx | 98 +++ .../ui-tool/tests/todo-history.client.spec.ts | 76 ++ .../ui-tool/tests/todo-row.client.spec.tsx | 18 +- .../ui-trajectory/tests/views.client.spec.tsx | 1 + .../src/client/slot-catalog.ts | 1 + snapshots/web/tool-details/session.v3.jsonl | 98 +++ snapshots/web/tool-details/snapshot.yml | 7 + snapshots/web/tool-details/ui.expected.md | 780 ++++++++++++++++++ tsconfig.host.json | 1 + 35 files changed, 3001 insertions(+), 53 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md create mode 100644 .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md create mode 100644 apps/web/tests/tool-details.e2e.ts create mode 100644 packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css create mode 100644 packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx create mode 100644 packages/client/ui-tool/src/client/tool/models/control-details-model.ts create mode 100644 packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts create mode 100644 packages/client/ui-tool/src/client/tool/models/details-card-model.ts create mode 100644 packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts create mode 100644 packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts create mode 100644 packages/client/ui-tool/src/client/tool/models/todo-history.ts create mode 100644 packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx create mode 100644 packages/client/ui-tool/tests/details-row.client.spec.tsx create mode 100644 packages/client/ui-tool/tests/todo-history.client.spec.ts create mode 100644 snapshots/web/tool-details/session.v3.jsonl create mode 100644 snapshots/web/tool-details/snapshot.yml create mode 100644 snapshots/web/tool-details/ui.expected.md diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index 6f20df4af6..a586d48851 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 6b19dc881d572bfece345cbbd5eca688b2e05aab -2026-08-23-client-derived-tool-presentation.zh.md: 98ada31627398f317e4c41336af56e8c6cf0837e +2026-08-23-client-derived-tool-presentation.md: d41ec5ea8ad3707f98eab3803e0d172c05f58041 +2026-08-23-client-derived-tool-presentation.zh.md: 5dd53cdefda4a5e0efcc047bc81edbbd5f199509 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 6b19dc881d..d41ec5ea8a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -22,7 +22,7 @@ The required result is one raw Session journal and one Client presentation owner ## Decision -The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md); all other presentation and ownership constraints remain. +The visual-equivalence requirements below exclude the separately approved [nested terminal-card fix](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md); all other presentation and ownership constraints remain. The Session Remote journal sends only raw, validated, persistable Session events. `session.page` and `session.follow` do not parse tool arguments, query the Tools registry, restore a presenter scope, execute `presentCall` or `presentResult`, or construct or clone any tool view. @@ -51,7 +51,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall | Retained | the Session log format, Remote journal lifecycle, and Conversation identity/topology | | Retained | the existing keyed slot, Generic fallback, and Chat, Details, and Trajectory structure | | Forbidden | a new Client presenter service, parallel registry, or wire renderer id | -| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) | +| Forbidden | new cards, visual redesign, interaction redesign, or Code Dispatch rich-card enhancements except the [nested terminal-card exception](../bug-fix/2026-09-05-nested-terminal-cards.md) and [compact tool details](2026-09-10-compact-tool-details.md) | | Forbidden | compatibility dual-writing, version negotiation, or retention of the old `view` field | ## Terminology diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 98ada31627..5dd53cdefd 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -22,7 +22,7 @@ Host presenter 与 Client keyed renderer 分担展示会形成对同一事件的 ## Decision -下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md);其他展示与所有权约束全部保留。 +下述展示对等要求不包含已独立批准的[嵌套 terminal 卡片修复](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md);其他展示与所有权约束全部保留。 Session Remote journal 只下发原始、已验证、可持久化的 Session event。`session.page` 和 `session.follow` 不解析工具参数,不查询 Tools registry,不恢复 presenter scope,不执行 `presentCall`/`presentResult`,也不构造或克隆任何 tool view。 @@ -51,7 +51,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa | 保留 | Session 日志格式、Remote journal 生命周期与 Conversation identity/topology | | 保留 | 现有 keyed slot、Generic fallback、Chat、Details 与 Trajectory 结构 | | 禁止 | 新 Client presenter service、平行 registry 或 wire renderer id | -| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)除外 | +| 禁止 | 新卡片、视觉改版、交互改版或 Code Dispatch rich-card 增强,[嵌套 terminal 卡片例外](../bug-fix/2026-09-05-nested-terminal-cards.zh.md)和[紧凑工具详情](2026-09-10-compact-tool-details.zh.md)除外 | | 禁止 | 为兼容保留双写、版本协商或旧 `view` 字段 | ## 术语 diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml new file mode 100644 index 0000000000..74691e5932 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md +2026-09-10-compact-tool-details.md: ca8f4721f4a5f121c98265c304ba6a30bb42596f +2026-09-10-compact-tool-details.zh.md: e62ed43f66af7d06898808733f556b9b42abb0e8 diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md new file mode 100644 index 0000000000..ca8f4721f4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md @@ -0,0 +1,27 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +English | [中文](2026-09-10-compact-tool-details.zh.md) + +## Problem + +Goal, todo, and schedule results contain a small set of user-facing facts that take more effort to read as JSON. Dedicated expanded bodies need to fit the existing conversation rows without adding a second presentation registry or live state to historical calls. + +## Decision + +The `ui-tool` package registers goal and schedule toolviews through the existing keyed slot and shares one package-internal fields/list component with the todo view. Successful, recognized results display recorded values; incomplete calls, errors, malformed values, and unsupported result formats retain generic input/output. Schedule domain errors, including uncertain persistence and an unknown deletion target, retain their original result. + +Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection. + +This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative. + +## Alternatives considered + +**A shared presenter framework:** the existing keyed slot already dispatches tools. Reusing only the detail body keeps the abstraction local until another package needs it. + +**A dashboard inside each card:** interactive controls, progress graphics, and live states add visual weight and can misrepresent what a historical call returned. Compact read-only fields and lists preserve the conversation's hierarchy. + +## Consequences + +Seven tool names gain readable details without changing Host tools, Session events, or public Client exports. The adapters own recognition of their recorded result formats; unsupported data remains inspectable as raw text. The authored tool-details Session replay covers their assembled rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md new file mode 100644 index 0000000000..e62ed43f66 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Compact Tool Details from Recorded Results + +Status: implemented + +[English](2026-09-10-compact-tool-details.md) | 中文 + +## 问题 + +目标、任务清单和定时任务的结果包含少量面向用户的事实,以 JSON 阅读时较费力。专用展开内容需要融入现有对话行,不能为历史调用新增一套呈现注册表或实时状态。 + +## 决策 + +`ui-tool` 包通过现有按键分发的插槽注册目标和定时任务视图,并与任务清单视图共用一个包内字段/列表组件。成功且可识别的结果显示记录值;未完成调用、错误、格式异常和不支持的结果格式保留通用输入/输出。定时任务领域错误(包括持久化不确定和删除目标不存在)保留原始结果。 + +工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。 + +这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。 + +## 考虑过的替代方案 + +**共享呈现框架:**现有按键插槽已负责工具分发。仅复用详情内容,可以在其他包出现需求前将抽象限制在包内。 + +**在每张卡片中嵌入仪表盘:**交互控件、进度图形和实时状态会增加视觉负担,也可能歪曲历史调用返回的事实。紧凑的只读字段和列表保留了对话的层级。 + +## 影响 + +七个工具名称获得可读详情,无需修改 Host 工具、Session 事件或公共 Client 导出。适配器负责识别各自记录的结果格式;不支持的数据仍可作为原始文本查看。手工编写的 tool-details Session 回放覆盖它们在完整页面中的呈现。 diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts new file mode 100644 index 0000000000..a5919daeed --- /dev/null +++ b/apps/web/tests/tool-details.e2e.ts @@ -0,0 +1,83 @@ +/** Authored Session rendering of compact recorded Tool results. */ +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/tool-details', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/tool-details/session.v3.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/tool-details/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'tool-details-web-e2e' +const PROMPT = 'Show a goal, a to-do list, and reminders.' + +describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + const fixture = await readFile(FIXTURE, 'utf8') + expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, fixture, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + const skillRow = page.locator('[data-tool="create_goal"]') + await expandOwningTurnProcess(page, skillRow) + await skillRow.waitFor({ timeout: 15_000 }) + }, 120_000) + + afterAll(async () => { + try { await browser?.close() } finally { await scaffold?.close() } + }) + + it('expands every recorded tool through its keyed renderer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-tool-details')) + const rows = page.locator('[data-tool]') + expect(await rows.count()).toBe(44) + for (const row of await rows.all()) { + await row.getByRole('button', { expanded: false }).first().click() + expect(await row.getByRole('listitem').count()).toBeGreaterThan(0) + expect(await row.getByText('Output', { exact: true }).count()).toBe(0) + expect(await row.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1) + } + const changedTodos = page.locator('[data-tool="todo_write"]').nth(1) + await changedTodos.getByText('1 added · 1 updated · 1 removed', { exact: true }).waitFor() + for (const summary of await page.locator('[data-tool] details > summary').all()) await summary.click() + await page.locator('[data-tool="create_goal"]').getByText('Awaiting continuation', { exact: true }).waitFor() + const schema = page.locator('[data-tool="cordis_inspect_list"] pre').first() + await expect.poll(() => schema.textContent()).toContain('"properties"') + const snapshots = await Promise.all(Array.from({ length: 44 }, (_, index) => + captureStableAria(page, `[data-chat-call-id="details-call-${index + 1}"] [data-tool]`, scaffold.workspaceCwd))) + const snapshot = snapshots.join('\n') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + await page.setViewportSize({ width: 360, height: 800 }) + const card = page.locator('[data-tool="schedule_create"]') + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps the snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.v3.jsonl', 'ui.expected.md']) + }) + +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3b01e3990c..0ed6e455fb 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -106,6 +106,7 @@ "tests/background-job-list.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", + "tests/tool-details.e2e.ts", "tests/turn-tail-actions.e2e.ts", "tests/goal-multi-turn-actions.e2e.ts", "tests/chat-scroll-fixture.ts", diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 56da231c15..5deb21f04b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -85,6 +85,192 @@ export const zh = { 'todo.progress.active': '{active} 进行中', 'todo.progress.pending': '{pending} 待处理', 'todo.rowTitle': '更新任务清单', + 'tool.title.createGoal': '创建目标', + 'tool.title.getGoal': '查看目标', + 'tool.title.updateGoal': '更新目标', + 'tool.title.createSchedule': '创建定时任务', + 'tool.title.listSchedules': '查看定时任务', + 'tool.title.deleteSchedule': '删除定时任务', + 'detail.state': '状态', + 'detail.todo.completed': '已完成', + 'detail.todo.in_progress': '进行中', + 'detail.todo.pending': '待处理', + 'detail.todo.empty': '任务清单为空', + 'todo.diff.initial': '首次记录', + 'todo.diff.compare': '与上次清单相比', + 'todo.diff.unavailable': '旧清单不可用', + 'todo.diff.noChanges': '清单没有变化', + 'todo.diff.added': '新增 {count}', + 'todo.diff.updated': '更新 {count}', + 'todo.diff.removed': '移除 {count}', + 'todo.diff.unchanged': '{count} 项未变化', + 'todo.diff.addedItem': '新增', + 'todo.diff.updatedItem': '状态变化', + 'todo.diff.movedItem': '顺序调整', + 'todo.diff.removedItem': '移除', + 'detail.goal.empty': '没有目标', + 'detail.goal.active': '进行中', + 'detail.goal.disarmed': '等待继续', + 'detail.goal.paused': '已暂停', + 'detail.goal.blocked': '受阻', + 'detail.goal.complete': '已完成', + 'detail.goal.rounds': '执行轮次', + 'detail.goal.reason': '受阻原因', + 'detail.days': '{count} 天', + 'detail.hours': '{count} 小时', + 'detail.minutes': '{count} 分钟', + 'detail.seconds': '{count} 秒', + 'detail.schedule.once': '单次', + 'detail.schedule.every': '每 {interval}', + 'detail.schedule.when': '计划时间', + 'detail.schedule.frequency': '重复', + 'detail.schedule.scheduled': '等待触发', + 'detail.schedule.overdue': '已到期,等待会话恢复', + 'detail.schedule.empty': '没有定时任务', + 'detail.schedule.deleted': '已删除', + 'detail.schedule.count': '{count} 个定时任务', + 'tool.title.inspectProviders': '检查提供方', + 'tool.title.queryRuntime': '查询运行时', + 'tool.title.inspectPlugins': '检查动态插件', + 'tool.title.workflow': '运行工作流', + 'tool.title.ralph': '运行 Ralph', + 'tool.title.readEvent': '读取事件', + 'tool.title.searchEvents': '搜索事件', + 'tool.title.traceEvent': '追踪事件', + 'tool.title.searchSessions': '搜索会话', + 'tool.title.traceSession': '追踪会话', + 'tool.title.listModels': '查看可用模型', + 'tool.title.subagent': '委派任务', + 'tool.title.listAgents': '查看代理', + 'tool.title.sendMessage': '发送消息', + 'tool.title.interruptAgent': '中断代理', + 'tool.title.listJobs': '查看后台任务', + 'tool.title.readJob': '读取任务输出', + 'tool.title.killJob': '取消后台任务', + 'tool.title.openTerminal': '创建终端', + 'tool.title.readTerminal': '读取终端', + 'tool.title.listTerminals': '查看终端', + 'tool.title.signalTerminal': '发送终端信号', + 'tool.title.closeTerminal': '关闭终端', + 'tool.title.lsp': '查询代码符号', + 'tool.title.findDefinition': '查找定义', + 'tool.title.findReferences': '查找引用', + 'tool.title.findImplementation': '查找实现', + 'tool.title.hoverSymbol': '查看符号信息', + 'tool.title.spawnTeammate': '创建队友', + 'tool.title.createTeamTask': '创建团队任务', + 'tool.title.getTeamTask': '读取团队任务', + 'tool.title.updateTeamTask': '更新团队任务', + 'tool.title.listTeamTasks': '查看团队任务', + 'tool.title.waitAgent': '等待队友', + 'detail.recordedResult': '调用结果', + 'detail.empty': '暂无结果', + 'detail.none': '无', + 'detail.yes': '是', + 'detail.no': '否', + 'detail.moreInInspect': '另有 {count} 项,可在「查看」中读取', + 'detail.status.running': '运行中', + 'detail.status.idle': '空闲', + 'detail.status.ready': '就绪', + 'detail.status.inactive': '未运行', + 'detail.status.provisioning': '准备中', + 'detail.status.failed': '失败', + 'detail.status.completed': '已完成', + 'detail.status.deleted': '已删除', + 'detail.status.killed': '已取消', + 'detail.status.accepted': '已接收', + 'detail.status.queued': '已入队', + 'detail.status.exited': '已退出', + 'detail.field.id': 'ID', + 'detail.field.revision': '版本', + 'detail.field.platform': '平台', + 'detail.field.provider': '提供方', + 'detail.field.model': '模型', + 'detail.field.role': '角色', + 'detail.field.context': '上下文', + 'detail.field.owner': '负责人', + 'detail.field.ready': '可开始', + 'detail.field.dependencies': '前置任务', + 'detail.field.writeScopes': '文件范围', + 'detail.field.warnings': '提示', + 'detail.field.diagnostics': '诊断', + 'detail.field.methods': '方法', + 'detail.field.inputSchema': '输入 Schema', + 'detail.field.outputSchema': '输出 Schema', + 'detail.field.currentPackage': '当前包', + 'detail.field.nextPackage': '待运行的包', + 'detail.field.latestRun': '最近运行', + 'detail.field.packages': '版本包', + 'detail.field.registrations': '注册项', + 'detail.field.props': '属性', + 'detail.field.data': '数据', + 'detail.field.source': '来源', + 'detail.field.content': '内容', + 'detail.field.message': '消息', + 'detail.field.messageId': '消息 ID', + 'detail.field.root': '根节点', + 'detail.field.pid': '进程 ID', + 'detail.field.type': '类型', + 'detail.field.time': '时间', + 'detail.field.seq': '事件序号', + 'detail.field.turn': '轮次', + 'detail.field.step': '步骤', + 'detail.field.callId': '调用 ID', + 'detail.field.agents': '启动代理数', + 'detail.field.result': '结果', + 'detail.field.parent': '父级', + 'detail.field.depth': '层级', + 'detail.field.exitCode': '退出码', + 'detail.field.signal': '信号', + 'detail.field.previousStatus': '中断前状态', + 'detail.field.agent': '代理 ID', + 'detail.field.job': '任务 ID', + 'detail.field.task': '任务内容', + 'detail.field.processGroup': '进程组', + 'detail.field.availability': '可用状态', + 'detail.field.bestMatch': '最相关事件', + 'detail.field.target': '目标事件', + 'detail.field.surface': '记录状态', + 'detail.agents.count': '{count} 个代理', + 'detail.jobs.count': '{count} 个后台任务', + 'detail.terminals.count': '{count} 个终端', + 'detail.tasks.count': '{count} 个团队任务', + 'detail.tasks.nextPage': '后面还有任务;下一页位置为 {cursor}', + 'detail.locations.count': '{count} 个位置', + 'detail.location': '第 {line} 行,第 {column} 列', + 'detail.receipt.delivered': '消息已送达', + 'detail.receipt.interrupt': '已请求中断', + 'detail.receipt.started': '已启动', + 'detail.receipt.cancel': '已请求取消', + 'detail.receipt.alreadyFinished': '任务已结束', + 'detail.receipt.signal': '信号已发送', + 'detail.receipt.closed': '已关闭', + 'detail.receipt.closing': '关闭中', + 'detail.wait.noProgress': '没有正在运行的队友', + 'detail.wait.title': '队友状态', + 'detail.wait.timeout': '等待超时', + 'detail.wait.changed': '检测到变化', + 'detail.agent.reply': '代理回复', + 'detail.models.title': '可用模型', + 'detail.output.lines': '第 {begin}–{end} 行,共 {total} 行', + 'detail.output.truncated': '输出已截断', + 'detail.providers.count': '{count} 个检查提供方', + 'detail.plugins.count': '{count} 个动态插件', + 'detail.workflow.script': '工作流脚本', + 'detail.ralph.reportedComplete': '代理报告完成', + 'detail.ralph.reportedBlocker': '代理报告受阻', + 'detail.ralph.limit': '已达到轮次上限', + 'detail.report.nextSteps': '待完成事项', + 'detail.trace.replacedBy': '被替换为', + 'detail.trace.replacementChain': '替换链', + 'detail.trace.replaces': '替换的事件', + 'detail.trace.sources': '引用来源', + 'detail.trace.derived': '派生事件', + 'detail.trace.ancestors': '祖先会话', + 'detail.trace.descendants': '后代会话', + 'detail.matches.count': '{count} 条匹配', + 'detail.matches.capped': '已达到结果上限,可缩小搜索范围', + 'detail.event.neighbors': '前后事件', 'todo.completed': '{done}/{total} 已完成', 'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', 'ask.rowTitle': '提问', @@ -251,6 +437,192 @@ export const en = { 'todo.progress.active': '{active} in progress', 'todo.progress.pending': '{pending} pending', 'todo.rowTitle': 'Update to-do list', + 'tool.title.createGoal': 'Create goal', + 'tool.title.getGoal': 'View goal', + 'tool.title.updateGoal': 'Update goal', + 'tool.title.createSchedule': 'Create reminder', + 'tool.title.listSchedules': 'List reminders', + 'tool.title.deleteSchedule': 'Delete reminder', + 'detail.state': 'Status', + 'detail.todo.completed': 'Completed', + 'detail.todo.in_progress': 'In progress', + 'detail.todo.pending': 'Pending', + 'detail.todo.empty': 'The to-do list is empty', + 'todo.diff.initial': 'Initial list', + 'todo.diff.compare': 'Changes since the previous list', + 'todo.diff.unavailable': 'Previous list unavailable', + 'todo.diff.noChanges': 'No changes to the list', + 'todo.diff.added': '{count} added', + 'todo.diff.updated': '{count} updated', + 'todo.diff.removed': '{count} removed', + 'todo.diff.unchanged': '{count} unchanged', + 'todo.diff.addedItem': 'Added', + 'todo.diff.updatedItem': 'Status changed', + 'todo.diff.movedItem': 'Reordered', + 'todo.diff.removedItem': 'Removed', + 'detail.goal.empty': 'No goal', + 'detail.goal.active': 'Active', + 'detail.goal.disarmed': 'Awaiting continuation', + 'detail.goal.paused': 'Paused', + 'detail.goal.blocked': 'Blocked', + 'detail.goal.complete': 'Completed', + 'detail.goal.rounds': 'Rounds', + 'detail.goal.reason': 'Blocker', + 'detail.days': '{count} d', + 'detail.hours': '{count} h', + 'detail.minutes': '{count} min', + 'detail.seconds': '{count} s', + 'detail.schedule.once': 'Once', + 'detail.schedule.every': 'Every {interval}', + 'detail.schedule.when': 'Scheduled for', + 'detail.schedule.frequency': 'Repeat', + 'detail.schedule.scheduled': 'Scheduled', + 'detail.schedule.overdue': 'Overdue, awaiting session resume', + 'detail.schedule.empty': 'No reminders', + 'detail.schedule.deleted': 'Deleted', + 'detail.schedule.count': '{count} reminders', + 'tool.title.inspectProviders': 'Inspect providers', + 'tool.title.queryRuntime': 'Query runtime', + 'tool.title.inspectPlugins': 'Inspect plugins', + 'tool.title.workflow': 'Run workflow', + 'tool.title.ralph': 'Run Ralph', + 'tool.title.readEvent': 'Read event', + 'tool.title.searchEvents': 'Search events', + 'tool.title.traceEvent': 'Trace event', + 'tool.title.searchSessions': 'Search sessions', + 'tool.title.traceSession': 'Trace session', + 'tool.title.listModels': 'List models', + 'tool.title.subagent': 'Delegate task', + 'tool.title.listAgents': 'List agents', + 'tool.title.sendMessage': 'Send message', + 'tool.title.interruptAgent': 'Interrupt agent', + 'tool.title.listJobs': 'List background jobs', + 'tool.title.readJob': 'Read job output', + 'tool.title.killJob': 'Cancel background job', + 'tool.title.openTerminal': 'Open terminal', + 'tool.title.readTerminal': 'Read terminal', + 'tool.title.listTerminals': 'List terminals', + 'tool.title.signalTerminal': 'Signal terminal', + 'tool.title.closeTerminal': 'Close terminal', + 'tool.title.lsp': 'Query code symbols', + 'tool.title.findDefinition': 'Find definition', + 'tool.title.findReferences': 'Find references', + 'tool.title.findImplementation': 'Find implementation', + 'tool.title.hoverSymbol': 'Inspect symbol', + 'tool.title.spawnTeammate': 'Create teammate', + 'tool.title.createTeamTask': 'Create team task', + 'tool.title.getTeamTask': 'Read team task', + 'tool.title.updateTeamTask': 'Update team task', + 'tool.title.listTeamTasks': 'List team tasks', + 'tool.title.waitAgent': 'Wait for teammates', + 'detail.recordedResult': 'Recorded result', + 'detail.empty': 'No results', + 'detail.none': 'None', + 'detail.yes': 'Yes', + 'detail.no': 'No', + 'detail.moreInInspect': '{count} more items available in Inspect', + 'detail.status.running': 'Running', + 'detail.status.idle': 'Idle', + 'detail.status.ready': 'Ready', + 'detail.status.inactive': 'Inactive', + 'detail.status.provisioning': 'Provisioning', + 'detail.status.failed': 'Failed', + 'detail.status.completed': 'Completed', + 'detail.status.deleted': 'Deleted', + 'detail.status.killed': 'Cancelled', + 'detail.status.accepted': 'Accepted', + 'detail.status.queued': 'Queued', + 'detail.status.exited': 'Exited', + 'detail.field.id': 'ID', + 'detail.field.revision': 'Revision', + 'detail.field.platform': 'Platform', + 'detail.field.provider': 'Provider', + 'detail.field.model': 'Model', + 'detail.field.role': 'Role', + 'detail.field.context': 'Context', + 'detail.field.owner': 'Owner', + 'detail.field.ready': 'Ready', + 'detail.field.dependencies': 'Dependencies', + 'detail.field.writeScopes': 'Write scopes', + 'detail.field.warnings': 'Warnings', + 'detail.field.diagnostics': 'Diagnostics', + 'detail.field.methods': 'Methods', + 'detail.field.inputSchema': 'Input schema', + 'detail.field.outputSchema': 'Output schema', + 'detail.field.currentPackage': 'Current package', + 'detail.field.nextPackage': 'Next package', + 'detail.field.latestRun': 'Latest run', + 'detail.field.packages': 'Packages', + 'detail.field.registrations': 'Registrations', + 'detail.field.props': 'Props', + 'detail.field.data': 'Data', + 'detail.field.source': 'Source', + 'detail.field.content': 'Content', + 'detail.field.message': 'Message', + 'detail.field.messageId': 'Message ID', + 'detail.field.root': 'Root', + 'detail.field.pid': 'Process ID', + 'detail.field.type': 'Type', + 'detail.field.time': 'Time', + 'detail.field.seq': 'Event sequence', + 'detail.field.turn': 'Turn', + 'detail.field.step': 'Step', + 'detail.field.callId': 'Call ID', + 'detail.field.agents': 'Agents started', + 'detail.field.result': 'Result', + 'detail.field.parent': 'Parent', + 'detail.field.depth': 'Depth', + 'detail.field.exitCode': 'Exit code', + 'detail.field.signal': 'Signal', + 'detail.field.previousStatus': 'Previous status', + 'detail.field.agent': 'Agent ID', + 'detail.field.job': 'Job ID', + 'detail.field.task': 'Task', + 'detail.field.processGroup': 'Process group', + 'detail.field.availability': 'Availability', + 'detail.field.bestMatch': 'Best match', + 'detail.field.target': 'Target event', + 'detail.field.surface': 'Record status', + 'detail.agents.count': '{count} agents', + 'detail.jobs.count': '{count} background jobs', + 'detail.terminals.count': '{count} terminals', + 'detail.tasks.count': '{count} team tasks', + 'detail.tasks.nextPage': 'More tasks available; next cursor is {cursor}', + 'detail.locations.count': '{count} locations', + 'detail.location': 'Line {line}, column {column}', + 'detail.receipt.delivered': 'Message delivered', + 'detail.receipt.interrupt': 'Interrupt requested', + 'detail.receipt.started': 'Started', + 'detail.receipt.cancel': 'Cancellation requested', + 'detail.receipt.alreadyFinished': 'Already finished', + 'detail.receipt.signal': 'Signal delivered', + 'detail.receipt.closed': 'Closed', + 'detail.receipt.closing': 'Closing', + 'detail.wait.noProgress': 'No active teammates', + 'detail.wait.title': 'Teammate activity', + 'detail.wait.timeout': 'Wait timed out', + 'detail.wait.changed': 'Change detected', + 'detail.agent.reply': 'Agent response', + 'detail.models.title': 'Available models', + 'detail.output.lines': 'Lines {begin}–{end} of {total}', + 'detail.output.truncated': 'Output truncated', + 'detail.providers.count': '{count} inspect providers', + 'detail.plugins.count': '{count} dynamic plugins', + 'detail.workflow.script': 'Workflow script', + 'detail.ralph.reportedComplete': 'Worker reported completion', + 'detail.ralph.reportedBlocker': 'Worker reported a blocker', + 'detail.ralph.limit': 'Round limit reached', + 'detail.report.nextSteps': 'Remaining work', + 'detail.trace.replacedBy': 'Replaced by', + 'detail.trace.replacementChain': 'Replacement chain', + 'detail.trace.replaces': 'Replaced events', + 'detail.trace.sources': 'Source events', + 'detail.trace.derived': 'Derived events', + 'detail.trace.ancestors': 'Ancestor sessions', + 'detail.trace.descendants': 'Descendant sessions', + 'detail.matches.count': '{count} matches', + 'detail.matches.capped': 'Result limit reached; narrow the search for more', + 'detail.event.neighbors': 'Surrounding events', 'todo.completed': '{done}/{total} completed', 'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', 'ask.rowTitle': 'Ask question', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ad0a426ba6..af11843422 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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-tool/README.md -README.md: b8dd2a5e3c6743e496d585a2de238714f00e9f6b -README.zh.md: 0f777981ff6ccc6945e516d3c9853a236cde82d7 +README.md: 011935cefc41542917eeb434e2bfed5ce00b0a44 +README.zh.md: a8270ed214b209162faabb3d9f16da1e7281d139 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index b8dd2a5e3c..011935cefc 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -64,6 +64,8 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules. +Goal and schedule tools use compact fields and lists; `todo_write` uses a static checklist. These expanded bodies read successful logged results, preserve generic input/output for failures or unsupported data, and keep Inspect available. Dates include the viewer's time zone, and statuses reflect the call result rather than current session state. [Compact tool details](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md) records the scope and presentation trade-offs. + The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 0f777981ff..a8270ed214 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -64,6 +64,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` 每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。 +目标与定时任务工具使用紧凑的字段和列表;`todo_write` 使用静态任务清单。展开内容读取成功的记录结果,为失败或不支持的数据保留通用输入/输出,并保留 Inspect。日期包含查看者的时区,状态反映调用结果而非当前会话状态。[紧凑工具详情](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md)记录了范围与呈现取舍。 + terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。 diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 28bc6e51fe..e6a866d1b2 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -14,6 +14,7 @@ import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx' import { readToolview } from './tool/toolviews/read-row.tsx' import { readImageToolview } from './tool/toolviews/read-image-row.tsx' import { searchToolview } from './tool/toolviews/search-row.tsx' +import { detailsToolview } from './tool/toolviews/details-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' @@ -47,5 +48,6 @@ export function apply(ctx: ClientContext): void { ctx.plugin(searchToolview) ctx.plugin(webToolview) ctx.plugin(todoToolview) + ctx.plugin(detailsToolview) ctx.plugin(askQuestionToolview) } diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css new file mode 100644 index 0000000000..e600b8a36c --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -0,0 +1,257 @@ +.root { + margin: 4px 0 4px 4px; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: 12px; + background: var(--dsw-alias-markdown-code-block); + color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); + overflow: hidden; +} + +.list { + list-style: none; + margin: 0; + padding: 0; + max-height: 320px; + overflow-y: auto; +} + +.item, +.empty { + margin: 0; + padding: 10px 14px; +} + +.item + .item { + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.root[data-inspect]:not([data-caption]) > .list > .item:first-child, +.root[data-inspect]:not([data-caption]) .empty, +.root[data-inspect] .caption { + padding-inline-end: 88px; +} + +.caption { + padding: 10px 14px 6px; + color: var(--dsw-alias-label-caption); + font-size: 12px; +} + +.heading { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.text { + flex: 1; + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.status { + flex: none; + align-self: flex-start; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 18px; + font-size: 16px; + line-height: 18px; +} + +.pending { + box-sizing: border-box; + width: 10px; + height: 10px; + border: 1px solid var(--dsw-alias-label-tertiary); + border-radius: 2px; +} + +.heading:has(.statusText) .text { + flex-basis: 12em; +} + +.statusText { + margin-inline-start: auto; + color: var(--dsw-alias-label-caption); + font-size: 12px; + white-space: nowrap; +} + +.previous, +.unchanged { + color: var(--dsw-alias-label-tertiary); +} + +.item[data-change='added'] .status { + color: var(--dsw-alias-state-success-primary); +} + +.item[data-change='removed'] .status { + color: var(--dsw-alias-state-error-primary); +} + +.item[data-change='removed'] .text { + color: var(--dsw-alias-label-tertiary); + text-decoration: line-through; +} + +.unchanged { + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.unchanged summary { + display: flex; + align-items: center; + gap: 6px; + padding: 9px 14px; + list-style: none; + cursor: pointer; +} + +.unchanged summary::-webkit-details-marker { + display: none; +} + +.unchanged[open] summary svg { + transform: rotate(90deg); +} + +.fields { + margin: 0; +} + +.heading + .fields { + margin-top: 6px; +} + +.field { + display: grid; + grid-template-columns: 5.5em minmax(0, 1fr); + gap: 12px; +} + +.field + .field { + margin-top: 4px; +} + +.field dt { + color: var(--dsw-alias-label-caption); +} + +.field dd { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.badge { + flex: none; + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--dsw-alias-label-caption); + font-size: 12px; +} + +.badge::before { + content: ''; + width: 5px; + height: 5px; + border-radius: 50%; + corner-shape: round; + background: currentColor; +} + +.badge[data-tone='success'] { color: var(--dsw-alias-state-success-primary); } +.badge[data-tone='warning'] { color: var(--dsw-alias-state-warn-primary); } +.badge[data-tone='error'] { color: var(--dsw-alias-state-error-primary); } + +.subtitle { + margin-top: 3px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + overflow-wrap: anywhere; +} + +.description { + margin: 6px 0; + color: var(--dsw-alias-label-caption); + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lines { + margin: 6px 0 0; + padding-inline-start: 18px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.lines li + li { margin-top: 4px; } + +.group { + margin-top: 8px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.group > summary { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 0 0; + color: var(--dsw-alias-label-caption); + list-style: none; + cursor: pointer; + overflow-wrap: anywhere; +} + +.group > summary::-webkit-details-marker { display: none; } +.group[open] > summary svg { transform: rotate(90deg); } +.group > summary svg { flex: none; } +.group .list { max-height: none; overflow: visible; } +.group .item { padding: 9px 0 2px 20px; } + +.prose, +.code { + max-width: 100%; + font-size: 13px; +} + +.prose { margin-top: 8px; } + +.root .item > .code { + margin: 8px 0 0; + max-height: 240px; + overflow: auto; +} + +.root .group .item:has(> .code:only-child) { + padding: 4px 0 0 20px; +} + +.root .group .item > .code:only-child { margin: 0; } + +.root .code [data-code-block-banner] { padding: 4px 10px; } +.root .code pre { padding: 6px 10px 8px; } + +.path { + flex: 1; + min-width: 0; + padding: 0; + border: none; + background: none; + color: inherit; + font: inherit; + text-align: start; + overflow-wrap: anywhere; + cursor: pointer; +} + +.path:hover { text-decoration: underline; } +.path:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 3px; } diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx new file mode 100644 index 0000000000..3612d85a98 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx @@ -0,0 +1,134 @@ +/** Compact, read-only fields and lists for recorded Tool results. */ +import { CodeBlock, MarkdownText, IconCheckOutline14, IconChevronRightOutline14, IconPlayOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { OpenFileOptions } from '@deepseek-ai/dsh-client-ui-chat/client' +import { markdownLabels } from '../models/primitive-labels.ts' +import css from './ToolDetails.module.css' + +/** One recorded entity, receipt, or collapsible group of related values. */ +export interface ToolDetailItem { + title?: string + subtitle?: string + description?: string + badge?: { label: string; tone: 'neutral' | 'success' | 'warning' | 'error' } + status?: { value: 'completed' | 'in_progress' | 'pending'; label: string } + previousStatus?: string + change?: { value: 'added' | 'removed' | 'updated'; label: string } + fields: readonly { label: string; value: string }[] + lines?: readonly string[] + markdown?: string + code?: { text: string; language?: string } + location?: { path: string; line?: number } + groups?: readonly { label: string; items: readonly ToolDetailItem[] }[] +} + +/** Localized display data shared by the built-in detail cards. */ +export interface ToolDetailsModel { + items: readonly ToolDetailItem[] + summary?: string + empty?: string + caption?: string + unchanged?: { label: string; items: ToolDetailsModel['items'] } +} + +interface DetailContentProps { + t: TranslateNS<'conversation'> + onOpenFile?: ((path: string, options?: OpenFileOptions) => void) | undefined +} + +function DetailItem({ item, t, onOpenFile }: { item: ToolDetailItem } & DetailContentProps) { + const status = item.status + return ( +
  • + {item.title !== undefined && ( +
    + {status !== undefined && ( + + {item.change?.value === 'added' ? '+' + : item.change?.value === 'removed' ? '−' + : status.value === 'completed' ? + : status.value === 'in_progress' ? + : } + + )} + {item.location !== undefined && onOpenFile !== undefined ? ( + + ) : {item.title}} + {item.badge !== undefined && {item.badge.label}} + {status !== undefined && ( + + {item.previousStatus !== undefined && ( + <>{item.previousStatus} → + )} + {status.label} + {item.change?.value === 'updated' && item.previousStatus === undefined && · {item.change.label}} + + )} +
    + )} + {item.subtitle !== undefined &&
    {item.subtitle}
    } + {item.description !== undefined &&

    {item.description}

    } + {item.fields.length > 0 && ( +
    + {item.fields.map(field => ( +
    +
    {field.label}
    +
    {field.value}
    +
    + ))} +
    + )} + {item.lines !== undefined &&
      {item.lines.map((line, index) =>
    • {line}
    • )}
    } + {item.markdown !== undefined &&
    } + {item.code !== undefined && ( + + )} + {item.groups?.map((group, index) => ( +
    + {group.label} +
      + {group.items.map((child, childIndex) => )} +
    +
    + ))} +
  • + ) +} + +/** + * Render recorded values with local disclosures, copy controls, and file navigation. + * @param props.model - Localized fields or list items; an empty list uses its empty label. + * @param props.hasInspect - Reserve space for the row's upper-right Inspect button. + * @param props.t - Conversation dictionary for shared Markdown and code controls. + * @param props.onOpenFile - Open a recorded file location in the session workspace. + * @returns The expanded detail body. + */ +export function ToolDetails({ model, hasInspect = false, t, onOpenFile }: { + model: ToolDetailsModel + hasInspect?: boolean +} & DetailContentProps) { + return ( +
    + {model.caption !== undefined &&
    {model.caption}
    } + {model.items.length === 0 ?

    {model.empty}

    : ( +
      + {model.items.map((item, index) => )} +
    + )} + {model.unchanged !== undefined && ( +
    + {model.unchanged.label} +
      + {model.unchanged.items.map((item, index) => )} +
    +
    + )} +
    + ) +} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index f1933dc068..ef1a9f6bbf 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -191,6 +191,17 @@ opacity: 1; } +.detailsBodyWrap { + position: relative; +} + +.detailsBodyWrap .inspectButton { + position: absolute; + top: 12px; + right: 12px; + margin: 0; +} + /* Solid hover fill (a translucent token would let content bleed through). */ .inspectButton:hover { background: var(--dsw-alias-interactive-bg-hover-solid); diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index ca5becec3b..9a67b36ff6 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -23,6 +23,7 @@ import { } from '../models/tool-call-model.ts' import type { WebCardModelProps } from '../models/web-card-model.ts' import { AskQuestionCard } from './AskQuestionCard.tsx' +import { ToolDetails, type ToolDetailsModel } from './ToolDetails.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -70,6 +71,8 @@ export interface ToolRowProps { loadImage?: MessageImageLoader | undefined search?: SearchCardModel | null | undefined web?: WebCardModelProps | null | undefined + /** Read-only fields/list card derived from a successful recorded result. */ + details?: ToolDetailsModel | null | undefined state: ToolRowState /** * Filesystem path from tool args; when set with onOpenFile, the summary @@ -128,6 +131,7 @@ export function ToolRow({ loadImage, search, web, + details, state, filePath, filePathLine, @@ -151,8 +155,9 @@ export function ToolRow({ const searchBody = search ?? null const webBody = web ?? null const askQuestionBody = askQuestion ?? null + const detailsBody = details ?? null const outputText = output ?? null - const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody ?? detailsBody const expandable = bodyRaw != null || outputText !== null || card !== null const open = expanded && expandable const bodyText = useMemo( @@ -234,7 +239,7 @@ export function ToolRow({ )} > -
    +
    {askQuestionBody !== null ? : terminalBody !== null @@ -288,36 +293,38 @@ export function ToolRow({ ) : webBody !== null ? - : ( - <> - {variant === 'code' && bodyText !== null && ( -
    - -
    - )} - {(cardBody !== null || outputText !== null) && ( -
    - {cardBody !== null && ( -
    - {t('row.input')} - {cardBody} -
    - )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
    - {t('row.output')} - - {outputText} - -
    - )} -
    - )} - - )} + : detailsBody !== null + ? + : ( + <> + {variant === 'code' && bodyText !== null && ( +
    + +
    + )} + {(cardBody !== null || outputText !== null) && ( +
    + {cardBody !== null && ( +
    + {t('row.input')} + {cardBody} +
    + )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
    + {t('row.output')} + + {outputText} + +
    + )} +
    + )} + + )} {inspect !== undefined && ( + ) + } return renderSafeLink(normalizeUri(url), children, key, glyph) } @@ -608,7 +629,7 @@ function renderLinkReference( return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } const rendered = renderChildren(node.children, { ...context, inLink: true }) - return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children)) + return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.openFile) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx new file mode 100644 index 0000000000..f30d1ebf73 --- /dev/null +++ b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { MarkdownText } from './markdown-test-components.tsx' + +afterEach(cleanup) + +describe('Markdown file links', () => { + it.each([ + ['src/index.ts', 'src/index.ts', undefined], + ['/workspace/src/index.ts#L24', '/workspace/src/index.ts', { line: 24 }], + ['../docs/My%20Notes.md#L24-L30', '../docs/My Notes.md', { line: 24 }], + ['C:/work/file.ts#L2', 'C:/work/file.ts', { line: 2 }], + ['C:%5Cwork%5Cfile.ts', 'C:\\work\\file.ts', undefined], + ['docs/%E4%B8%AD%E6%96%87.md', 'docs/中文.md', undefined], + ['file%23name%3F.txt', 'file#name?.txt', undefined], + ])('opens %s through the preview callback', (target, path, options) => { + const openFile = vi.fn() + const view = render() + const link = view.getByRole('button', { name: 'source' }) + expect(link.getAttribute('title')).toBe(target) + fireEvent.click(link) + expect(openFile).toHaveBeenCalledWith(path, options) + expect(view.container.querySelector('a')).toBeNull() + }) + + it('handles reference links and code labels without nesting file-mention buttons', () => { + const openFile = vi.fn() + const resolve = vi.fn() + const view = render() + fireEvent.click(view.getByRole('button', { name: 'see index.ts' })) + expect(openFile).toHaveBeenCalledWith('src/index.ts', { line: 3 }) + expect(resolve).not.toHaveBeenCalled() + expect(view.container.querySelector('button button')).toBeNull() + }) + + it.each([ + '//example.com/file', '%2F%2Fexample.com/file', '%5C%5Cserver%5Cfile', + 'javascript:alert', 'data:text/plain,hi', 'file:///etc/passwd', 'vscode:open', + '%6Aavascript:alert', '#L2', 'file.ts?raw=1', 'file%ZZ.ts', 'file%00.ts', + 'file.ts#heading', 'file.ts#L0', 'file.ts#L3-L2', + 'file.ts#L9007199254740992', 'file.ts#L1-L9007199254740992', + ])('keeps unsupported destination %s inert', (target) => { + const openFile = vi.fn() + const view = render() + expect(view.getByText('source')).toBeTruthy() + expect(view.container.querySelector('button, a')).toBeNull() + expect(openFile).not.toHaveBeenCalled() + }) + + it('preserves external links and leaves local links inert without an opener', () => { + const view = render() + expect(view.getByRole('link', { name: 'web' }).getAttribute('target')).toBe('_blank') + expect(view.getByRole('link', { name: 'mail' }).getAttribute('href')).toBe('mailto:a@example.com') + expect(view.queryByRole('button')).toBeNull() + }) + + it('enables links after streaming and uses a replaced opener', () => { + const first = vi.fn() + const second = vi.fn() + const text = '[source](src/a.ts)\n\nmore\n\n' + const view = render() + expect(view.queryByRole('button')).toBeNull() + view.rerender() + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(first).toHaveBeenCalledOnce() + view.rerender() + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(second).toHaveBeenCalledOnce() + expect(first).toHaveBeenCalledOnce() + }) +}) diff --git a/snapshots/web/markdown-file-links/links.expected.md b/snapshots/web/markdown-file-links/links.expected.md new file mode 100644 index 0000000000..6e13842563 --- /dev/null +++ b/snapshots/web/markdown-file-links/links.expected.md @@ -0,0 +1,11 @@ +- paragraph: + - button "Source at line 24" + - text: · + - button "Absolute at line 30" + - text: · + - button "Other example" + - text: · + - button "Missing file" + - text: · + - link "Website": + - /url: https://example.com/ diff --git a/snapshots/web/markdown-file-links/preview.expected.md b/snapshots/web/markdown-file-links/preview.expected.md new file mode 100644 index 0000000000..ea54abc808 --- /dev/null +++ b/snapshots/web/markdown-file-links/preview.expected.md @@ -0,0 +1,6 @@ +- text: {{cwd}}/other/example.txt +- button "Line wrap" [pressed]: + - img +- button "Read the file again": + - img +- text: other file diff --git a/snapshots/web/markdown-file-links/session.v3.jsonl b/snapshots/web/markdown-file-links/session.v3.jsonl new file mode 100644 index 0000000000..c9d85c864f --- /dev/null +++ b/snapshots/web/markdown-file-links/session.v3.jsonl @@ -0,0 +1,17 @@ +{"version":3,"isSeeded":false,"createdAt":0,"cwd":"{{cwd}}","delegationDepth":0,"type":"session","id":"{{session:1}}"} +{"type":"turn/start","data":{"turn":1}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt"},"id":"{{message:1}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpc:1}}"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} +{"type":"session/title","data":{"title":"Use the read tool twice","messageSeqs":[3],"source":{"kind":"fallback"}}} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/message","data":{"turn":1,"step":1,"usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27},"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."},{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:3}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":0,"index":0,"dt":[125,30,0,1,0,0,30,1,0,0,0,0,30,1,0,0,30,0,0,0,0,1,30,1,0,0],"texts":["The"," user"," wants"," me"," to"," read"," a",".txt"," and"," b",".txt",","," then"," reply"," with"," \"","D","ONE","\"."," Let"," me"," do"," both"," reads"," in"," parallel","."]},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":1,"dt":[30,0,0,0,0,0,29,1,0,29,1],"id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","args":["","{","\"","file","_path","\"",": ","\"","a",".txt","\"","}"]},{"type":"chunk","time":0,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":2,"dt":[27,0,0,0,1,31,0,1,0,26,1],"id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","args":["","{","\"","file","_path","\"",": ","\"","b",".txt","\"","}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with \"DONE\". Let me do both reads in parallel."}}},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":103,"cacheReadTokens":7680,"reasoningTokens":27}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_OsndvlcKnCcUmae7QXal8633","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_00_OsndvlcKnCcUmae7QXal8633","content":[{"type":"text","text":"{{cwd}}/workspace/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_00_OsndvlcKnCcUmae7QXal8633"},"id":"{{message:4}}"}},"sourceEventSeqs":[7],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"id":"{{message:5}}"}},"sourceEventSeqs":[7],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"text","text":"[Source at line 24](src/example.txt#L24-L30) · [Absolute at line 30]({{cwd}}/src/example.txt#L30) · [Other example](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[Source at line 24](src/example.txt#L24-L30) · [Absolute at line 30]({{cwd}}/src/example.txt#L30) · [Other example](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[Source at line 24](src/example.txt#L24-L30) · [Absolute at line 30]({{cwd}}/src/example.txt#L30) · [Other example](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/markdown-file-links/snapshot.yml b/snapshots/web/markdown-file-links/snapshot.yml new file mode 100644 index 0000000000..2d89037127 --- /dev/null +++ b/snapshots/web/markdown-file-links/snapshot.yml @@ -0,0 +1,7 @@ +version: 1 +scenario: markdown-file-links +profile: web +composition: web-default +recording: authored +header: + class: web-default From 7bc0154bfc898634bdda97a04c9adcaafca7ed08 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 15 Sep 2026 16:22:03 +0800 Subject: [PATCH 009/225] test(web): verify keyboard file navigation and capture preview --- apps/web/tests/markdown-file-links.e2e.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts index aa62584745..a83a158ed6 100644 --- a/apps/web/tests/markdown-file-links.e2e.ts +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -34,6 +34,7 @@ describe('web e2e: Markdown file links', () => { await page.locator('[role="treeitem"]').first().click() await page.locator('[role="treeitem"]').nth(1).click() await page.getByRole('button', { name: 'Source at line 24', exact: true }).waitFor() + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() }, 120_000) afterAll(async () => { @@ -52,12 +53,17 @@ describe('web e2e: Markdown file links', () => { await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n') await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) .toBe(join(scaffold.workspaceCwd, 'src/example.txt')) - await page.getByRole('button', { name: 'Absolute at line 30', exact: true }).click() + const absolute = page.getByRole('button', { name: 'Absolute at line 30', exact: true }) + await absolute.focus() + await absolute.press('Enter') await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('source line 30\n') expect(await column.locator('[data-dockkit-tab-title]').allTextContents()).toEqual(['example.txt']) expect(page.url()).toBe(beforeUrl) expect(page.context().pages()).toHaveLength(1) expect(await page.getByRole('link', { name: 'Website', exact: true }).getAttribute('target')).toBe('_blank') + const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) + await mkdir(artifacts, { recursive: true }) + await page.screenshot({ path: join(artifacts, 'markdown-file-links.png') }) await page.getByRole('button', { name: 'Other example', exact: true }).click() await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) .toBe(join(scaffold.workspaceCwd, 'other/example.txt')) From 9e611c90a31c35d3c7ea14a572417d425b67725e Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 15 Sep 2026 16:22:40 +0800 Subject: [PATCH 010/225] test(web): finish sidebar transitions before preview capture --- apps/web/tests/markdown-file-links.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts index a83a158ed6..734f69de0b 100644 --- a/apps/web/tests/markdown-file-links.e2e.ts +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -63,7 +63,7 @@ describe('web e2e: Markdown file links', () => { expect(await page.getByRole('link', { name: 'Website', exact: true }).getAttribute('target')).toBe('_blank') const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) await mkdir(artifacts, { recursive: true }) - await page.screenshot({ path: join(artifacts, 'markdown-file-links.png') }) + await page.screenshot({ path: join(artifacts, 'markdown-file-links.png'), animations: 'disabled' }) await page.getByRole('button', { name: 'Other example', exact: true }).click() await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) .toBe(join(scaffold.workspaceCwd, 'other/example.txt')) From 25525dc49547ff136d274d2d32681e4b9d36cd73 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 15 Sep 2026 16:25:49 +0800 Subject: [PATCH 011/225] test(web): register file-link scenario in host compilation --- apps/web/tsconfig.json | 1 + tsconfig.host.json | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index fe609be8c9..2d0e0ae5b0 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -82,6 +82,7 @@ "tests/open-in-app-ssh.e2e.ts", "tests/message-feedback.e2e.ts", "tests/markdown-images.e2e.ts", + "tests/markdown-file-links.e2e.ts", "tests/reference-composer.e2e.ts", "tests/markdown-wide-table.e2e.ts", "tests/math-rendering.e2e.ts", diff --git a/tsconfig.host.json b/tsconfig.host.json index e5841338d1..de4bfa6788 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -72,6 +72,7 @@ "apps/web/tests/open-in-app-ssh.e2e.ts", "apps/web/tests/message-feedback.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", + "apps/web/tests/markdown-file-links.e2e.ts", "apps/web/tests/reference-composer.e2e.ts", "apps/web/tests/markdown-wide-table.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", From cc872a39b9ac7793a918a5ce85d9a66570e9c5b9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 15 Sep 2026 16:39:42 +0800 Subject: [PATCH 012/225] test(subprocess): verify owned exit listeners on disposal --- ...26-08-28-subprocess-native-containment.i18n.yaml | 4 ++-- .../2026-08-28-subprocess-native-containment.md | 2 ++ .../2026-08-28-subprocess-native-containment.zh.md | 2 ++ .../tests/fixtures/process-exit-host.ts | 13 ++++++++----- .../subprocess-local/tests/process-exit.spec.ts | 13 ++++++++----- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml index bb8c3f29df..d67dd0c16f 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md -2026-08-28-subprocess-native-containment.md: 1b0fd162be77356001bcd9224bb2ee2889e6d6c0 -2026-08-28-subprocess-native-containment.zh.md: f33643f98a6ea6a90c0780432cbe11a35a868562 +2026-08-28-subprocess-native-containment.md: 64fd59e13ea38d7f6e066d5dadec4be830c9c0af +2026-08-28-subprocess-native-containment.zh.md: 59e51c5a5c19f55058eb3881928d4f218a22a59c diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md index 1b0fd162be..64fd59e13e 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.md @@ -54,6 +54,8 @@ This note owns the current native-containment mechanism. It partially updates th ## Verification +The host-exit disposal fixture compares the provider's listener identities before and after disposal and verifies that an independently registered exit listener remains installed. Total process listener counts cannot establish provider cleanup because other process-lifetime handlers may register after provider construction. + - Provider and Linux protocol suites pin synchronous NUL rejection before launch side effects, strict request/error decoding, target cwd and complete environment restoration, private-variable collision, symlink-sensitive PATH traversal with preserved argv, close-on-exec removal for inherited stdio, pre-exec error ownership, failed-deep-probe retry plus successful-deep-probe caching with per-call manager checks, the three scope-establishment states including requested versus unexpected exits with an unconsumed request, `LoadState`/`ActiveState`/`TasksCurrent` parsing, releasing a leftover scope left active with no processes beside the live-client, unterminated, and unset-count cases, `reloading`, terminate wake-up with losing-delay cancellation, bounded established-scope backoff, and exactly-once PTY managed-owner cleanup. - Windows protocol and Win32 suites pin exactly two result branches, numeric-only target exits, ordinary-error start cancellation with raw parent-local reasons, the reduced `name`/`message`/`code`/`syscall`/`path` error record, the fixed `2`/`3`/`267` to `ENOENT`, `740` to `EACCES`, `5` to `EPERM`, `193` to `EFTYPE`, and remaining-code to `UNKNOWN` mapping, start delivery after runner spawn, empty-range settlement after pre-spawn failure, explicit ordinally sorted target environment blocks with `=C:` preservation and double-NUL termination, `uv_get_osfhandle()` carrier mapping and unsigned invalid-sentinel rejection, the null-device ignored-stdin carrier and piped non-ignored stdin, result-send and IPC-disconnect failures, direct-result latching before stdio settlement, active-process quiescence, and unique handle cleanup. - A keyless [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session snapshot pins the model-facing timeout result. A Linux user-systemd fixture holds the launch request unconsumed at an input barrier and verifies cancellation plus range settlement. diff --git a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md index f33643f98a..59e51c5a5c 100644 --- a/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-28-subprocess-native-containment.zh.md @@ -54,6 +54,8 @@ selector 是 per-spawn locator 或 sentinel,不是凭据或持久格式。Linu ## Verification +宿主退出清理 fixture 对比 provider 在 disposal 前后的监听器身份,并验证独立注册的退出监听器仍然存在。进程监听器总数无法证明 provider 清理完成,因为其他进程生命周期处理器可能在 provider 构造之后注册。 + - provider 与 Linux 协议测试套件固定同步 NUL 拒绝发生在启动副作用之前、严格 request/error 解码、target cwd 与完整环境恢复、私有变量碰撞、保留 argv 且对 symlink 敏感的 PATH 遍历、为继承 stdio 清除 close-on-exec、pre-exec error ownership、失败深度 probe 重试与成功深度 probe 缓存及逐调用 manager 检查、三种 scope 建立状态(包括 request 未消费时的请求终止与意外退出)、`LoadState`/`ActiveState`/`TasksCurrent` 解析、释放被留在 active 且没有任何进程的遗留 scope(连同 client 仍存活、未请求终止与进程数未上报三种情形)、`reloading`、带未胜出 delay 取消的 terminate wake-up、建立后有上限的退避,以及 PTY managed-owner 恰好一次 cleanup。 - Windows 协议与 Win32 测试套件固定恰好两个 result 分支、只含数字的 target exit、使用普通 error 的 start cancellation 与 parent 原样保留的本地 reason、缩减到 `name`/`message`/`code`/`syscall`/`path` 的 error record、固定的 `2`/`3`/`267` 到 `ENOENT`、`740` 到 `EACCES`、`5` 到 `EPERM`、`193` 到 `EFTYPE` 及其余 code 到 `UNKNOWN` 的映射、runner spawn 后才发送 start、spawn 前 failure 的 empty-range settlement、按序数显式排序的 target 环境块及 `=C:` 保留和双 NUL 结尾、`uv_get_osfhandle()` carrier 映射与 unsigned invalid sentinel 拒绝、null-device ignored-stdin carrier 与非 ignore stdin pipe、result-send 与 IPC-disconnect failure、stdio settlement 前的 direct-result 锁存、active-process 完全停稳,以及唯一 handle cleanup。 - 无需密钥的 [`bash-startup-timeout`](../../../../snapshots/session/bash-startup-timeout/snapshot.yml) Session 快照固定模型可见的超时结果。Linux user-systemd fixture 通过输入屏障保持启动请求未消费,并验证取消与 range settlement。 diff --git a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts index 080b503152..febf68edc4 100644 --- a/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts +++ b/packages/subprocess/subprocess-local/tests/fixtures/process-exit-host.ts @@ -27,10 +27,13 @@ async function waitForFile(path: string): Promise { } } -const listenersBefore = process.listenerCount('exit') +const listenersBefore = new Set(process.listeners('exit')) const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessRuntime) -const listenersAfterLoad = process.listenerCount('exit') +const ownedListeners = process.listeners('exit').filter(listener => !listenersBefore.has(listener)) +// Independent process-lifetime listeners may be installed after this provider. +const unrelatedListener = (): void => {} +if (trigger === 'dispose') process.once('exit', unrelatedListener) if (kind === 'ordinary') { ctx.subprocess.spawn({ argv: [process.execPath, managedTree, treeState], @@ -63,9 +66,9 @@ await waitForFile(proceed) if (trigger === 'dispose') { await fiber.dispose() await writeFile(join(root, 'dispose.json'), JSON.stringify({ - listenersBefore, - listenersAfterLoad, - listenersAfterDispose: process.listenerCount('exit'), + ownedListenersAfterLoad: ownedListeners.length, + ownedListenersAfterDispose: ownedListeners.filter(listener => process.listeners('exit').includes(listener)).length, + unrelatedListenerPreserved: process.listeners('exit').includes(unrelatedListener), })) } else if (trigger === 'direct') { process.exit(23) diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index 107d032da5..2c15ee4d6c 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -118,9 +118,9 @@ async function runScenario(kind: ManagedKind, trigger: ExitTrigger) { treeGone = true const disposeCounts = trigger === 'dispose' ? JSON.parse(await readFile(join(root, 'dispose.json'), 'utf8')) as { - listenersBefore: number - listenersAfterLoad: number - listenersAfterDispose: number + ownedListenersAfterLoad: number + ownedListenersAfterDispose: number + unrelatedListenerPreserved: boolean } : undefined return { outcome, disposeCounts } @@ -166,7 +166,10 @@ describe('synchronous cleanup on host exit', () => { it('preserves normal terminate-and-join disposal and removes the exit listener', { timeout: testTimeoutMs }, async () => { const { outcome, disposeCounts } = await runScenario('ordinary', 'dispose') expect(outcome.exitCode).toBe(0) - expect(disposeCounts?.listenersAfterLoad).toBe((disposeCounts?.listenersBefore ?? 0) + 1) - expect(disposeCounts?.listenersAfterDispose).toBe(disposeCounts?.listenersBefore) + expect(disposeCounts).toEqual({ + ownedListenersAfterLoad: 1, + ownedListenersAfterDispose: 0, + unrelatedListenerPreserved: true, + }) }) }) From 7aca91667923b40f1dce6abc594eb086e1b1e6db Mon Sep 17 00:00:00 2001 From: gengruilin Date: Tue, 15 Sep 2026 19:30:58 +0800 Subject: [PATCH 013/225] fix(docs): keep platform commands readable in MPA builds --- ...09-15-docs-platform-command-tabs.i18n.yaml | 4 +- .../2026-09-15-docs-platform-command-tabs.md | 4 +- ...026-09-15-docs-platform-command-tabs.zh.md | 4 +- docs/user/guide/python-sdk.i18n.yaml | 4 +- docs/user/guide/python-sdk.md | 20 ++++++-- docs/user/guide/python-sdk.zh.md | 20 ++++++-- website/.vitepress/code-groups.ts | 14 +++++- website/.vitepress/config.ts | 3 +- website/AGENTS.md | 2 +- website/tests/code-groups.spec.ts | 46 +++++++++++++++++-- 10 files changed, 97 insertions(+), 24 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml index 5f69a9a77f..f6c0f88653 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md -2026-09-15-docs-platform-command-tabs.md: f21fef8b7091de296ee2aab329fdc7d6362e8c14 -2026-09-15-docs-platform-command-tabs.zh.md: 2b309a4587df3fc4b727baf6aa546f07e2a51311 +2026-09-15-docs-platform-command-tabs.md: 4d44d1191a7ffc1601e15aa4d9f119a90e06b0dd +2026-09-15-docs-platform-command-tabs.zh.md: 450765b52d1507fa9b9e6a20a1854e6861655838 diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md index f21fef8b70..4d44d1191a 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md @@ -14,7 +14,7 @@ Each of the four platform-dependent steps uses VitePress's native `code-group`: Both languages retain every command in canonical Markdown. The documentation projector preserves the groups and their code in the website's raw Markdown. Explicit anchors before each group preserve the former platform heading targets; following a platform anchor locates the group without selecting a tab. -The theme adds a visible keyboard focus outline to native radio labels and displays both command blocks in search excerpts, whose tab controls are unavailable. A Markdown renderer adapter makes each native tab strip a form with submission prevented. Local search copies a section's rendered HTML, including radio names; separate form owners stop those copies from clearing the page's selection. VitePress owns selection, syntax highlighting, and copying the active code block. There is no shared selection state or stored preference. +The theme adds a visible keyboard focus outline to native radio labels and displays both command blocks in search excerpts, whose tab controls are unavailable. A Markdown renderer adapter makes each native tab strip a form with submission prevented. Local search copies a section's rendered HTML, including radio names; separate form owners stop those copies from clearing the page's selection. VitePress owns selection, syntax highlighting, and copying the active code block. MPA builds omit those client handlers, so build-only styles display both command blocks and hide the tab strip and copy buttons. There is no shared selection state or stored preference. ## Alternatives considered @@ -24,7 +24,7 @@ The theme adds a visible keyboard focus outline to native radio labels and displ ## Consequences -Readers see one command version per step while repository readers and raw-Markdown consumers retain both. Windows readers select PowerShell in each group. Existing platform links still reach the corresponding step, although the platform names leave the page outline. +SPA readers see one command version per step while MPA, repository, and raw-Markdown readers retain both. Windows readers select PowerShell in each group. Existing platform links still reach the corresponding step, although the platform names leave the page outline. Verification covers command preservation, bilingual pairing, projected and raw Markdown, and actual browser switching, copying, keyboard focus, search, themes, and narrow layouts. Framework-owned interaction is exercised on the running site; session replay is inapplicable to this documentation-only presentation. diff --git a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md index 2b309a4587..450765b52d 100644 --- a/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.zh.md @@ -14,7 +14,7 @@ Status: implemented 两种语言均在规范 Markdown 中保留每条命令。文档投影器在网站原始 Markdown 中保留分组及代码。各组前的显式锚点保留原有平台标题的链接目标;平台锚点定位到分组,但不选择标签。 -主题为原生单选按钮标签补充可见的键盘焦点轮廓,并在无法操作标签的搜索摘要中显示两个命令块。Markdown 渲染适配器将各原生标签栏设为禁止提交的表单。本地搜索会复制章节渲染后的 HTML,其中包含单选按钮名称;独立的所属表单可避免副本清除页面的选择。VitePress 负责选择、语法高亮和复制当前代码块。不引入共享选择状态或持久化偏好。 +主题为原生单选按钮标签补充可见的键盘焦点轮廓,并在无法操作标签的搜索摘要中显示两个命令块。Markdown 渲染适配器将各原生标签栏设为禁止提交的表单。本地搜索会复制章节渲染后的 HTML,其中包含单选按钮名称;独立的所属表单可避免副本清除页面的选择。VitePress 负责选择、语法高亮和复制当前代码块。MPA 构建不包含这些客户端处理器,因此构建专用样式显示两个命令块,并隐藏标签栏与复制按钮。不引入共享选择状态或持久化偏好。 ## 考虑过的替代方案 @@ -24,7 +24,7 @@ Status: implemented ## 影响 -读者每一步只看到一个命令版本,仓库读者和原始 Markdown 消费者仍能获取两个版本。Windows 读者需要在各组中选择 PowerShell。原有平台链接仍到达对应步骤,但平台名称不再出现在页面大纲中。 +SPA 读者每一步只看到一个命令版本,MPA、仓库和原始 Markdown 读者仍能获取两个版本。Windows 读者需要在各组中选择 PowerShell。原有平台链接仍到达对应步骤,但平台名称不再出现在页面大纲中。 验证覆盖命令保留、双语配对、投影和原始 Markdown,以及真实浏览器中的切换、复制、键盘焦点、搜索、主题和窄屏布局。框架负责的交互在运行中的站点验收;会话回放不适用于这项纯文档展示变更。 diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 98aa274cf8..2cafd271b0 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.i18n.yaml @@ -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/user/guide/python-sdk.md -python-sdk.md: 445003e980c92c5c727e51b41deb1b516681b20b -python-sdk.zh.md: b776781022dd3ef7af2331d169c006b9c9fa3381 +python-sdk.md: a14d656171f37a7feea8c091741f2175f9617cd4 +python-sdk.zh.md: 0846ed95ef3a396b468b0f50bcc176c64a963850 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index 445003e980..a14d656171 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -14,7 +14,10 @@ This tutorial installs the published Python SDK, runs the shipped standalone min ## Install the SDK - +
    + + +
    ::: code-group @@ -42,7 +45,10 @@ The installation includes a matching native runtime wheel and the `dsh` command. Export the credential and, when needed, a compatible proxy endpoint: - +
    + + +
    ::: code-group @@ -60,7 +66,10 @@ $env:DEEPSEEK_API_KEY = "sk-your-key-here" Run one task with explicit workspace and home paths: - +
    + + +
    ::: code-group @@ -115,7 +124,10 @@ The SDK starts the bundled `dsh --profile sdk-minimal` process lazily and reuses Use `dsh plugin` for dependencies and bundle layers that should persist in this home: - +
    + + +
    ::: code-group diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index b776781022..0846ed95ef 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -14,7 +14,10 @@ ## 安装 SDK - +
    + + +
    ::: code-group @@ -42,7 +45,10 @@ python -m pip install deepseek-harness-sdk 导出凭据;使用兼容代理时再设置 endpoint: - +
    + + +
    ::: code-group @@ -60,7 +66,10 @@ $env:DEEPSEEK_API_KEY = "sk-your-key-here" 使用显式 workspace 与 home 路径运行一个任务: - +
    + + +
    ::: code-group @@ -115,7 +124,10 @@ SDK 会延迟启动内置的 `dsh --profile sdk-minimal` 进程,并复用到 需要在该 home 中持久保存依赖与 bundle 层时,使用 `dsh plugin`: - +
    + + +
    ::: code-group diff --git a/website/.vitepress/code-groups.ts b/website/.vitepress/code-groups.ts index 00d7217ac4..4abf16e626 100644 --- a/website/.vitepress/code-groups.ts +++ b/website/.vitepress/code-groups.ts @@ -1,6 +1,18 @@ /** Isolates native code-group radios from copies rendered in local search. */ -import type { MarkdownRenderer } from 'vitepress' +import type { HeadConfig, MarkdownRenderer } from 'vitepress' + +/** + * Keeps all code-group commands readable when MPA output omits the theme's client handlers. + * @param mpa - Whether the resolved site uses VitePress's MPA build. + * @returns MPA-only styles that expose every block and hide inactive controls. + */ +export function codeGroupFallbackHead(mpa: boolean | undefined): HeadConfig[] { + return mpa ? [['style', {}, ` +.vp-code-group .tabs, .vp-code-group button.copy { display: none; } +.vp-code-group .blocks > div { display: block; } +`]] : [] +} /** * Gives each native tab strip its own form so search excerpts cannot clear the page's selection. diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index b77e1ac186..e39a9aa51e 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -5,7 +5,7 @@ import { resolve } from 'node:path' import type { DefaultTheme, PageData, SiteConfig } from 'vitepress' import type { ViteDevServer } from 'vite' import { withMermaid } from 'vitepress-plugin-mermaid' -import { isolateCodeGroupRadios } from './code-groups.ts' +import { codeGroupFallbackHead, isolateCodeGroupRadios } from './code-groups.ts' import { landingLink, localeCollections, orderedPages, routeLink, sectionSpec, type DocsLocale, type DocsPage, type DocsSidebar } from '../docs.ts' import { docsSourceFiles, emitRawMarkdownPages, llmsTxt, projectDocs, rawMarkdownRoute } from '../../scripts/project-doc-site.ts' @@ -296,6 +296,7 @@ export default withMermaid({ title: siteIdentity.title, description: siteIdentity.description, base, + transformHead: ({ siteConfig }) => codeGroupFallbackHead(siteConfig.mpa), /** Emit the raw-Markdown twin of every route plus llms.txt beside the rendered site. */ buildEnd(siteConfig: SiteConfig) { emitRawMarkdownPages(siteConfig.outDir) diff --git a/website/AGENTS.md b/website/AGENTS.md index 9e017f9cf0..b7f24a6a22 100644 --- a/website/AGENTS.md +++ b/website/AGENTS.md @@ -18,4 +18,4 @@ Run `pnpm docs:check` after changing this subtree; the gate rejects additional n The default-theme extension owns the Mermaid fullscreen viewer. Keep its enhancements separate from Markdown projection and preserve the original SVG. Route, language, theme, and rendered-SVG changes close the active view; theme disposal releases every observer, listener, and scroll lock. The [viewer decision](../.agents/notes/implemented/feature/2026-09-14-docs-mermaid-viewer.md) explains SVG isolation and verification. -Native code groups keep their radio controls in separate forms so copied search excerpts cannot clear the page's selected platform. Search excerpts display every command block because their tab controls are unavailable; the [platform-tab decision](../.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md) owns the interaction and verification. +Native code groups keep their radio controls in separate forms so copied search excerpts cannot clear the page's selected platform. Search excerpts and MPA builds display every command block because tab switching is unavailable; the [platform-tab decision](../.agents/notes/implemented/feature/2026-09-15-docs-platform-command-tabs.md) owns the interaction and verification. diff --git a/website/tests/code-groups.spec.ts b/website/tests/code-groups.spec.ts index 6ffc40677b..08b3fb2b93 100644 --- a/website/tests/code-groups.spec.ts +++ b/website/tests/code-groups.spec.ts @@ -1,21 +1,30 @@ /** Native code-group markup remains isolated when local search copies a section. */ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { JSDOM } from 'jsdom' import { createMarkdownRenderer } from 'vitepress' import { expect, it } from 'vitest' -import { isolateCodeGroupRadios } from '../.vitepress/code-groups.ts' +import { codeGroupFallbackHead, isolateCodeGroupRadios } from '../.vitepress/code-groups.ts' + +const source = '::: code-group\n\n```sh [Linux/macOS]\n. .venv/bin/activate\n```\n\n```powershell [Windows PowerShell]\n.venv\\Scripts\\Activate.ps1\n```\n\n:::\n' it('keeps the page selection when a search excerpt selects the same named radio', async () => { const md = await createMarkdownRenderer(import.meta.dirname, { config: isolateCodeGroupRadios }) - const html = md.render('::: code-group\n\n```sh [Linux/macOS]\n. .venv/bin/activate\n```\n\n```powershell [Windows PowerShell]\n.venv\\Scripts\\Activate.ps1\n```\n\n:::\n') - const dom = new JSDOM(`
    ${html}
    `) + const html = md.render(source) + const dom = new JSDOM(`
    ${html}
    `) try { const { document } = dom.window const pageRadios = document.querySelectorAll('main input') - const excerptRadio = document.querySelector('aside input') - assert(pageRadios[0] && pageRadios[1] && excerptRadio) + const excerpt = document.querySelector('aside') + assert(pageRadios[0] && pageRadios[1] && excerpt) pageRadios[1].checked = true + + // Search remounts the compiled page, reusing its generated radio names and IDs. + excerpt.innerHTML = html + expect(pageRadios[1].checked).toBe(true) + const excerptRadio = excerpt.querySelector('input') + assert(excerptRadio) excerptRadio.checked = true expect(pageRadios[0].checked).toBe(false) @@ -33,3 +42,30 @@ it('keeps the page selection when a search excerpt selects the same named radio' dom.window.close() } }) + +it('exposes both commands without client handlers in MPA output', async () => { + const md = await createMarkdownRenderer(import.meta.dirname, { config: isolateCodeGroupRadios }) + const nativeStyles = readFileSync(new URL('../node_modules/vitepress/dist/client/theme-default/styles/components/vp-code-group.css', import.meta.url), 'utf8') + const fallback = codeGroupFallbackHead(true) + const dom = new JSDOM(`${md.render(source)}`) + try { + const { document } = dom.window + const blocks = [...document.querySelectorAll('.blocks > div')] + expect(blocks.map(block => dom.window.getComputedStyle(block).display)).toEqual(['block', 'none']) + for (const [tag, , content] of fallback) { + const element = document.createElement(tag) + element.textContent = content ?? '' + document.head.append(element) + } + expect(blocks.map(block => dom.window.getComputedStyle(block).display)).toEqual(['block', 'block']) + const controls = [...document.querySelectorAll('.tabs, button.copy')] + expect(controls).toHaveLength(3) + expect(controls.every(control => dom.window.getComputedStyle(control).display === 'none')).toBe(true) + expect(blocks.map(block => block.querySelector('code')?.textContent)).toEqual([ + '. .venv/bin/activate', '.venv\\Scripts\\Activate.ps1', + ]) + expect(codeGroupFallbackHead(false)).toEqual([]) + } finally { + dom.window.close() + } +}) From fbc6abcfd42f6dc8d465218c750f97855119e750 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 16 Sep 2026 13:36:39 +0800 Subject: [PATCH 014/225] feat(web): guide file references with concise colon labels --- ...9-15-markdown-file-preview-links.i18n.yaml | 4 +-- .../2026-09-15-markdown-file-preview-links.md | 30 ++++++++++++++++--- ...26-09-15-markdown-file-preview-links.zh.md | 30 ++++++++++++++++--- apps/web/tests/markdown-file-links.e2e.ts | 8 ++--- .../client/ui-deliverables/README.i18n.yaml | 4 +-- packages/client/ui-deliverables/README.md | 8 ++--- packages/client/ui-deliverables/README.zh.md | 8 ++--- packages/client/ui-deliverables/src/index.ts | 12 ++++---- .../ui-deliverables/tests/prompt.host.spec.ts | 2 +- .../system-prompt.expected.md | 2 +- .../system-prompt.expected.md | 2 +- .../web/markdown-file-links/links.expected.md | 6 ++-- .../web/markdown-file-links/session.v3.jsonl | 2 +- .../web/ptc-round/system-prompt.expected.md | 2 +- 14 files changed, 82 insertions(+), 38 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml index b5b3940dd9..cfa6060a56 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md -2026-09-15-markdown-file-preview-links.md: 54174e250507c8cd51432099bf75ec4c576aded7 -2026-09-15-markdown-file-preview-links.zh.md: f29af6385891ad23801154e8e9a83a14fb4b032e +2026-09-15-markdown-file-preview-links.md: 07177c15d134b805042d93f48f8caae6bc165620 +2026-09-15-markdown-file-preview-links.zh.md: bc6b343c0091339e9be25d062a006905c6e9477f diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md index 54174e2505..07177c15d1 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -14,12 +14,34 @@ Settled Assistant Markdown passes explicit local link destinations to the Chat f The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert. +The Web file-reference prompt asks for a link on every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Names default to a basename or clear alias with minimal disambiguating parents. Precise labels use `filename:24` or `filename:24–30`, while destinations keep the parser's `#L24` or `#L24-L30` syntax. The renderer preserves model-authored text; it does not rewrite labels to enforce the prompt. + ## Alternatives considered -- Allow relative browser anchors: those navigate the application URL instead of requesting Session file content. -- Require a produced-file entry: this excludes ordinary read-only explanations. -- Add a new preview service: Chat already supplies the required opener and line parameter. +**Relative browser anchors.** They navigate the application URL instead of requesting Session file content. + +**Require a produced-file entry.** This excludes ordinary read-only explanations. + +**Add a preview service.** Chat already supplies the required opener and line parameter. + +**Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation. ## Consequences -Source references need no new Session event or model prompt. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, line navigation, and tab reuse through the shipped composition. +Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. Web prompt sidecars pin the assembled guidance. + +## Prompt evaluation + +The development comparison ran four read-only tasks three times per variant. E counts eligible existing-file mentions, L valid links, M missing links, and I invalid links; E = L + M + I. A timed out once without a final answer; that attempt remains a failure and is excluded only from content denominators. These descriptive measurements are neither a weighted quality score nor a holdout evaluation. + +| Metric | A | B | C | +| --- | ---: | ---: | ---: | +| Completed first answers / attempts | 11/12 | 12/12 | 12/12 | +| Valid-link coverage L/E | 453/476 (95.2%) | 539/585 (92.1%) | 467/592 (78.9%) | +| Missing / invalid links | 23 / 0 | 45 / 1 | 124 / 1 | +| Answers with M = I = 0 | 2/11 | 5/12 | 4/12 | +| Unnecessary directory labels / named labels | 149/373 | 200/471 | 166/403 | +| Full-path labels / named labels | 44/373 | 89/471 | 118/403 | +| Balanced 11-answer coverage | 95.2% | 93.0% | 79.0% | + +The colon revision is a separate development run. Its first three answers contained 28 mixed `:start-Lend` suffixes, retained as failures. Explicitly forbidding both `#` and `L` in the suffix yielded 51 structurally valid links in one repeated explanation, but no colon labels, three missing links, and 27 unnecessary directory qualifiers. A subsequent user-accepted demo produced 18 structurally valid links, including three colon labels and no mixed suffix. Neither run establishes reliable compliance or exhaustive factual accuracy. Authentication failures, the timeout, and all first answers remain in the local experiment archive; PR #4255 carries the extended 69-row metrics and task-rubric tables. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md index f29af63858..bc6b343c00 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -14,12 +14,34 @@ Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若 现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。 +Web 文件引用提示词要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。名称默认使用 basename 或清楚的别名,仅添加消除歧义所需的父目录。精确标签使用 `filename:24` 或 `filename:24–30`,目标仍使用解析器支持的 `#L24` 或 `#L24-L30` 语法。渲染器保留模型原文,不通过重写标签强制满足提示词。 + ## Alternatives considered -- 允许相对浏览器链接:这会跳转应用 URL,而非请求 Session 文件内容。 -- 要求产出文件条目:这会排除普通只读讲解。 -- 增加预览服务:Chat 已提供所需打开器和行号参数。 +**相对浏览器链接。** 这会跳转应用 URL,而非请求 Session 文件内容。 + +**要求产出文件条目。** 这会排除普通只读讲解。 + +**增加预览服务。** Chat 已提供所需打开器和行号参数。 + +**文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。 ## Consequences -源码引用无需新增 Session 事件或模型提示词。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、行号导航和标签复用。 +源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。Web 提示词 sidecar 固定组合后的指导。 + +## Prompt evaluation + +开发对照对每版执行四个只读任务,每个任务三次。E 统计应链接的现有文件提及,L 为有效链接,M 为漏链,I 为无效链接;E = L + M + I。A 有一次超时且没有最终回答;该次仍保留为失败,仅从内容分母排除。这些描述性测量既不是加权质量总分,也不是 holdout 评估。 + +| 指标 | A | B | C | +| --- | ---: | ---: | ---: | +| 完整首答 / 尝试 | 11/12 | 12/12 | 12/12 | +| 有效链接覆盖 L/E | 453/476 (95.2%) | 539/585 (92.1%) | 467/592 (78.9%) | +| 漏链 / 无效链接 | 23 / 0 | 45 / 1 | 124 / 1 | +| M = I = 0 的回答 | 2/11 | 5/12 | 4/12 | +| 不必要目录标签 / 名称标签 | 149/373 | 200/471 | 166/403 | +| 完整路径标签 / 名称标签 | 44/373 | 89/471 | 118/403 | +| 平衡的 11 份回答覆盖率 | 95.2% | 93.0% | 79.0% | + +冒号修订是独立的开发验证。最初三份回答包含 28 个混合 `:start-Lend` 后缀,均保留为失败。明确禁止后缀包含 `#` 和 `L` 后,一次重复讲解生成 51 个结构有效的链接,但没有冒号标签,仍有三次漏链和 27 个不必要的目录限定。随后用户接受的 demo 生成 18 个结构有效的链接,其中三个使用冒号标签,没有混合后缀。这些运行都不能证明稳定遵循或完整事实正确性。认证失败、超时和所有首答仍保留在本地实验归档中;PR #4255 包含扩展的 69 行指标及任务 rubric 表。 diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts index 734f69de0b..e7acd74152 100644 --- a/apps/web/tests/markdown-file-links.e2e.ts +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -33,7 +33,7 @@ describe('web e2e: Markdown file links', () => { await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.locator('[role="treeitem"]').first().click() await page.locator('[role="treeitem"]').nth(1).click() - await page.getByRole('button', { name: 'Source at line 24', exact: true }).waitFor() + await page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }).waitFor() await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() }, 120_000) @@ -44,7 +44,7 @@ describe('web e2e: Markdown file links', () => { it('previews relative and absolute links at the requested line without duplicate tabs', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-file-links')) - const source = page.getByRole('button', { name: 'Source at line 24', exact: true }) + const source = page.getByRole('button', { name: 'src/example.txt:24–30', exact: true }) const prose = await source.locator('..').ariaSnapshot() await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'links.expected.md'), prose, MODE) const beforeUrl = page.url() @@ -53,7 +53,7 @@ describe('web e2e: Markdown file links', () => { await expect.poll(() => column.locator('[data-textpreview-target="24"]').textContent()).toBe('source line 24\n') await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) .toBe(join(scaffold.workspaceCwd, 'src/example.txt')) - const absolute = page.getByRole('button', { name: 'Absolute at line 30', exact: true }) + const absolute = page.getByRole('button', { name: 'src/example.txt:30', exact: true }) await absolute.focus() await absolute.press('Enter') await expect.poll(() => column.locator('[data-textpreview-target="30"]').textContent()).toBe('source line 30\n') @@ -64,7 +64,7 @@ describe('web e2e: Markdown file links', () => { const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) await mkdir(artifacts, { recursive: true }) await page.screenshot({ path: join(artifacts, 'markdown-file-links.png'), animations: 'disabled' }) - await page.getByRole('button', { name: 'Other example', exact: true }).click() + await page.getByRole('button', { name: 'other/example.txt', exact: true }).click() await expect.poll(() => column.locator('[data-textpreview-path]').textContent()) .toBe(join(scaffold.workspaceCwd, 'other/example.txt')) await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('other file\n') diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index f359ce5e69..d7f33e278e 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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: 5ae62206bed36e611635511f0ad3472ea4e98107 -README.zh.md: 3d154d9c4239f119624c74e219980c0be3ab81c0 +README.md: 62ab7c1b3352e36a27fe374941be82e6927b2506 +README.zh.md: 82aa5e96b5233aeda9f80beb63cde6b5739cd227 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 5ae62206be..62ab7c1b33 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -50,7 +50,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
    Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. The browser half registers a wrapper around `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces 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 `ProducedFiles` and explicit deliveries into the chat view's `conversation.chat.turnTail` hole. `deliverablesDefinition` folds each Turn's successful first-party mutation calls into `DeliverablesTurnData` from the validated raw arguments of `write`, `edit`, and mutating `str_replace_editor` commands. Reads, deletes, unsupported tools, malformed calls, and failed results contribute nothing. A new mutation tool needs an explicit Client contribution before it joins the list. The package also provides the `chatFileMentions` service the chat view consults per closing message; composing the plugin out removes both surfaces and leaves the view's empty chain at zero cost. Native opening uses an authenticated POST addressed by the viewed Session, event sequence, and original file index. The Host reads the viewed Session header with the declaration and passes its cwd, or the deployment workspace root when absent, to `workspaceFiles.stat`. 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 and the card directs the user to Sidebar preview; 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. @@ -77,11 +77,11 @@ Read these pages when the deliverables surface is not enough. They move from the #### What the model sees -One fixed paragraph instructs the model to name primary files from successful creation or modification calls in its final response and to format those and any other changed-file references as exact-path or unique-basename Markdown inline code, such as `out/report.html`. +The guidance asks the model to name primary outputs after successful creation or modification and link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. #### Token effect -One fixed prompt paragraph whenever this package is loaded. The [present tool](../../fs/tool-present/README.md#model-experience) owns the delivery schema and result text. +One fixed paragraph containing an output reminder and file-reference guidance whenever this package is loaded. The [present tool](../../fs/tool-present/README.md#model-experience) owns the delivery schema and result text. #### KV Cache effect @@ -95,7 +95,7 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. -- **Terminal-created files require explicit delivery** — call `present` to make them available as delivery cards and clickable references. +- **Terminal-created files require explicit delivery** — call `present` to make them available as delivery cards and inline-code references; explicit Markdown links can reference existing files directly. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Directories have no destination** — chips open files in the right Sidebar's text preview, which shows files only; the former native folder handoff is gone rather than replaced. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 3d154d9c42..82aa5e96b5 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -50,7 +50,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
    实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 +Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverable-file-references` 系统提示词段。显式 Markdown 链接使用共享的 [Markdown 渲染器](../ui-primitives/README.zh.md);行内代码匹配仍仅限产出或交付文件。浏览器半部把组合 `ProducedFiles` 与显式交付的包装组件注册进 chat 视图的 `conversation.chat.turnTail` 洞。`deliverablesDefinition` 根据 `write`、`edit` 和有修改作用的 `str_replace_editor` 命令中经过校验的原始参数,把每个轮次成功的第一方修改调用折叠进 `DeliverablesTurnData`。读取、删除、不受支持的工具、格式错误的调用和失败结果不贡献任何条目。新的修改工具必须增加显式 Client contribution 才能加入列表。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会同时移除两个表面,视图的空链以零成本留下。 原生打开使用经过认证的 POST,通过当前查看的会话、事件序号和原始文件索引定位声明。Host 读取声明及当前查看的会话 header,将其中的 cwd 传给 `workspaceFiles.stat`;未记录 cwd 时使用部署的工作目录。它与侧栏预览使用同一组合文件系统,无需启动 Agent,子会话也适用。原生操作要求规范化的进程路径能从 Host 路径映射回同一进程路径。提供方没有这种映射时返回 422,卡片提示使用侧栏预览;Host 上存在同名文件并不足够。同一份桌面可用性配置同时约束信息查询和实际执行。编辑会影响后续打开的内容;删除后返回错误。不创建文件内容副本或附件。插件释放时取消并等待进行中的原生打开请求。 @@ -77,11 +77,11 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, #### 模型看到的内容 -一段固定提示词要求模型在最终回复中点名成功创建或修改的主要文件,并将这些文件以及正文中提到的其他本轮变更文件写成采用精确路径或唯一 basename 的 Markdown 行内代码,例如 `out/report.html`。 +提示词要求模型在成功创建或修改文件后点名主要产出,并链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 #### Token 影响 -加载本包时增加一段固定提示词。[present 工具](../../fs/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 +加载本包时增加一段包含产出提醒和文件引用指导的固定提示词。[present 工具](../../fs/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 #### KV Cache 影响 @@ -95,7 +95,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 -- **终端创建的文件需要显式交付**——调用 `present` 声明后才会显示交付卡片和可点击引用。 +- **终端创建的文件需要显式交付**——调用 `present` 声明后才会显示交付卡片和行内代码引用;显式 Markdown 链接可以直接引用现有文件。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **目录没有打开目标**——标签项在右侧 Sidebar 的文本预览中打开文件,该预览仅支持文件,不提供原生文件夹打开动作。 diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index 04e5e7a8dd..bc6be9f726 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -1,7 +1,6 @@ /** - * Deliverables plugin, node half. Registers the response-format guidance that - * lets the browser half recognize final-response file references and serves - * authenticated native opens of declared files. The browser + * Deliverables plugin, node half. Registers Web file-reference guidance and + * serves authenticated native opens of declared files. The browser * half ships via exports["./client"], discovered through the package.json * dsh.client declaration. */ @@ -13,12 +12,13 @@ import { registerPresentOpen } from './present-open.ts' /** Services required for file-reference guidance and authenticated native opens of declared files. */ export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'] -/** Stable final-response guidance owned by the matching renderer. */ +/** Static Web guidance for primary outputs and existing-file references. */ const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' - + 'To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.' + + 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. ' + + 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.' /** - * Register model guidance for the file-reference renderer shipped by this package. + * Register Web file-reference guidance and native opens for declared files. * @param ctx - host context carrying the system-prompt registry. */ export function apply(ctx: Context): void { diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index b56bfa3b8e..5070303a73 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -27,7 +27,7 @@ describe('ui-deliverables node plugin', () => { const section = (await ctx.systemPrompt.assemble()).sections .find(entry => entry.name === 'ui:deliverable-file-references') - expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn."') + expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') await mounted.dispose() expect((await ctx.systemPrompt.assemble()).sections diff --git a/snapshots/web/cordis-tool-round/system-prompt.expected.md b/snapshots/web/cordis-tool-round/system-prompt.expected.md index ef9be82aa6..a6bd84f32d 100644 --- a/snapshots/web/cordis-tool-round/system-prompt.expected.md +++ b/snapshots/web/cordis-tool-round/system-prompt.expected.md @@ -136,7 +136,7 @@ Use subagent in the background by default. Start independent delegations togethe Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. -When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. +When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. diff --git a/snapshots/web/fresh-round-trip/system-prompt.expected.md b/snapshots/web/fresh-round-trip/system-prompt.expected.md index 55a8c46a0a..a320dcb8b8 100644 --- a/snapshots/web/fresh-round-trip/system-prompt.expected.md +++ b/snapshots/web/fresh-round-trip/system-prompt.expected.md @@ -30,7 +30,7 @@ Use subagent in the background by default. Start independent delegations togethe Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message. -When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. +When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. diff --git a/snapshots/web/markdown-file-links/links.expected.md b/snapshots/web/markdown-file-links/links.expected.md index 6e13842563..b819a2ff60 100644 --- a/snapshots/web/markdown-file-links/links.expected.md +++ b/snapshots/web/markdown-file-links/links.expected.md @@ -1,9 +1,9 @@ - paragraph: - - button "Source at line 24" + - button "src/example.txt:24–30" - text: · - - button "Absolute at line 30" + - button "src/example.txt:30" - text: · - - button "Other example" + - button "other/example.txt" - text: · - button "Missing file" - text: · diff --git a/snapshots/web/markdown-file-links/session.v3.jsonl b/snapshots/web/markdown-file-links/session.v3.jsonl index c9d85c864f..fd445f9697 100644 --- a/snapshots/web/markdown-file-links/session.v3.jsonl +++ b/snapshots/web/markdown-file-links/session.v3.jsonl @@ -12,6 +12,6 @@ {"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"call_01_Hw6AQjhf9gjxnOtppcGx0725","content":[{"type":"text","text":"{{cwd}}/workspace/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false}],"source":{"kind":"tool","callId":"call_01_Hw6AQjhf9gjxnOtppcGx0725"},"id":"{{message:5}}"}},"sourceEventSeqs":[7],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/message","data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"text","text":"[Source at line 24](src/example.txt#L24-L30) · [Absolute at line 30]({{cwd}}/src/example.txt#L30) · [Other example](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[Source at line 24](src/example.txt#L24-L30) · [Absolute at line 30]({{cwd}}/src/example.txt#L30) · [Other example](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[Source at line 24](src/example.txt#L24-L30) · [Absolute at line 30]({{cwd}}/src/example.txt#L30) · [Other example](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"usage":{"inputTokens":215,"outputTokens":32,"cacheReadTokens":7808,"reasoningTokens":29},"message":{"role":"assistant","content":[{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}],"source":{"provider":"deepseek-official","model":"deepseek-v4-flash","kind":"model"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"[src/example.txt:24–30](src/example.txt#L24-L30) · [src/example.txt:30]({{cwd}}/src/example.txt#L30) · [other/example.txt](other/example.txt) · [Missing file](missing.txt) · [Website](https://example.com/)"}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/web/ptc-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md index fe8b9b7685..72d3cf9b72 100644 --- a/snapshots/web/ptc-round/system-prompt.expected.md +++ b/snapshots/web/ptc-round/system-prompt.expected.md @@ -540,7 +540,7 @@ declare const tools: { } ``` -When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. +When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself. From ed0ae7786d7ab70319de018384e8dfbfc450ed1c Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 16 Sep 2026 13:43:20 +0800 Subject: [PATCH 015/225] test(web): align shipped file-reference guidance expectation --- .../web-runtime-context/file-reference-prompt.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index 6561157aec..fbfa9b1cab 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -1 +1 @@ -When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn. +When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. From f25f3527ef6aeba787f2e06150ede8096e3c059b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 16 Sep 2026 14:44:16 +0800 Subject: [PATCH 016/225] test(ui-deliverables): preserve file-reference prompt evaluations --- ...9-15-markdown-file-preview-links.i18n.yaml | 4 +- .../2026-09-15-markdown-file-preview-links.md | 2 + ...26-09-15-markdown-file-preview-links.zh.md | 2 + .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 1 + packages/client/ui-deliverables/README.zh.md | 1 + .../evals/file-references/README.i18n.yaml | 6 + .../evals/file-references/README.md | 58 +++ .../evals/file-references/README.zh.md | 58 +++ .../evals/file-references/cases/diagnose.txt | 1 + .../evals/file-references/cases/explain.txt | 1 + .../evals/file-references/cases/plan.txt | 1 + .../evals/file-references/cases/review.txt | 1 + .../evals/file-references/prompts/a-colon.txt | 2 + .../file-references/prompts/baseline.txt | 2 + .../evals/file-references/prompts/display.txt | 2 + .../file-references/prompts/filename.txt | 2 + .../results/2026-09-16/metrics.md | 108 +++++ .../results/2026-09-16/notes.md | 14 + .../results/2026-09-16/observations.json | 415 ++++++++++++++++++ .../evals/file-references/rubric.md | 75 ++++ .../evals/file-references/run.py | 174 ++++++++ .../evals/file-references/summarize.py | 81 ++++ .../evals/file-references/test_eval.py | 115 +++++ .../evals/file-references/treatment.mjs | 14 + 25 files changed, 1140 insertions(+), 4 deletions(-) create mode 100644 packages/client/ui-deliverables/evals/file-references/README.i18n.yaml create mode 100644 packages/client/ui-deliverables/evals/file-references/README.md create mode 100644 packages/client/ui-deliverables/evals/file-references/README.zh.md create mode 100644 packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/cases/explain.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/cases/plan.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/cases/review.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/display.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/filename.txt create mode 100644 packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md create mode 100644 packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md create mode 100644 packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json create mode 100644 packages/client/ui-deliverables/evals/file-references/rubric.md create mode 100644 packages/client/ui-deliverables/evals/file-references/run.py create mode 100644 packages/client/ui-deliverables/evals/file-references/summarize.py create mode 100644 packages/client/ui-deliverables/evals/file-references/test_eval.py create mode 100644 packages/client/ui-deliverables/evals/file-references/treatment.mjs diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml index cfa6060a56..d0db537fe3 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md -2026-09-15-markdown-file-preview-links.md: 07177c15d134b805042d93f48f8caae6bc165620 -2026-09-15-markdown-file-preview-links.zh.md: bc6b343c0091339e9be25d062a006905c6e9477f +2026-09-15-markdown-file-preview-links.md: 337385c2218d8b38b292094e90f12a46f0a7297c +2026-09-15-markdown-file-preview-links.zh.md: a14974bbd078f895b2598fe18943c8a8d55cbb88 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md index 07177c15d1..337385c221 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -45,3 +45,5 @@ The development comparison ran four read-only tasks three times per variant. E c | Balanced 11-answer coverage | 95.2% | 93.0% | 79.0% | The colon revision is a separate development run. Its first three answers contained 28 mixed `:start-Lend` suffixes, retained as failures. Explicitly forbidding both `#` and `L` in the suffix yielded 51 structurally valid links in one repeated explanation, but no colon labels, three missing links, and 27 unnecessary directory qualifiers. A subsequent user-accepted demo produced 18 structurally valid links, including three colon labels and no mixed suffix. Neither run establishes reliable compliance or exhaustive factual accuracy. Authentication failures, the timeout, and all first answers remain in the local experiment archive; PR #4255 carries the extended 69-row metrics and task-rubric tables. + +Reusable [evaluation inputs and rubrics](../../../../packages/client/ui-deliverables/evals/file-references/README.md) live beside the prompt owner, with compact historical observations and failure records. Their manual runner uses the supported headless profile and keeps generated outputs outside version control. Package ownership keeps prompt changes and their regression criteria together; the repository performance benchmark tree has different input and timing requirements. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md index bc6b343c00..a14974bbd0 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -45,3 +45,5 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外 | 平衡的 11 份回答覆盖率 | 95.2% | 93.0% | 79.0% | 冒号修订是独立的开发验证。最初三份回答包含 28 个混合 `:start-Lend` 后缀,均保留为失败。明确禁止后缀包含 `#` 和 `L` 后,一次重复讲解生成 51 个结构有效的链接,但没有冒号标签,仍有三次漏链和 27 个不必要的目录限定。随后用户接受的 demo 生成 18 个结构有效的链接,其中三个使用冒号标签,没有混合后缀。这些运行都不能证明稳定遵循或完整事实正确性。认证失败、超时和所有首答仍保留在本地实验归档中;PR #4255 包含扩展的 69 行指标及任务 rubric 表。 + +可复用的[评测输入和 rubric](../../../../packages/client/ui-deliverables/evals/file-references/README.zh.md) 与提示词所有者放在一起,并保留精简历史观察和失败记录。人工触发的运行器使用受支持的 headless profile,生成输出不进入版本控制。包内归属让提示词变化与回归标准一起维护;仓库性能 benchmark 目录有不同的输入和计时要求。 diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index d7f33e278e..29bc407ba6 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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: 62ab7c1b3352e36a27fe374941be82e6927b2506 -README.zh.md: 82aa5e96b5233aeda9f80beb63cde6b5739cd227 +README.md: 2cc174a1be69cec37ef1eb07c017e70562e9a3a0 +README.zh.md: a25b046ef2fa76a8f787265a8992572830efa255 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 62ab7c1b33..2cc174a1be 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -63,6 +63,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 row to the turn-tail hole and the decisions behind the vocabulary. +- [File-reference evaluation](evals/file-references/README.md) — frozen prompts, tasks, manual rubrics, and reproducible development-result summaries. - [ui-conversation](../ui-conversation/README.md) — declares the `conversation.chat.turnTail` hole and renders the closing prose. - [Workspace file links](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md) — the decision behind the 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. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 82aa5e96b5..a25b046ef2 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -63,6 +63,7 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl 当产出物面不够用时阅读以下页面。它们从该行进入 turn-tail 洞与词表背后的决策。 +- [文件引用评测](evals/file-references/README.zh.md) — 冻结提示词、任务、人工 rubric 和可复算的开发评测汇总。 - [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 - [工作区文件链接](../../../.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)——收尾正文可点击提及背后的决策。 diff --git a/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml b/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml new file mode 100644 index 0000000000..fa43b76322 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-deliverables/evals/file-references/README.md +README.md: e6c5520cf92cacc80d917f600fdeb0352f6c5c7b +README.zh.md: ea94964ec996ee6e206b6f141e6b71063e2e2a88 diff --git a/packages/client/ui-deliverables/evals/file-references/README.md b/packages/client/ui-deliverables/evals/file-references/README.md new file mode 100644 index 0000000000..e6c5520cf9 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/README.md @@ -0,0 +1,58 @@ +--- +description: "File-reference prompt evaluation inputs, manual rubrics, and preserved development results for prompt maintainers." +kind: "package-reference" +--- + +# File-reference prompt evaluation + +English | [中文](README.zh.md) + +## Summary + +This owner-local evaluation compares file-reference guidance using four unchanged development tasks. It preserves A/B/C, the final colon-label revision, eligibility rules, task rubrics, and historical failures. Real-model runs are manual; the existing package tests and Web Session snapshots own keyless product regressions. The runner and aggregator use Python's standard library; the treatment enters the logged system prompt through a regular plugin. + +## Table of Contents + +- [Run a cohort](#run-a-cohort) +- [Review and summarize](#review-and-summarize) +- [Preserved results](#preserved-results) +- [Dev Note](#dev-note) + + +## Run a cohort + +Requires Python 3.9+, a POSIX host, Node and repository dependencies, and a built checkout (`pnpm run build`). Run from the repository root. Supply the normal DSH environment file with `--env-file`, or inherit its environment; the runner does not read credential values. `--model` is explicit, with the DeepSeek provider and high reasoning effort. Each attempt gets fresh DSH home, agent home, Session, and an archived HEAD workspace; the executable is this checkout's built `dsh --profile headless` launcher. + +For one real smoke, set `DSH_EVAL_ENV` to your normal environment-file path and run: + +```sh +python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV" +``` + +Omit `--variant`, `--case`, and `--repetitions` for the original three variants, four tasks, and three repetitions. `--prepare-only` records inputs without calling the API. The default 480-second attempt bound can be shortened with `--timeout`. Runs execute serially and stop at the first failed attempt; no retry or resume replaces an output. The printed directory lives under ignored `.artifacts/file-reference-evals/`, retaining input hashes, source archive, outputs, workspaces, Sessions, and failures. A supplied `--output` must be new and inside `.artifacts/`. + +The portable runner uses the normal read-only sandbox and never-approval policy. Evaluator files are removed from the task workspace, but host/runtime/network resources are shared: this is not hermetic isolation. Inspect recorded tool activity for out-of-workspace access. Its controls differ from historical R3's macOS-specific guards and parallel scheduling; compare only within the new cohort, not by pooling it with R3. The archive pins source HEAD while the launcher hash identifies the build; rebuild after runtime changes. + + +## Review and summarize + +Apply [rubric.md](rubric.md) to the full first answer and preserved workspace. Keep evaluators separate from execution; rubric contents are not passed to the model. Occurrence eligibility, unnecessary directories, ambiguity, and factual support require human review. Preserve the occurrence and task ledgers with each run, then write hash-bound `annotation.json` as documented in the rubric. Unreviewed answers remain unreviewed; an absent answer never counts as a perfect response. + +The summarizer accepts the printed run directory, or the committed historical observations: + +```sh +python3 packages/client/ui-deliverables/evals/file-references/summarize.py packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json +python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v +``` + +It validates nonnegative counts, E = L + M + I, duplicate attempts, and fresh-answer hashes. It reports planned/attempted/answered counts, missing audits, valid-link coverage, perfect audited answers, and timeouts. It does not automatically infer missing-file mentions or judge semantic correctness. These keyless Python checks validate the evaluation tools; they are manually invoked and do not add a real-API CI job. + + +## Preserved results + +[The R3 table](results/2026-09-16/metrics.md) retains all 69 original metrics and task rubrics. [Observations](results/2026-09-16/observations.json) reproduce the core counts and include the timeout. [Limitations and later runs](results/2026-09-16/notes.md) keep the colon revisions and guided recording separate. The raw first answers, full Sessions, authentication logs, and original audit ledgers remain in the private experiment archive, not in this package or its npm tarball. Historical aggregate counts support recomputation, not independent re-auditing without those raw records. + + +## Dev Note + +The [file-preview decision](../../../../../.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md) owns prompt selection. This directory owns reusable evaluation inputs and methods; repository `benchmarks/` owns cross-package performance gates. diff --git a/packages/client/ui-deliverables/evals/file-references/README.zh.md b/packages/client/ui-deliverables/evals/file-references/README.zh.md new file mode 100644 index 0000000000..ea94964ec9 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/README.zh.md @@ -0,0 +1,58 @@ +--- +description: "供提示词维护者使用的文件引用提示词评测输入、人工 rubric 和保留的开发评测结果。" +kind: "package-reference" +--- + +# 文件引用提示词评测 + +[English](README.md) | 中文 + +## 摘要 + +这份包内评测使用四个未修改的开发任务比较文件引用指导。它保留 A/B/C、最终冒号标签版、计数规则、任务 rubric 和历史失败。真实模型运行由人工触发;现有包测试和 Web Session 快照负责无密钥产品回归。运行器和汇总器使用 Python 标准库;评测提示词通过普通插件进入已记录的系统提示词。 + +## 目录 + +- [运行一组评测](#run-a-cohort) +- [评审与汇总](#review-and-summarize) +- [保留的结果](#preserved-results) +- [开发者笔记](#dev-note) + + +## 运行一组评测 + +需要 Python 3.9+、POSIX 主机、Node 和仓库依赖,以及已构建的检出目录(`pnpm run build`)。从仓库根目录执行。通过 `--env-file` 指定正常使用的 DSH 环境文件,或继承其环境;运行器不读取凭据值。`--model` 必须显式提供,使用 DeepSeek provider 和 high 推理强度。每次尝试使用新的 DSH home、agent home、Session 和 HEAD 归档工作区;可执行程序是本检出目录构建的 `dsh --profile headless` 启动器。 + +执行一次真实冒烟验证时,将 `DSH_EVAL_ENV` 设为正常环境文件路径,然后运行: + +```sh +python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV" +``` + +省略 `--variant`、`--case` 和 `--repetitions` 即使用原三版、四个任务和三次重复。`--prepare-only` 只记录输入,不调用 API。默认每次尝试上限为 480 秒,可用 `--timeout` 缩短。运行串行执行,遇到首次失败即停止;重试或恢复不会替换输出。打印的目录位于被忽略的 `.artifacts/file-reference-evals/` 下,保留输入哈希、源码归档、输出、工作区、Session 和失败。指定的 `--output` 必须是 `.artifacts/` 内的新目录。 + +可移植运行器使用正常的只读 sandbox 和 never approval 策略。评测文件会从任务工作区移除,但宿主、运行时和网络资源仍共享,因此不是完全隔离环境。应检查记录的工具活动中是否存在工作区外访问。它的控制措施与历史 R3 的 macOS 专用防护及并行调度不同;只在新组内比较,不与 R3 合并统计。归档固定 HEAD 源码,启动器哈希标识构建;运行时代码变化后应重新构建。 + + +## 评审与汇总 + +依据 [rubric.md](rubric.md) 检查完整首答和保留的工作区。评审与执行分开;rubric 内容不传给模型。引用是否应计数、目录是否多余、是否有歧义及事实依据都需要人工评审。保留每次运行的引用和任务台账,再按 rubric 写入绑定答案哈希的 `annotation.json`。未经评审的回答保持未评审;缺失回答不计为完美回答。 + +汇总器接受打印的运行目录,或已提交的历史观察数据: + +```sh +python3 packages/client/ui-deliverables/evals/file-references/summarize.py packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json +python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v +``` + +它验证非负计数、E = L + M + I、重复尝试和新答案哈希,并报告计划数、尝试数、回答数、缺失审计、有效链接覆盖、已审计完美回答和超时。它不自动推断遗漏的文件提及,也不判断语义正确性。这些无密钥 Python 检查验证评测工具,由人工调用,不增加真实 API CI 作业。 + + +## 保留的结果 + +[R3 表格](results/2026-09-16/metrics.md) 保留全部 69 项原始指标和任务 rubric。[观察数据](results/2026-09-16/observations.json) 可复算核心计数,并包含超时。[限制及后续运行](results/2026-09-16/notes.md) 将冒号修订和指定格式的录屏单列。原始首答、完整 Session、认证日志和原始审计台账保留在私有实验归档中,不进入此包或其 npm tarball。历史聚合计数支持复算;没有原始记录时,不支持独立重新审计。 + + +## 开发者笔记 + +[文件预览决策](../../../../../.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md) 负责提示词选择。本目录负责可复用评测输入和方法;仓库 `benchmarks/` 负责跨包性能门禁。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt b/packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt new file mode 100644 index 0000000000..a922eab05a --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt @@ -0,0 +1 @@ +使用 str_replace_editor 时,我先 view 了一个文件,外部程序随后改了它,再执行 str_replace 报 FS_STALE_VERSION。请基于 packages/fs 里的实现、测试和文档给出排查步骤,说明怎样正确重试、恢复旧会话时有什么不同,以及从哪些文件继续定位;可以给必要的调用示例。只读分析,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/explain.txt b/packages/client/ui-deliverables/evals/file-references/cases/explain.txt new file mode 100644 index 0000000000..bd103b673e --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/cases/explain.txt @@ -0,0 +1 @@ +讲讲 packages/bundle/headless 是怎么把命令行任务跑成一次 Agent 会话的:任务从哪里来,如何选模型和工作目录,什么时候打印结果,失败和恢复会话如何处理。只读分析,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/plan.txt b/packages/client/ui-deliverables/evals/file-references/cases/plan.txt new file mode 100644 index 0000000000..5ef6863e66 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/cases/plan.txt @@ -0,0 +1 @@ +想让 packages/util/atomic-write 的 writeFileAtomic 支持 Buffer,同时保留现有字符串调用。请给一个最小修改方案,列出涉及的实现、测试、文档与调用方,说明兼容性和验证方式。只分析方案,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/review.txt b/packages/client/ui-deliverables/evals/file-references/cases/review.txt new file mode 100644 index 0000000000..41cc4bad89 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/cases/review.txt @@ -0,0 +1 @@ +请 review packages/util/lazy-require 的实现、测试和 README,指出值得修复的问题;没有确认的问题也请直说,并说明你检查了哪些行为。只读分析,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt b/packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt new file mode 100644 index 0000000000..b31f8a440f --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt @@ -0,0 +1,2 @@ +Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. +Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt b/packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt new file mode 100644 index 0000000000..a5ead75311 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt @@ -0,0 +1,2 @@ +Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. +Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/display.txt b/packages/client/ui-deliverables/evals/file-references/prompts/display.txt new file mode 100644 index 0000000000..3e3890dfbc --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/prompts/display.txt @@ -0,0 +1,2 @@ +Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. +Keep full paths in link targets, with only a filename or short, clear alias visible; use the surrounding text or a brief qualifier to distinguish same-named files. diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/filename.txt b/packages/client/ui-deliverables/evals/file-references/prompts/filename.txt new file mode 100644 index 0000000000..a511017804 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/prompts/filename.txt @@ -0,0 +1,2 @@ +Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. +Use the filename alone as the label by default; if ambiguous in context, use a short, clear alias or brief qualifier. Keep directory paths in the target. diff --git a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md new file mode 100644 index 0000000000..1d4471a2a4 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md @@ -0,0 +1,108 @@ +# R3 补充 metrics 与 rubric 表格 + +A = baseline,B = filename,C = display。除标明“首次尝试”或“平衡子集”的行外,使用全部非空首答:A 11 份、B/C 各 12 份。A 的 review 第 2 轮 480.17 秒超时保留,空答不进入内容分母;没有重跑。这里补充汇总既有台账,未修改冻结 rubric、首答或失败记录。 + +数字是描述量,不能相加为总分。任务要求被覆盖,不等于其中断言正确;事实问题和限制另列。耗时含工具工作、网络与主机调度,仅描述本轮,不能归因为 SP 性能差异。排除项与待定项统计台账条目,集体 README 条目可能代表多个对象,因此不能直接加回 E。 + +## 执行与稳定性 + +| 指标 / rubric | A | B | C | +| --- | ---: | ---: | ---: | +| 首次尝试 | 12 | 12 | 12 | +| 完整首答 / 首次尝试 | 11/12 (91.7%) | 12/12 (100.0%) | 12/12 (100.0%) | +| 无最终回答的超时 | 1 | 0 | 0 | +| 首答有效引用全部链接(M=I=0) | 2/11 (18.2%) | 5/12 (41.7%) | 4/12 (33.3%) | +| 首答完全没有文件链接 | 0/11 (0.0%) | 0/12 (0.0%) | 3/12 (25.0%) | +| 完成首答耗时中位数(秒) | 77.19 | 94.28 | 86.09 | +| 完成首答耗时范围(秒) | 58.97–244.59 | 64.84–306.79 | 66.34–368.05 | +| 所有首次尝试总墙钟耗时之和(秒;并发任务相加) | 1639.39 | 1493.39 | 1615.84 | +| 工作区未变更 / 首次尝试 | 12/12 (100.0%) | 12/12 (100.0%) | 12/12 (100.0%) | + +## 引用、标签与歧义 + +| 指标 / rubric | A | B | C | +| --- | ---: | ---: | ---: | +| 有效链接覆盖 L/E | 453/476 (95.2%) | 539/585 (92.1%) | 467/592 (78.9%) | +| 已生成链接中的结构有效率 L/(L+I) | 453/453 (100.0%) | 539/540 (99.8%) | 467/468 (99.8%) | +| 漏链 / 无效链接 | 23 / 0 | 45 / 1 | 124 / 1 | +| 越界行号引用 | 0 | 1 | 1 | +| 不同文件数(跨首答按仓库相对路径去重) | 89 | 94 | 104 | +| 每份首答不同文件数之和(跨答不去重) | 186 | 207 | 230 | +| 待定引用台账条目(不进 E) | 12 | 16 | 17 | +| 排除项台账条目(不是全体排除引用次数) | 67 | 64 | 104 | +| 同名文件上下文歧义:引用次数 / 首答数 | 0 / 0 | 4 / 1 | 2 / 1 | +| 不必要目录标签 / 文件名或别名标签 | 149/373 (39.9%) | 200/471 (42.5%) | 166/403 (41.2%) | +| 完整路径标签 / 文件名或别名标签 | 44/373 (11.8%) | 89/471 (18.9%) | 118/403 (29.3%) | +| 未链接引用中仍显示完整路径 | 1 | 2 | 59 | +| 文件名或别名长度中位数 / P90(字符) | 19 / 39 | 16 / 43 | 20 / 42 | +| 文件名或别名长度均值(字符) | 21.58 | 21.86 | 23.37 | +| 实际数字标签 / 明确行号标签(不进名称长度) | 79 / 1 | 68 / 1 | 65 / 0 | + +## 平衡子集:每版相同的 11 个任务轮次 + +| 指标 / rubric | A | B | C | +| --- | ---: | ---: | ---: | +| 有效链接覆盖 | 453/476 (95.2%) | 506/544 (93.0%) | 432/547 (79.0%) | +| 全引用有效链接的首答 | 2/11 (18.2%) | 5/11 (45.5%) | 4/11 (36.4%) | +| 不必要目录标签 / 文件名或别名标签 | 149/373 (39.9%) | 185/438 (42.2%) | 152/368 (41.3%) | +| 同名歧义引用次数 | 0 | 4 | 2 | +| 完成耗时中位数(秒) | 77.19 | 93.92 | 84.02 | + +## 原任务 rubric 分项 + +| 指标 / rubric | A | B | C | +| --- | ---: | ---: | ---: | +| P1 Buffer 最小实现与 string 支持 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | +| P2 实现、测试、文档、调用方四个面 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | +| P3 兼容性 | 3/3 已回应 | 3/3 已回应 | 2/3 已回应;1 份有矛盾 | +| P4 验证方案 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | +| P5 只读/无子代理 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | +| D1 陈旧版本时序与排查步骤 | 3/3 已回应 | 1/3 已回应;2 份有矛盾 | 2/3 已回应;1 份有矛盾 | +| D2 正确重试流程 | 2/3 已回应;1 份有矛盾 | 3/3 已回应 | 3/3 已回应 | +| D3 恢复旧会话的区别 | 3/3 已回应 | 2/3 已回应;1 份有矛盾 | 2/3 已回应;1 份有矛盾 | +| D4 实现、测试、文档依据 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | +| D5 给出的调用示例符合实现 | 2/3 已回应;1 份有矛盾 | 2/3 已回应;1 份有矛盾 | 3/3 已回应 | +| D6 只读/无子代理 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | +| R1 实现检查 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | +| R2 测试检查 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | +| R3 README 检查 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | +| R4 明确的 findings 结论 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | +| R5 说明检查过的行为 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | +| R6 核查 findings 的事实依据 | 2/2 有审计;均保留证据限制;另 1 次超时 | 3/3 有审计;均保留证据限制 | 3/3 有审计;均保留证据限制 | +| R7 只读/无子代理 | 2/2 记录支持;另 1 次超时 | 3/3 记录支持 | 3/3 记录支持 | +| X1 任务来源 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | +| X2 模型选择 | 3/3 已覆盖;1 份有矛盾 | 3/3 已覆盖;1 份有矛盾 | 3/3 已覆盖 | +| X3 工作目录 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | +| X4 输出时机 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | +| X5 失败处理 | 3/3 已覆盖;1 份有矛盾 | 3/3 已覆盖 | 3/3 已覆盖 | +| X6 恢复会话 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | +| X7 贯通会话流程 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | +| X8 事实及引用依据 | 3/3 记录问题或证据限制 | 3/3 记录问题或证据限制 | 3/3 记录问题或证据限制 | +| X9 只读/无子代理 | 3/3 记录支持 | 3/3 记录支持 | 3/3 记录支持 | + +## 问题记录:各任务保留各自分类,不相加成总分 + +| 指标 / rubric | A | B | C | +| --- | ---: | ---: | ---: | +| plan:源码支持的矛盾条目 | 1 | 2 | 2 | +| diagnose:源码支持的矛盾条目 | 5 | 6 | 6 | +| plan:仓库指令不符条目 | 2 | 1 | 2 | +| plan:无依据断言 / 文档遗漏条目 | 1 / 1 | 1 / 0 | 0 / 1 | +| explain:X8 问题或证据限制条目(含轻微措辞、引用问题) | 7 | 7 | 7 | +| review:R6 仍未证明的事实/运行结论 | 2/2 保留限制 | 3/3 保留限制 | 3/3 保留限制 | + +## 实验与 GUI rubric 状态 + +| 指标 / rubric | A | B | C | +| --- | ---: | ---: | ---: | +| SP 两条物理行,通用指导 | 2 | 2 | 2 | +| 原任务逐字保留;三次首次尝试 | 已核对 | 已核对 | 已核对 | +| GUI 原始首答导入(方案第 1、2 轮) | 2 份;字节一致 | 2 份;字节一致 | 2 份;字节一致 | +| GUI 显示、换行、截断与表格布局 | 未验证:浏览器阻止访问 | 未验证:浏览器阻止访问 | 未验证:浏览器阻止访问 | +| 实际点击并打开目标/定位行号 | 未验证 | 未验证 | 未验证 | +| 主观美观/偏好 | 未评;无数值分数 | 未评;无数值分数 | 未评;无数值分数 | +| 未见过的 holdout | 无;仅开发/回归任务 | 无;仅开发/回归任务 | 无;仅开发/回归任务 | + +同名歧义的人工记录集中于 B diagnose 第 2 轮的 4 次和 C diagnose 第 1 轮的 2 次;若把相邻角色的列举顺序视为足够对应,两者敏感性下界均为 0。0 表示没有观察到歧义,不代表全局唯一性得到证明。长度延续主汇总的顺序统计口径(偶数样本中位数取较低中位项,P90 取上整秩),只描述文件名/别名,排除数字与明确行号标签。 + +结构有效率只检查 Markdown、解析、文件存在性和行号范围。语义引用支持未逐条穷尽;例如 A explain 第 2 轮、B explain 第 1 轮仍有范围存在但不能完全支持断言的引用。越界行号的 2 次分属 B/C explain 第 3 轮;不应误报为文件不存在。不同评审的事实问题分类与粒度不同,尤其 review 的 R6 是逐例文字审计,故没有将它强行变成与其他任务可加总的错误次数。 diff --git a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md new file mode 100644 index 0000000000..01ad6408f0 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md @@ -0,0 +1,14 @@ +# R3 and subsequent colon-label observations + +R3 contains 36 first attempts on four known development tasks, three repetitions per A/B/C treatment. One A review attempt timed out at 480.17 seconds without a final answer; it remains in execution counts and outside content denominators. Earlier authentication failures and earlier unsuccessful cohorts remain separately archived. No retry replaces an original first answer. + +The [original 69-row table](metrics.md) preserves its audit-time GUI status. The [compact observations](observations.json) preserve E/L/M/I for every attempted slot and exact SHA-256 hashes of the original prompts and tasks. These aggregates do not contain complete answer text or occurrence-level evidence; independent semantic re-audit requires the private archive. The original controls included macOS-specific host-read restrictions, maximum concurrency four, and a 480-second wall limit. Two runs had recorded host-access limitations; another read unfrozen external history. No cohort is claimed hermetic. + +| Later evidence | Valid links | Missing links | Label observations | Scope | +| --- | ---: | ---: | --- | --- | +| First A colon revision: plan, review, explain | 102 | 13 | 28 mixed `:start-Lend` suffixes | Retained failures; separate revision. | +| Corrected A: explanation | 51 | 3 | No colon labels; 27 unnecessary directory qualifiers | Colon branch inactive. | +| Corrected A: accepted GUI demo | 18 | Not fully audited | 3 colon labels; no mixed suffix | One task; user accepted the display. | +| PR recording | 5 | Not fully audited | Explicit `filename:line` request; five colon labels | Guided UI demonstration; not autonomous adherence evidence. | + +A leads original occurrence coverage; B has more wholly linked answers and completes all attempts. C has three answers without Markdown file links. All variants have factual problems or unresolved claims. No weighted score, statistically established winner, or unseen holdout is available. The final colon revision has not received the full four-task, three-repeat comparison. diff --git a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json new file mode 100644 index 0000000000..815481fe47 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json @@ -0,0 +1,415 @@ +{ + "cohort": "historical-r3", + "runs": [ + { + "name": "plan-baseline-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 36, + "L": 34, + "M": 2, + "I": 0 + } + }, + { + "name": "plan-filename-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 36, + "L": 35, + "M": 1, + "I": 0 + } + }, + { + "name": "plan-display-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 30, + "L": 0, + "M": 30, + "I": 0 + } + }, + { + "name": "diagnose-baseline-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 76, + "L": 74, + "M": 2, + "I": 0 + } + }, + { + "name": "diagnose-filename-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 65, + "L": 65, + "M": 0, + "I": 0 + } + }, + { + "name": "diagnose-display-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 85, + "L": 85, + "M": 0, + "I": 0 + } + }, + { + "name": "review-baseline-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 37, + "L": 33, + "M": 4, + "I": 0 + } + }, + { + "name": "review-filename-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 36, + "L": 25, + "M": 11, + "I": 0 + } + }, + { + "name": "review-display-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 31, + "L": 19, + "M": 12, + "I": 0 + } + }, + { + "name": "explain-baseline-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 28, + "L": 27, + "M": 1, + "I": 0 + } + }, + { + "name": "explain-filename-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 53, + "L": 53, + "M": 0, + "I": 0 + } + }, + { + "name": "explain-display-1", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 53, + "L": 52, + "M": 1, + "I": 0 + } + }, + { + "name": "plan-filename-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 34, + "L": 30, + "M": 4, + "I": 0 + } + }, + { + "name": "plan-display-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 27, + "L": 20, + "M": 7, + "I": 0 + } + }, + { + "name": "plan-baseline-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 30, + "L": 28, + "M": 2, + "I": 0 + } + }, + { + "name": "diagnose-filename-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 66, + "L": 66, + "M": 0, + "I": 0 + } + }, + { + "name": "diagnose-display-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 57, + "L": 57, + "M": 0, + "I": 0 + } + }, + { + "name": "diagnose-baseline-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 65, + "L": 64, + "M": 1, + "I": 0 + } + }, + { + "name": "review-filename-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 41, + "L": 33, + "M": 8, + "I": 0 + } + }, + { + "name": "review-display-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 45, + "L": 35, + "M": 10, + "I": 0 + } + }, + { + "name": "review-baseline-2", + "nonemptyFinal": false, + "timedOut": true, + "counts": null + }, + { + "name": "explain-filename-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 41, + "L": 39, + "M": 2, + "I": 0 + } + }, + { + "name": "explain-display-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 61, + "L": 61, + "M": 0, + "I": 0 + } + }, + { + "name": "explain-baseline-2", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 38, + "L": 38, + "M": 0, + "I": 0 + } + }, + { + "name": "plan-display-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 31, + "L": 0, + "M": 31, + "I": 0 + } + }, + { + "name": "plan-baseline-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 26, + "L": 25, + "M": 1, + "I": 0 + } + }, + { + "name": "plan-filename-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 46, + "L": 46, + "M": 0, + "I": 0 + } + }, + { + "name": "diagnose-display-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 57, + "L": 57, + "M": 0, + "I": 0 + } + }, + { + "name": "diagnose-baseline-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 58, + "L": 57, + "M": 1, + "I": 0 + } + }, + { + "name": "diagnose-filename-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 81, + "L": 81, + "M": 0, + "I": 0 + } + }, + { + "name": "review-display-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 32, + "L": 0, + "M": 32, + "I": 0 + } + }, + { + "name": "review-baseline-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 29, + "L": 20, + "M": 9, + "I": 0 + } + }, + { + "name": "review-filename-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 33, + "L": 20, + "M": 13, + "I": 0 + } + }, + { + "name": "explain-display-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 83, + "L": 81, + "M": 1, + "I": 1 + } + }, + { + "name": "explain-baseline-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 53, + "L": 53, + "M": 0, + "I": 0 + } + }, + { + "name": "explain-filename-3", + "nonemptyFinal": true, + "timedOut": false, + "counts": { + "E": 53, + "L": 46, + "M": 6, + "I": 1 + } + } + ], + "inputHashes": { + "prompts": { + "baseline": "a26cb88160f3078cda6ad3bb87714eee5faa7e2a898511d971412c40e2695f69", + "filename": "fcec33296928dcb369f9b13b96dc1433cc339665abf25fcce956957df41cb906", + "display": "48f2e4816785357713fed14717847d5964583862f9ea9de8fa02bd5fee4bd354" + }, + "tasks": { + "plan": "4c010a99a3d0a3713e15fdc220bd35ecb5f7f35bd1089805159ca12a08a153ec", + "diagnose": "afe4c6646c8265e5716244566522cf0f9559585e0d3f1f1d5897f95d61075cee", + "review": "eb3d6274371135e4cfb95a987fdccc983b4fbc627e7d9d03642a6277d08f0b72", + "explain": "236e36fadefb1475ed0335532eacbd318efc7368eec7cb6b82d563f2f44a8bd4" + } + }, + "limitations": [ + "Development tasks; no holdout or weighted score.", + "A review repetition 2 timed out; no retry replaced it.", + "Two runs had host-access limitations; another read unfrozen external history.", + "Portable runner has different controls; do not pool cohorts." + ] +} diff --git a/packages/client/ui-deliverables/evals/file-references/rubric.md b/packages/client/ui-deliverables/evals/file-references/rubric.md new file mode 100644 index 0000000000..e09d8bb89b --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/rubric.md @@ -0,0 +1,75 @@ +--- +description: "Frozen file-reference eligibility, label judgments, and original-task checks for prompt maintainers." +kind: "package-reference" +--- + +# File-reference evaluation rubric + +## Summary + +These checks preserve the user-calibrated R3 rules and four original tasks. Task coverage, supported factual contradictions, unresolved claims, linking, and presentation are separate observations. No weighted score, character cutoff, significance claim, or universal-winner threshold is defined. + +## Table of Contents + +- [References and labels](#references-and-labels) +- [Original tasks](#original-tasks) +- [Review records](#review-records) +- [GUI and experiment limits](#gui-and-experiment-limits) + +## References and labels + +Count every reader-facing mention of an identifiable existing file, including repeats, headings, lists, tables, aliases, and bare filenames in inline code. Commands, configuration expressions, code blocks, directories, globs, hypothetical files, and unverified candidates do not enter the verified denominator. Record exclusions and uncertainty with their reasons; false existence claims remain factual issues. + +E is eligible mentions, L valid links, M missing links, and I invalid links: E = L + M + I. Zero E is inactive, never 100%. A timed-out attempt remains a failure; an absent answer has no content denominator. Keep all first attempts and report missing reviews explicitly. + +One filename followed by plain line numbers is one occurrence. Repeating only a plain line number creates no additional linking obligation. An actual numeric-only Markdown file link counts once. Full workspace-relative and absolute targets are valid; known-line anchors use `#L24` or `#L24-L30`. A displayed colon suffix is not a DSH destination anchor. Check parser acceptance, intended file identity, existence, and range separately from whether the cited text supports the claim. + +Labels may use a filename, short suffix, clear alias, or contextual numeric reference. Prefer an identifiable basename over its full repository path. Judge unnecessary directories and same-basename ambiguity in visible nearby context; hidden URLs and tooltips do not establish visible clarity. A basename need not be globally unique. Keep composite README references, generic groups, and unresolved aliases outside decisive counts, with sensitivity bounds where useful. + +Report full-path frequency, unnecessary qualifiers, label lengths, distinct files, numeric labels, malformed targets, wrong-file targets, and broken ranges separately. Exclude numeric-only and explicit line-reference labels from filename/alias lengths. Count unlinked full-path mentions too, so missing links cannot masquerade as shorter labels. Use the lower middle observation for even-sample medians and the ceiling rank for P90, matching the historical report. + +## Original tasks + +Each criterion receives addressed, omitted, contradicted, or unverified, with output spans and source evidence. Covering a topic does not establish correctness. Suggested edits do not violate read-only scope. No requirement to execute tests or produce findings is added to a task that asks for inspection. + +| ID | Requirement | +| --- | --- | +| P1 | Minimal Buffer support grounded in implementation, retaining string support. | +| P2 | Address implementation, tests, documentation, and callers; evidence may show a surface needs no edit. | +| P3 | Explain compatibility without requiring an unsupported overload or API design. | +| P4 | Give verification appropriate to the proposed change. | +| P5 | Read-only analysis, no subagents, assessed from execution evidence. | +| D1 | Explain the stale-version sequence and usable investigation steps. | +| D2 | Give the supported retry procedure and current basis for retry. | +| D3 | Explain restored-session differences from source evidence. | +| D4 | Identify relevant implementation, tests, documentation, and useful investigation files. | +| D5 | Invocation examples are optional; supplied examples must agree with implementation. | +| D6 | Read-only analysis, no subagents, assessed from execution evidence. | +| R1 | Inspect or discuss the implementation. | +| R2 | Inspect or discuss the tests. | +| R3 | Inspect README and documented expectations. | +| R4 | Give supported findings or explicitly state no issue was confirmed. | +| R5 | Explain the behaviors checked beyond a bare assertion of correctness. | +| R6 | Verify material findings against preserved source; distinguish confirmed issues, limitations, and speculation. | +| R7 | Read-only analysis, no subagents, assessed from execution evidence. | +| X1 | Explain CLI task origin and entry into Session execution. | +| X2 | Explain source-supported model selection inputs and defaults. | +| X3 | Explain working-directory selection. | +| X4 | Explain output timing relative to execution and completion. | +| X5 | Explain failure handling without unsupported recovery promises. | +| X6 | Explain Session resume and relevant task/model/directory interactions. | +| X7 | Connect the topics into a coherent Session lifecycle account. | +| X8 | Verify material factual claims and citations against preserved source. | +| X9 | Read-only analysis, no subagents, assessed from execution evidence. | + +## Review records + +For each first answer, review the complete text and retain an occurrence ledger containing line, column, text, identified file, L/M/I classification, exclusions, and ambiguity. Record task-criterion observations and source-supported contradictions separately. Reviewers must not infer E by counting only Markdown links. A self-review is supplemental evidence, not authoritative scoring. + +Create `annotation.json` beside a reviewed run's `final.md`, with `finalSha256`, `counts` containing E/L/M/I, and the occurrence/task ledgers. The summarizer verifies the answer hash and arithmetic; it does not validate human identity judgments or semantic claims. Missing annotations remain unreviewed. Historical observations contain aggregate counts imported from the original audit; their full raw ledgers remain in the private experiment archive. + +## GUI and experiment limits + +Review scanability, informative labels, same-basename clarity, visible/clickable links, correct destinations, wrapping, clipping, tables, line-number placement, and retained task explanation. Match viewport, theme, and zoom across prompts; inspect raw rendered answers without rewriting them. Screenshots alone do not verify clicking. Overall attractiveness and acceptable density require human preference, with no numeric beauty score. + +Freeze exact tasks, treatments, permitted resources, and bounds before execution. Keep evaluator material outside the task workspace. Run three fresh repetitions to describe variance; preserve failures and never replace them with silent retries. Known development tasks are not holdouts. An inactive formatting branch remains unverified. Later colon-label revisions and guided GUI demos are separate evidence, not new scores for the original A/B/C cohort. diff --git a/packages/client/ui-deliverables/evals/file-references/run.py b/packages/client/ui-deliverables/evals/file-references/run.py new file mode 100644 index 0000000000..945be8359b --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/run.py @@ -0,0 +1,174 @@ +"""Run preserved file-reference tasks through built dsh profiles on POSIX hosts.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import signal +import subprocess +import tempfile +import time + +OWNER = Path(__file__).resolve().parent +REPO = OWNER.parents[4] + + +def digest(path): + """Hash exact input or output bytes without parsing their contents.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def execute(command, workspace, task, slot, timeout): + """Preserve first output and reap the owned process group, including on interruption.""" + started = time.monotonic() + timed_out = False + interrupted = False + with task.open('rb') as stdin, (slot / 'stdout.jsonl').open('xb') as stdout, (slot / 'stderr.txt').open('xb') as stderr: + child = subprocess.Popen(command, cwd=workspace, stdin=stdin, stdout=stdout, + stderr=stderr, start_new_session=True) + try: + child.wait(timeout=timeout) + except subprocess.TimeoutExpired: + timed_out = True + except KeyboardInterrupt: + interrupted = True + finally: + # Terminate descendants that inherit the launcher process group. + try: + os.killpg(child.pid, signal.SIGTERM) + except ProcessLookupError: + pass + try: + child.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(child.pid, signal.SIGKILL) + child.wait() + # A descendant may ignore TERM after the leader exits; never leave it running. + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + deadline = time.monotonic() + 5 + teardown_error = None + while True: + try: + os.killpg(child.pid, 0) + except ProcessLookupError: + break + if time.monotonic() >= deadline: + teardown_error = 'Owned process group did not finish teardown' + break + time.sleep(.01) + finals = [] + output_error = None + for line in (slot / 'stdout.jsonl').read_text().splitlines(): + try: + event = json.loads(line) + except json.JSONDecodeError: + output_error = 'stdout contains a non-JSON line; inspect preserved output' + break + if event.get('type') == 'final': + finals.append(event['text']) + final = finals[-1] if finals else '' + if final.strip(): + (slot / 'final.md').write_text(final + '\n') + result = {'exitCode': child.returncode, 'timedOut': timed_out, + 'interrupted': interrupted, 'outputError': output_error, 'teardownError': teardown_error, 'durationSeconds': time.monotonic() - started, + 'nonemptyFinal': bool(final.strip()), + 'finalSha256': digest(slot / 'final.md') if final.strip() else None} + (slot / 'execution.json').write_text(json.dumps(result, indent=2) + '\n') + return result + + +def main(): + """Freeze inputs before serial execution; stop at the first failed attempt.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--variant', nargs='+', choices=['baseline', 'filename', 'display', 'a-colon'], default=['baseline', 'filename', 'display']) + parser.add_argument('--case', nargs='+', choices=['plan', 'diagnose', 'review', 'explain'], default=['plan', 'diagnose', 'review', 'explain']) + parser.add_argument('--repetitions', type=int, default=3) + parser.add_argument('--timeout', type=float, default=480) + parser.add_argument('--model', required=True, help='Explicit DeepSeek model id, recorded with every run') + parser.add_argument('--env-file', type=Path, help='Normal DSH .env source; values are never read by this runner') + parser.add_argument('--output', type=Path, help='New directory under repository .artifacts/') + parser.add_argument('--prepare-only', action='store_true', help='Freeze the cohort without calling the API') + args = parser.parse_args() + if os.name != 'posix': + parser.error('Process-group cleanup requires a POSIX host') + if args.repetitions < 1 or not 0 < args.timeout <= 480: + parser.error('Use positive repetitions and a timeout no greater than 480 seconds') + if len(set(args.variant)) != len(args.variant) or len(set(args.case)) != len(args.case): + parser.error('Cases and variants must be unique') + launcher = REPO / 'apps/cli/lib/bin.js' + if not launcher.is_file(): + parser.error('Build this checkout with pnpm run build first') + root = REPO / '.artifacts/file-reference-evals' + root.mkdir(parents=True, exist_ok=True) + output = args.output.resolve() if args.output else Path(tempfile.mkdtemp(prefix='run-', dir=root)) + if args.output: + if not output.is_relative_to(REPO / '.artifacts'): + parser.error('--output must be inside repository .artifacts/') + output.mkdir(mode=0o700) + output.chmod(0o700) + env_args = ['--env-file=' + str(args.env_file.resolve())] if args.env_file else [] + revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip() + archive = output / 'source.tar' + with archive.open('xb') as target: + subprocess.run(['git', 'archive', 'HEAD'], cwd=REPO, stdout=target, check=True) + frozen = output / 'inputs' + frozen.mkdir() + for folder in ['cases', 'prompts']: + shutil.copytree(OWNER / folder, frozen / folder) + shutil.copyfile(OWNER / 'treatment.mjs', frozen / 'treatment.mjs') + shutil.copyfile(OWNER / 'rubric.md', frozen / 'rubric.md') + names = [f'{case}-{variant}-{repeat}' for repeat in range(1, args.repetitions + 1) + for case in args.case for variant in args.variant] + manifest = {'sourceRevision': revision, 'launcherSha256': digest(launcher), + 'sourceArchiveSha256': digest(archive), 'runs': names, + 'model': args.model, 'reasoningEffort': 'high', 'timeoutSeconds': args.timeout, + 'preparedOnly': args.prepare_only, 'cohort': 'portable-owner-eval', + 'inputs': {str(p.relative_to(frozen)): digest(p) for p in frozen.rglob('*') if p.is_file()}} + (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') + print(output, flush=True) + if args.prepare_only: + return + for name in names: + case, variant = name.rsplit('-', 1)[0].split('-', 1) + slot = output / name + slot.mkdir() + workspace = slot / 'workspace' + workspace.mkdir() + subprocess.run(['tar', '-xf', str(archive), '-C', str(workspace)], check=True) + # Evaluator instructions and historical scores are not task inputs. + evals = workspace / OWNER.relative_to(REPO) + if evals.exists(): + shutil.rmtree(evals) + before = {str(p.relative_to(workspace)): digest(p) for p in workspace.rglob('*') if p.is_file()} + home = slot / 'home' + home.mkdir() + (home / 'settings.yaml').write_text(json.dumps({'agent-default-model': { + 'provider': 'deepseek-official', 'model': args.model, 'reasoningEffort': 'high'}})) + patch = [ + {'id': 'sandbox-policy', 'config': {'mode': 'read-only'}}, + {'id': 'approval', 'config': {'policy': 'never'}}, + {'id': 'agent-instructions', 'config': {'maxBytes': 65536, 'projectRootMarkers': ['pnpm-workspace.yaml'], 'dshHome': str(slot / 'agents')}}, + {'insert': [{'id': 'file-reference-treatment', 'name': str(frozen / 'treatment.mjs'), + 'config': {'promptFile': str(frozen / 'prompts' / (variant + '.txt'))}}]}, + ] + overlay = slot / 'overlay.yml' + overlay.write_text(json.dumps(patch)) + command = ['env', 'DSH_HOME=' + str(home), 'DSH_AGENTS_HOME=' + str(slot / 'agents'), + 'node', *env_args, str(launcher), '--profile', 'headless', '--patch', str(overlay), '--json', '-'] + task = frozen / 'cases' / (case + '.txt') + result = execute(command, workspace, task, slot, args.timeout) + after = {str(p.relative_to(workspace)): digest(p) for p in workspace.rglob('*') if p.is_file()} + changed = sorted(k for k in before.keys() | after.keys() if before.get(k) != after.get(k)) + (slot / 'workspace-audit.json').write_text(json.dumps({'unchanged': not changed, 'changedFiles': changed}, indent=2) + '\n') + print(json.dumps({'run': name, **result, 'workspaceUnchanged': not changed}), flush=True) + if result['exitCode'] != 0 or result['timedOut'] or result['interrupted'] or result['outputError'] or result['teardownError'] or not result['nonemptyFinal'] or changed: + (output / 'stop.json').write_text(json.dumps({'failed': name, 'unstarted': names[names.index(name) + 1:]}, indent=2) + '\n') + raise SystemExit(1) + + +if __name__ == '__main__': + main() diff --git a/packages/client/ui-deliverables/evals/file-references/summarize.py b/packages/client/ui-deliverables/evals/file-references/summarize.py new file mode 100644 index 0000000000..e14aba4516 --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/summarize.py @@ -0,0 +1,81 @@ +"""Aggregate reviewed occurrence counts without treating unreviewed answers as passes.""" +import argparse +import json +from pathlib import Path +from run import digest + + +def observations(source): + """Read historical records or join fresh outputs with hash-bound human annotations.""" + if source.is_file(): + return json.loads(source.read_text())['runs'] + manifest = json.loads((source / 'manifest.json').read_text()) + rows = [] + for name in manifest['runs']: + slot = source / name + if not (slot / 'execution.json').exists(): + if (slot / 'stdout.jsonl').exists(): + raise ValueError(f'{name}: incomplete execution record; inspect retained output') + rows.append({'name': name, 'unstarted': True}) + continue + row = {'name': name, **json.loads((slot / 'execution.json').read_text()), 'counts': None} + annotation = slot / 'annotation.json' + if annotation.exists(): + review = json.loads(annotation.read_text()) + if not row['nonemptyFinal'] or not review['finalSha256'] == row['finalSha256'] == digest(slot / 'final.md'): + raise ValueError(f'{name}: annotation does not match the preserved final answer') + row['counts'] = review['counts'] + rows.append(row) + return rows + + +def aggregate(rows): + """Keep execution and content denominators separate; reject invalid ledgers.""" + result = {} + seen = set() + for row in rows: + name = row['name'] + if name in seen: + raise ValueError(f'Duplicate attempt: {name}') + seen.add(name) + variant = name.rsplit('-', 1)[0].split('-', 1)[1] + group = result.setdefault(variant, dict(planned=0, attempted=0, completed=0, timedOut=0, + audited=0, unreviewed=0, perfect=0, E=0, L=0, M=0, I=0)) + group['planned'] += 1 + if row.get('unstarted'): + continue + group['attempted'] += 1 + group['completed'] += int(row['nonemptyFinal']) + group['timedOut'] += int(row['timedOut']) + counts = row.get('counts') + if counts is None: + group['unreviewed'] += int(row['nonemptyFinal']) + continue + if not row['nonemptyFinal']: + raise ValueError(f'{name}: an absent final answer cannot have content counts') + if any(type(counts.get(k)) is not int or counts[k] < 0 for k in ['E', 'L', 'M', 'I']): + raise ValueError(f'{name}: E/L/M/I must be nonnegative integers') + if counts['E'] != counts['L'] + counts['M'] + counts['I']: + raise ValueError(f'{name}: E must equal L + M + I') + group['audited'] += 1 + group['perfect'] += int(counts['E'] > 0 and counts['M'] == counts['I'] == 0) + for key in ['E', 'L', 'M', 'I']: + group[key] += counts[key] + return result + + +def main(): + """Print the same compact result table for archived and newly reviewed cohorts.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source', type=Path, help='Run directory or historical observations.json') + args = parser.parse_args() + groups = aggregate(observations(args.source)) + print('| Prompt | Answers / attempts / planned | Audited | Unreviewed | L/E | Missing / invalid | Perfect | Timeouts |') + print('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') + for variant, row in groups.items(): + rate = f"{row['L']}/{row['E']} ({row['L'] / row['E']:.1%})" if row['E'] else 'inactive' + print(f"| {variant} | {row['completed']}/{row['attempted']}/{row['planned']} | {row['audited']} | {row['unreviewed']} | {rate} | {row['M']} / {row['I']} | {row['perfect']} | {row['timedOut']} |") + + +if __name__ == '__main__': + main() diff --git a/packages/client/ui-deliverables/evals/file-references/test_eval.py b/packages/client/ui-deliverables/evals/file-references/test_eval.py new file mode 100644 index 0000000000..0db2f546cc --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/test_eval.py @@ -0,0 +1,115 @@ +"""Keyless checks for retained failures and honest evaluation denominators.""" +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from run import execute, digest +from summarize import aggregate, observations + + +class EvaluationTests(unittest.TestCase): + def test_missing_audits_and_unstarted_slots_are_not_passes(self): + rows = [ + {'name': 'plan-baseline-1', 'nonemptyFinal': True, 'timedOut': False}, + {'name': 'plan-baseline-2', 'nonemptyFinal': False, 'timedOut': True}, + {'name': 'plan-baseline-3', 'unstarted': True}, + ] + self.assertEqual(aggregate(rows)['baseline'], dict(planned=3, attempted=2, completed=1, + timedOut=1, audited=0, unreviewed=1, perfect=0, E=0, L=0, M=0, I=0)) + + def test_interrupted_record_is_not_an_unstarted_slot(self): + with tempfile.TemporaryDirectory() as root: + source = Path(root) + (source / 'manifest.json').write_text(json.dumps({'runs': ['plan-baseline-1']})) + slot = source / 'plan-baseline-1' + slot.mkdir() + (slot / 'stdout.jsonl').write_text('{}\n') + with self.assertRaisesRegex(ValueError, 'incomplete execution record'): + observations(source) + + def test_bad_denominators_and_duplicate_attempts_fail(self): + row = {'name': 'plan-a-colon-1', 'nonemptyFinal': True, 'timedOut': False, + 'counts': {'E': 2, 'L': 1, 'M': 0, 'I': 0}} + with self.assertRaisesRegex(ValueError, 'E must equal'): + aggregate([row]) + row['counts']['M'] = 1 + self.assertEqual(aggregate([row])['a-colon']['M'], 1) + with self.assertRaisesRegex(ValueError, 'Duplicate'): + aggregate([row, row]) + row['counts']['E'] = True + with self.assertRaisesRegex(ValueError, 'nonnegative integers'): + aggregate([row]) + + def test_frozen_inputs_match_original_hashes(self): + owner = Path(__file__).parent + hashes = json.loads((owner / 'results/2026-09-16/observations.json').read_text())['inputHashes'] + for kind, expected in hashes.items(): + folder = 'cases' if kind == 'tasks' else 'prompts' + for name, value in expected.items(): + self.assertEqual(digest(owner / folder / (name + '.txt')), value) + + def test_descendant_ignoring_term_is_reaped(self): + with tempfile.TemporaryDirectory() as root: + slot = Path(root) + task = slot / 'task.txt' + task.write_text('task') + code = 'import json, subprocess, sys, time; child=subprocess.Popen([sys.executable,"-c","import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); time.sleep(60)"]); print(json.dumps({"pid":child.pid}),flush=True); time.sleep(60)' + result = execute([sys.executable, '-c', code], slot, task, slot, .3) + self.assertTrue(result['timedOut']) + pid = json.loads((slot / 'stdout.jsonl').read_text())['pid'] + with self.assertRaises(ProcessLookupError): + os.kill(pid, 0) + + def test_historical_counts_reproduce_report(self): + source = Path(__file__).parent / 'results/2026-09-16/observations.json' + groups = aggregate(observations(source)) + for variant, expected in [('baseline', (476, 453, 23, 0)), + ('filename', (585, 539, 45, 1)), ('display', (592, 467, 124, 1))]: + self.assertEqual(tuple(groups[variant][k] for k in ['E', 'L', 'M', 'I']), expected) + self.assertEqual(groups['baseline']['timedOut'], 1) + + def test_annotation_cannot_reclassify_changed_first_answer(self): + with tempfile.TemporaryDirectory() as root: + source = Path(root) + (source / 'manifest.json').write_text(json.dumps({'runs': ['plan-baseline-1']})) + slot = source / 'plan-baseline-1' + slot.mkdir() + final = slot / 'final.md' + final.write_text('first answer') + original = digest(final) + (slot / 'execution.json').write_text(json.dumps({'nonemptyFinal': True, 'timedOut': False, 'finalSha256': original})) + (slot / 'annotation.json').write_text(json.dumps({'finalSha256': original, 'counts': {'E': 1, 'L': 1, 'M': 0, 'I': 0}})) + self.assertEqual(aggregate(observations(source))['baseline']['L'], 1) + final.write_text('changed answer') + with self.assertRaisesRegex(ValueError, 'does not match'): + observations(source) + + def test_malformed_output_retains_execution_failure(self): + with tempfile.TemporaryDirectory() as root: + slot = Path(root) + task = slot / 'task.txt' + task.write_text('task') + result = execute([sys.executable, '-c', 'print("not json")'], slot, task, slot, 10) + self.assertIsNotNone(result['outputError']) + self.assertTrue((slot / 'execution.json').exists()) + + def test_timeout_preserves_output_and_reaps_process(self): + with tempfile.TemporaryDirectory() as root: + slot = Path(root) + task = slot / 'task.txt' + task.write_text('task') + result = execute([sys.executable, '-c', 'import time; print("{}", flush=True); time.sleep(60)'], + slot, task, slot, .2) + self.assertTrue(result['timedOut']) + self.assertFalse(result['nonemptyFinal']) + self.assertIsNotNone(result['exitCode']) + self.assertEqual((slot / 'stdout.jsonl').read_text(), '{}\n') + self.assertEqual(json.loads((slot / 'execution.json').read_text())['timedOut'], True) + with self.assertRaises(FileExistsError): + execute([sys.executable, '-c', 'pass'], slot, task, slot, 1) + + +if __name__ == '__main__': + unittest.main() diff --git a/packages/client/ui-deliverables/evals/file-references/treatment.mjs b/packages/client/ui-deliverables/evals/file-references/treatment.mjs new file mode 100644 index 0000000000..d8b288a04c --- /dev/null +++ b/packages/client/ui-deliverables/evals/file-references/treatment.mjs @@ -0,0 +1,14 @@ +/** Frozen evaluation treatment registered through the ordinary system-prompt plugin. */ +import { readFileSync } from 'node:fs' + +export const name = 'file-reference-evaluation' +export const inject = ['systemPrompt'] + +export function apply(ctx, config) { + ctx.systemPrompt.section({ + name: 'evaluation:file-references', + order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'), + text: readFileSync(config.promptFile, 'utf8'), + interpolate: false, + }) +} From fa2abdd6f17f572b47ab44883603b8518c12952c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 16:39:53 +0800 Subject: [PATCH 017/225] fix(web): reconcile stale blank session hints from projections --- ...nd-projection-owned-client-state.i18n.yaml | 4 +- ...tions-and-projection-owned-client-state.md | 2 +- ...ns-and-projection-owned-client-state.zh.md | 2 +- apps/web/tests/workspace-management.e2e.ts | 56 +++++++++++++++++-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../src/client/sessions/manager.ts | 14 ++++- .../src/client/sessions/session.ts | 9 ++- .../tests/manager.client.spec.ts | 55 ++++++++++++++++++ .../new-session.expected.md | 13 +++++ 11 files changed, 145 insertions(+), 18 deletions(-) create mode 100644 snapshots/web/workspace-management/new-session.expected.md diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 3c5fbdae42..2418f0b77d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: ff5628c3051086159d532b71ca3e98c1b41a51f8 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: e433df28fec86564fe1c1e9e88686a5abe6b9549 +2026-08-25-session-observations-and-projection-owned-client-state.md: 88a4cbed008ebbb8f6733c5c55b68b9781309820 +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 9dd6ce9e2243b535c614cd5ad0862384a4536623 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index ff5628c305..88a4cbed00 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an ### Domain applications -- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. +- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. A stale list response cannot override a newer history or control projection. - **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header. - **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session. - **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index e433df28fe..9dd6ce9e22 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ### 领域应用 -- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。 +- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。 Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。过期列表响应不能覆盖较新的 history 或 control projection。 - **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。 - **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。 - **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index bec77985d3..6525d6fd2d 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -17,7 +17,7 @@ import { join, sep } from 'node:path' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session' import { logPath } from '../../../packages/session/session-persistence-jsonl/src/format.ts' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -754,8 +754,56 @@ describe('web e2e: workspace management (create / rename / grouping / hover affo it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { expect(tripwire.warnings).toEqual([]) - // The directory-browser aria golden is this spec's one owned artifact; - // the seed it reuses is owned (and inventory-guarded) by seeded-history. - await assertFixtureInventory(SNAPSHOT_DIR, ['.gitkeep', 'directory-browser.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + '.gitkeep', 'directory-browser.expected.md', 'new-session.expected.md', + ]) + }) +}) + +describe('web e2e: New Session after an outdated blank cache', () => { + it('opens a fresh conversation instead of reusing the recorded conversation', async () => { + const scaffold = await launchWebScaffold({}) + let browser: Browser | undefined + try { + const now = Date.UTC(2026, 8, 16) + const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'new-session-stale-blank', + undefined, { createdAt: now - 60_000 }) + const stored = await scaffold.ctx.sessionPersistence.stat(id) + if (stored === undefined) throw new Error('seeded Session is missing') + // A durable log may advance after its last blank projection checkpoint. + const blank = scaffold.ctx.sessions.prepare(id, { + eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0), + }) + await scaffold.ctx.sessionProjectionCache.write(blank) + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, 'New session regression') + await workspace.attachSession(id) + browser = await chromium.launch() + const page = await newEnglishPage(browser) + await page.clock.setFixedTime(now) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'web-e2e-new-session-stale-blank')) + + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor() + const selected = page.locator('[role="treeitem"][aria-selected="true"]') + await expect.poll(() => selected.innerText()).not.toBe('New Session') + const previous = (await selected.innerText()).split('\n')[0]! + const before = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) + + await page.getByRole('button', { name: 'New session', exact: true }).last().click() + await expect.poll(() => selected.innerText()).toBe('New Session') + await page.getByRole('tab', { name: 'Chat', exact: true }).waitFor({ state: 'hidden' }) + await expect.poll(() => workspace.sessionIds.length).toBe(2) + expect(workspace.sessionIds).toContain(id) + await page.getByRole('treeitem').filter({ hasText: previous }).waitFor() + const after = await captureStableAria(page, '[role="tree"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'new-session.expected.md'), + `Before New Session\n${before}\nAfter New Session\n${after}`, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } }) }) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 54f3e511fd..f92d06f911 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 772cec4f15da7150617b847824070b27749394ae -README.zh.md: 0f04e539d3eb389273b7b7950acf2dadcc5410bc +README.md: ac83c31a83afb02f0b4afc3b9d35170b0eeb3b5f +README.zh.md: fc2d26b793e1888a64ff043bb2ba3043acded728 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 772cec4f15..ac83c31a83 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -30,6 +30,8 @@ The Client journal validates exact V3 event envelopes before publishing follow s Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application. +Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. An opened conversation therefore cannot be reused by New Session because an older list response still marks it blank. + Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer list, history, or live values. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 0f04e539d3..fc2d26b793 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -30,6 +30,8 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证精 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。 +Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 + Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 list、history 或 live 值。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 4480402c4d..3439494fa7 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -332,9 +332,13 @@ export class SessionManager { let store = this.projectionStores.get(sessionId) if (store === undefined) { store = new ProjectionValueStore() - // List rows project off store keys (title); any-key changes re-enter - // the manager's own batched rebuild channel. - store.subscribeAny(() => { this.notifier.markDirty() }) + const projections = store + store.subscribeAny(() => { + if (projections.values().sessionListMetadata?.blank === false) { + this.sessions.get(sessionId)?.handleBlank(false) + } + this.notifier.markDirty() + }) this.projectionStores.set(sessionId, store) } return store @@ -900,8 +904,12 @@ export class SessionManager { const projectionStore = this.projectionStores.get(summary.sessionId) const title = projectionStore?.get('title') const projectionValues = projectionStore?.values() + const metadata = projectionValues?.sessionListMetadata return { ...summary, + // Cached list hints can precede a history opening or control update. + blank: summary.blank && metadata?.blank !== false, + updatedAt: Math.max(summary.updatedAt, metadata?.lastPromptAt ?? 0), ...(typeof title === 'string' && title !== '' ? { title } : {}), ...(projectionValues === undefined ? {} : { projectionValues }), } diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 3c401cc3e7..2a889c9ce3 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -535,13 +535,12 @@ export class Session implements SessionFace { } /** - * Blank-bit relay from the authoritative summary source (`session.list` and - * `api-session/added`). Monotone: once any signal (local first send, - * running flip, an earlier summary) cleared it, a stale true never - * re-blanks. - * @param blank - the summary's derived empty-log bit. + * Relay list blankness without overriding a started conversation established + * by the current projection, a local prompt, or running state. + * @param blank - whether the list or projection reports an unstarted conversation. */ handleBlank(blank: boolean): void { + blank = blank && this.projections.values().sessionListMetadata?.blank !== false if (blank === this.blankBit) return if (blank && (this.promptAttempted || this.running)) return this.blankBit = blank diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index c33a4a28c0..cc72ac7ab9 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -64,6 +64,61 @@ describe('SessionManager instances', () => { }) describe('list lifecycle', () => { + it('uses metadata received before the list without reblanking started rows', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + try { + manager.handleControlFrame({ type: 'projection', sessionId: S1, + key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 }) + manager.handleControlFrame({ type: 'projection', sessionId: S2, + key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 }) + remote.session.list.mockResolvedValue(ok({ items: [ + summary(S1, { blank: true }), summary(S2), + ] as never[] })) + await manager.refreshList() + expect(manager.getListSnapshot().items.map(item => item.blank)).toEqual([false, false]) + expect(manager.get(S1).getSnapshot().blank).toBe(false) + expect(manager.get(S2).getSnapshot().blank).toBe(false) + manager.handleControlFrame({ type: 'projection', sessionId: S1, + key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 7 }) + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 }) + } finally { + await manager.dispose() + } + }) + + it('keeps an opened conversation out of blank-session reuse after a stale list refresh', async ({ mock, remote }) => { + const stale = { + ...summary(S1, { blank: true, cwd: '/workspace' }), + projections: { asOfSeq: 2, values: { + sessionListMetadata: { blank: true, lastPromptAt: null }, + } }, + } + remote.session.list.mockResolvedValue(ok({ items: [stale] as never[] })) + const manager = makeManager(mock, remote) + try { + await manager.refreshList() + const session = manager.get(S1) + expect(session.getSnapshot().blank).toBe(true) + + // History opening can be newer than the metadata-only list cache. + session.projections.seed({ asOfSeq: SessionSeq(61), values: { + sessionListMetadata: { blank: false, lastPromptAt: 1200 }, + } }) + await vi.waitFor(() => { + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 }) + expect(session.getSnapshot().blank).toBe(false) + }) + + await manager.refreshList() + expect(manager.getListSnapshot().items[0]).toMatchObject({ blank: false, updatedAt: 1200 }) + expect(session.getSnapshot().blank).toBe(false) + manager.handleSessionAdded(stale) + expect(session.getSnapshot().blank).toBe(false) + } finally { + await manager.dispose() + } + }) + it('single-flights refreshList and preserves the Host baseline order', async ({ mock, remote }) => { const gate = Promise.withResolvers>>() remote.session.list.mockReturnValue(gate.promise) diff --git a/snapshots/web/workspace-management/new-session.expected.md b/snapshots/web/workspace-management/new-session.expected.md new file mode 100644 index 0000000000..c5b1a98754 --- /dev/null +++ b/snapshots/web/workspace-management/new-session.expected.md @@ -0,0 +1,13 @@ +Before New Session +- tree "Sessions": + - treeitem "New session regression" [expanded]: + - img + - text: New session regression + - treeitem "Use the read tool twice now" [selected] +After New Session +- tree "Sessions": + - treeitem "New session regression" [expanded]: + - img + - text: New session regression + - treeitem "New Session" [selected] + - treeitem "Use the read tool twice now" From 17dd9f0e29511c7e3e9f942b6de4b864cf47065f Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Wed, 16 Sep 2026 19:41:08 +0800 Subject: [PATCH 018/225] docs(web): propose consistent trajectory attachment presentation Refs #4356 --- .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 4 + .../2026-08-26-generic-file-upload.zh.md | 4 + ...ajectory-attachment-presentation.i18n.yaml | 6 ++ ...9-15-trajectory-attachment-presentation.md | 89 +++++++++++++++++++ ...5-trajectory-attachment-presentation.zh.md | 89 +++++++++++++++++++ 6 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml create mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md create mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 56fb8a76d2..93cf5d2d20 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: 6d0d2901b14c44930b4342292d317ebe03705730 -2026-08-26-generic-file-upload.zh.md: 2d78811c24ba1a801d43f21f4e63db5646b0431b +2026-08-26-generic-file-upload.md: bda96caa36f27a2270971127b5e3635a7fec9fcd +2026-08-26-generic-file-upload.zh.md: 2ddfd48f1ea04ba6cfbff10224444de8d37d7a8a diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index 6d0d2901b1..bda96caa36 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -48,3 +48,7 @@ Default mode, active Plan Mode, and an active goal share ordinary queue, follow- ## Testing Unit and integration coverage pins verbatim and streamed storage, name sanitization, a declared 2.19 GiB request passing through the streaming bridge without aggregate buffering, raw and RPC wire admission, Blob and transferable `ReadableStream` carriers, bounded background-upload concurrency, progress and cancellation, cross-Session upload residency, command file-receipt submission without a second byte read, native and PTC projection under read-only and workspace-write permissions, upload staging, ordered queue and steer submission, queue-to-steer conversion, pending echo retirement and failure recovery, active plan and goal intake, `/plan` and `/goal` mixed attachments, subagent refusal, composer and Chat attachment layouts, and Trajectory file summaries. The keyless `file-upload-round` snapshot records the browser upload, model file read, and rendered answer. + +## Related + +The [Trajectory attachment presentation proposal](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md) covers ledger counts and inspector attachment layouts. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 2d78811c24..2ddfd48f1e 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -48,3 +48,7 @@ Status: implemented ## 测试 单元与集成覆盖原样与流式存储、文件名清洗、声明为 2.19 GiB 的请求通过流式 bridge 且不发生总量缓冲、原始字节与 RPC wire 准入、Blob 与可转移 `ReadableStream` 载体、后台上传并发上限、进度与取消、跨 Session 上传常驻、命令通过文件凭证提交且不再次读取字节、read-only 与 workspace-write 权限下的 native 和 PTC 投影、上传暂存、有序 queue 与 steer 提交、队列转 steer、pending 回显退休与失败恢复、active plan 和 goal 的附件入口、`/plan` 与 `/goal` 混合附件、子代理拒绝、composer 与 Chat 附件布局,以及 Trajectory 文件摘要。keyless `file-upload-round` 快照记录浏览器上传、模型读取文件与答案呈现。 + +## 相关记录 + +[Trajectory 附件展示提案](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)涵盖列表计数与详情面板附件布局。 diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml new file mode 100644 index 0000000000..f7fafc2b16 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md +2026-09-15-trajectory-attachment-presentation.md: 23ce4145f0a90e0e03a90ca4c335bb2cb3598785 +2026-09-15-trajectory-attachment-presentation.zh.md: cbd507235e28a523360bd47a105acbb59963418f diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md new file mode 100644 index 0000000000..23ce4145f0 --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -0,0 +1,89 @@ +# Agent Note: Consistent Trajectory attachment presentation + +Status: proposed + +English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) + +## Problem + +A user message can contain text, images, and ordinary files, but Trajectory exposes different subsets of that message across its ledger and inspector. The [ledger projection](../../../../packages/client/ui-trajectory/src/client/layout.ts) omits the image count when text is present. The [inspector renderer](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx) displays images without ordinary file cards in Preview and mixes file JSON with rendered images in Raw. Readers cannot reliably identify the complete attachment set without switching views. + +The Summary tab embeds `MarkdownRecordContent` with compact presentation, while Preview uses the same component without that option. Attachment changes must preserve that shared renderer so both entry points expose the same content and actions. + +## Proposal + +Deliver one Trajectory presentation change covering the ledger, Summary, Preview, and Raw. The proposed behavior is not implemented by this document; Issue #4356 and its linked PR track execution status and completed evidence. + +### Display behavior + +| Location | Proposed behavior | +|---|---| +| Ledger | Show separate nonzero image and ordinary-file counts beside the text excerpt, including messages that contain text. Keep each record on one compact line. | +| Summary and Preview | Render the message text followed by one ordered attachment list. Use the same attachment renderer in both tabs; Summary may retain its compact spacing and scroll container. | +| Attachment list | Use equal-width compact rows with consistent height, spacing, and corners. Place a file-type icon or contained image thumbnail on the left and the recorded name and available metadata on the right. Preserve attachment occurrence order, including repeated references. | +| Image interaction | Fit the whole image inside its thumbnail without cropping. Open the existing large-image viewer on activation; preserve keyboard access, loading feedback, and retry after a failed read. | +| Raw | Preserve original content-block order and boundaries. Give image and file blocks consistent, initially collapsed metadata disclosures, with complete recorded fields available on expansion. Keep text blocks as unrendered text; generated display labels do not become raw fields. | + +Use the recorded filename when available and a localized numbered image label otherwise. Show type and size only when supported by the recorded data; preserve zero-byte sizes. Long names may truncate visually, but the full name must remain available through accessible text and a tooltip. A message without text still exposes every attachment. Multiple attachments remain reachable inside the inspector's existing scroll area. + +### Implementation ownership + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns the counts, ordered attachment projection, and inspector composition. Carry typed image and file references through its view data rather than recovering file metadata by parsing JSON prepared for display. Keep complete recorded attachment fields available to Raw. + +[ui-attachment](../../../../packages/client/ui-attachment/README.md) retains image loading and viewer behavior, using the Conversation-owned per-session cache. Compose cross-package UI through slots and reuse suitable static controls from [ui-primitives](../../../../packages/client/ui-primitives/README.md), following the [client rules](../../../../packages/client/AGENTS.md). Update all consumers of any changed shared props. Keep the containment treatment specific to the requested attachment presentation so existing Chat and tool-image sizing remains covered by regression checks. + +This scope uses existing durable attachment references. It introduces no upload, storage, model-request, or Session-format change, and no ordinary-file content viewer or download action. Styles follow [Web styling](../../../../docs/web-styling.md); display text, accessibility labels, and tooltips use typed locale dictionaries. + +### Existing decisions + +The [generic file upload decision](../../implemented/feature/2026-08-26-generic-file-upload.md) remains authoritative for upload, storage, admission, and ordered message attachments. This proposal revises only its Trajectory presentation and does not fully supersede that record. The [unreadable attachment quarantine proposal](2026-08-20-attachment-read-quarantine.md) concerns model-request recovery and remains independent of thumbnail loading feedback. + +## Implementation plan + +The steps below define the delivery sequence and completion evidence. They are not a record of commands already run. Use the current [development guide](../../../../docs/development.md), [testing policy](../../../../docs/testing.md), and linked workflows when executing them. + +| Step | Work | Completion evidence | +|---|---|---| +| 1. Establish the baseline | Verify the internal repository, live base, clean worktree, dependencies, and applicable rules; use an isolated feature branch. | The repository, branch, and base are identified and unrelated changes are excluded. | +| 2. Associate an Issue | Find the existing task or create its Issue with reproduction, scope, native Type, applicable Project fields, and the acceptance criteria below. | A real Issue owns progress and provides the PR reference. | +| 3. Map consumers | Trace Trajectory attachment data, Summary/Preview composition, Raw serialization, image slots, and any shared props. | Every affected consumer and test owner is identified before interfaces change. | +| 4. Implement presentation | Preserve typed attachment data, fix counts, introduce the shared attachment list, and align Raw disclosures in that order. | All four locations expose the specified information without changing durable message data. | +| 5. Add behavior evidence | Extend projection and component tests, then the recorded mixed-upload browser scenario; cover both inspector entry points and image interactions. | Assertions reject the missing-file, missing-count, ordering, and layout regressions. | +| 6. Update documentation | Update affected README pairs, JSDoc, and the owning decision records with the implementation; record bilingual consistency. | Documentation describes the implemented behavior, and this proposal is rewritten for its final lifecycle when implementation is complete. | +| 7. Validate the outgoing diff | Apply the [pre-push check workflow](../../../skills/dsh-pre-push-checks/SKILL.md) and the client check ladder; inspect the complete diff and changed expected outputs. | Relevant local checks pass, with commands and limitations recorded accurately. | +| 8. Commit and demonstrate | Review hook changes, commit normally, build the clean committed tree, and record one isolated real-server, real-model flow under the [GIF workflow](../../../skills/record-browser-gif/SKILL.md). Push normally and verify the remote head. | The reviewed GIF names the demonstrated commit and serving conditions, and the remote branch contains that commit. | +| 9. Publish and review | Fill the [PR template](../../../../.github/pull_request_template.md), link the Issue, select live kind/area labels, and attach evidence. Check the PR preview and CI; address review on the same branch. | The current head has reviewable evidence; fixes update affected checks and the GIF when its demonstrated commit changes. | +| 10. Merge and follow up | After review, satisfy live approval and merge rules, then follow mainline checks and Issue state. | The authorized merge is verified and any remaining work has an owner; release publication follows its separate workflow. | + +### Verification coverage + +| Layer | Planned coverage | +|---|---| +| Projection | Text with one image and one file; images only; files only; attachment-only mixed messages; empty text; multiple and repeated attachment occurrences. Counts and raw fields match the input. | +| Components | Summary and Preview show the same attachment names, order, metadata, and activation behavior. Raw preserves block order and complete fields. Cover absent image names, zero-byte files, long filenames, loading, failure, and retry. | +| Assembled application | Extend [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) and its [recorded scenario](../../../../snapshots/web/file-upload-round/snapshot.yml) to inspect Summary, Preview, and Raw after the mixed upload and after reopening history. Keep the [shared image-cache check](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts). | +| Browser layout and accessibility | Verify narrow and wide inspector widths, light and dark themes, both locales, complete thumbnail containment, reachable final attachments, and keyboard activation and viewer dismissal. Keep geometry expectations beside their test owner under the [snapshot placement rules](../../../../snapshots/AGENTS.md). | +| Local checks | Run focused tests during implementation, then the required GUI and browser replay checks from the client rules, locale verification, documentation checks, and lint. Normal pre-push runs typecheck; CI owns exhaustive coverage and platform checks. | +| Demonstration | Record mixed submission, the ledger counts, Summary, Preview with image activation, and expanded Raw metadata from one real model session. Keep media outside the feature branch and attach it according to the GIF workflow. | + +## Alternatives considered + +**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments. + +**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list is the proposed default. + +**Keep file JSON and image previews together in Raw.** This preserves the existing rendering, but assigns different meanings to the same tab for two attachment kinds. Consistent metadata disclosures let readers inspect recorded fields while Preview owns the visual image presentation. + +## Acceptance criteria + +- A message containing text, one image, and one ordinary file shows both counts in the ledger and both attachments in Summary and Preview. +- Summary and Preview share one attachment renderer; each provides the same ordered attachments and image actions despite their different spacing and containers. +- Attachment-only messages, repeated references, unnamed images, zero-byte files, and long filenames remain identifiable without invented metadata. +- Images fit completely inside compact thumbnails. Keyboard and pointer users can open and dismiss the existing image viewer; a failed image load remains visible and retryable. +- Raw preserves every original content block and its order. Image and file disclosures expose all recorded attachment fields without replacing them with generated labels or downloaded file contents. +- Narrow panels, both themes, and both locales remain usable. Existing Chat/tool-image behavior and per-session image cache reuse retain regression coverage. +- The implementation includes corresponding tests, reviewed keyless browser expectations, paired documentation, and a real-model GIF tied to the demonstrated PR head. + +## Risks + +Adding file references to view data can accidentally route them through image-only consumers or lose raw fields. Trace every affected discriminant and verify mixed and repeated occurrences. Compact thumbnails reduce immediate readability of diagrams, so full-image activation must remain discoverable. Shared renderer changes can affect Chat and tool results, while Summary's scroll container can hide final rows; both need assembled-browser checks. Missing recorded metadata must remain visibly absent, and image-read failures must not hide unrelated files. Unavailable model credentials block the real-model demonstration and must be reported rather than replaced with fixture footage. diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md new file mode 100644 index 0000000000..cbd507235e --- /dev/null +++ b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -0,0 +1,89 @@ +# Agent Note: 统一 Trajectory 附件展示 + +Status: proposed + +[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 + +## 问题 + +用户消息可以包含文本、图片和普通文件,但 Trajectory 列表与详情面板展示的消息内容并不完整一致。[列表投影](../../../../packages/client/ui-trajectory/src/client/layout.ts)在消息含有文本时省略图片数量。[详情渲染器](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx)在预览中展示图片而不展示普通文件卡片,在原始内容中又把文件 JSON 与渲染后的图片混排。读者需要切换视图才能确认完整的附件集合。 + +概述标签页通过紧凑展示选项嵌入 `MarkdownRecordContent`,预览标签页使用同一组件,但不启用该选项。附件调整必须保留这一共享渲染器,使两个入口展示相同的内容和操作。 + +## 提案 + +交付一项覆盖列表、概述、预览和原始内容的 Trajectory 展示调整。本文不实现所提议的行为;执行状态和已完成的验证证据由 Issue #4356 及其关联 PR(Pull Request)记录。 + +### 展示行为 + +| 位置 | 提议的行为 | +|---|---| +| 列表 | 在文本摘要旁分别显示非零的图片数量与普通文件数量,包含有文本的消息。每条记录保持为紧凑单行。 | +| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的附件渲染器;概述可以保留紧凑间距和滚动容器。 | +| 附件列表 | 使用等宽的紧凑行,统一行高、间距和圆角。左侧放文件类型图标或完整缩放的图片缩略图,右侧放已记录的名称与可用元数据。保留每次附件出现的顺序,包括重复引用。 | +| 图片交互 | 图片完整缩放到缩略图内,不裁剪。激活时打开现有大图查看器;保留键盘访问、加载反馈和读取失败后的重试。 | +| 原始内容 | 保留原始内容块顺序与边界。图片块和文件块使用一致、默认收起的元数据折叠区,展开后显示完整记录字段。文本块保持未渲染文本;生成的显示标签不进入原始字段。 | + +有已记录文件名时使用文件名,否则为图片使用本地化的编号标签。类型与大小只在已记录数据支持时显示;保留零字节大小。长名称可以在视觉上截断,但完整名称必须能通过无障碍文本和工具提示获取。没有正文的消息仍须展示每个附件。多个附件在详情面板现有滚动区域内保持可达。 + +### 实现职责 + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。通过其视图数据传递带类型的图片与文件引用,不从为展示准备的 JSON 中反向解析文件元数据。为原始内容保留完整的已记录附件字段。 + +[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)继续负责图片加载和查看器行为,使用 Conversation 拥有的会话级缓存。通过 slot 组合跨包 UI,并复用 [ui-primitives](../../../../packages/client/ui-primitives/README.zh.md) 中适用的静态控件,遵循[客户端规则](../../../../packages/client/AGENTS.md)。共享属性发生变化时,更新全部消费方。完整缩放处理限定于本次附件展示,并通过回归检查覆盖现有 Chat 和工具图片尺寸行为。 + +本次范围使用现有持久附件引用,不改变上传、存储、模型请求或会话格式,也不增加普通文件内容查看器或下载操作。样式遵循 [Web 样式规范](../../../../docs/web-styling.zh.md);显示文字、无障碍标签和工具提示使用带类型的本地化字典。 + +### 现有决定 + +[通用文件上传决定](../../implemented/feature/2026-08-26-generic-file-upload.zh.md)继续负责上传、存储、准入和有序消息附件。本提案只调整其中的 Trajectory 展示,并不完整取代该记录。[不可读附件隔离提案](2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 + +## 实施计划 + +以下步骤定义交付顺序与完成证据,不代表命令已经执行。执行时使用当前[开发指南](../../../../docs/development.zh.md)、[测试策略](../../../../docs/testing.zh.md)和所链接的工作流。 + +| 步骤 | 工作 | 完成证据 | +|---|---|---| +| 1. 确定基线 | 核对内部仓库、实时基线、干净的 worktree、依赖和适用规则;使用隔离的功能分支。 | 仓库、分支和基线明确,未混入无关修改。 | +| 2. 关联 Issue | 查找现有任务或创建 Issue,写明复现、范围、原生 Type、适用的 Project 字段和下列验收标准。 | 真实 Issue 负责进度,并作为 PR 的引用。 | +| 3. 梳理消费方 | 跟踪 Trajectory 附件数据、概述与预览组合、原始内容序列化、图片 slot 和共享属性。 | 接口修改前,已明确全部受影响消费方与测试归属。 | +| 4. 实现展示 | 依次保留带类型的附件数据、修正计数、引入共享附件列表、统一原始内容折叠区。 | 四处位置均展示规定信息,持久消息数据不变。 | +| 5. 补行为证据 | 扩展投影与组件测试,再扩展已录制的混合上传浏览器场景;覆盖两个详情入口和图片交互。 | 断言能够检出文件遗漏、计数遗漏、顺序及布局回归。 | +| 6. 更新文档 | 随实现更新受影响的 README 双语文件、JSDoc 和所属决定记录,并记录双语一致性。 | 文档描述已实现行为,完整实现后按最终生命周期改写本提案。 | +| 7. 验证待提交差异 | 应用[推送前检查工作流](../../../skills/dsh-pre-push-checks/SKILL.md)和客户端检查层级;检查完整差异与变化的预期输出。 | 相关本地检查通过,准确记录命令与限制。 | +| 8. 提交与演示 | 检查 hook 修改,正常提交,构建干净的已提交代码树,并按 [GIF 工作流](../../../skills/record-browser-gif/SKILL.md)录制一次隔离的真实服务、真实模型流程。正常推送并核对远端提交。 | 已检查的 GIF 标明演示提交和服务条件,远端分支包含该提交。 | +| 9. 发布与评审 | 填写 [PR 模板](../../../../.github/pull_request_template.md),关联 Issue,选择当前 kind/area 标签并附证据。检查 PR 预览和 CI;在同一分支处理评审。 | 当前提交具有可评审证据;修复后更新受影响检查,演示提交变化时更新 GIF。 | +| 10. 合并与跟进 | 评审后满足实时审批与合并规则,再跟进主线检查和 Issue 状态。 | 已核实获授权的合并,剩余工作有明确归属;发行走独立流程。 | + +### 验证覆盖 + +| 层级 | 计划覆盖 | +|---|---| +| 投影 | 文本加一张图片与一个文件、只有图片、只有文件、无正文的混合附件、空文本、多个附件与重复附件。计数和原始字段与输入一致。 | +| 组件 | 概述与预览显示相同的附件名称、顺序、元数据与激活行为。原始内容保留块顺序和完整字段。覆盖图片无名称、零字节文件、长文件名、加载、失败与重试。 | +| 组装应用 | 扩展 [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) 及其[录制场景](../../../../snapshots/web/file-upload-round/snapshot.yml),在混合上传后和重新打开历史后检查概述、预览与原始内容。保留[共享图片缓存检查](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)。 | +| 浏览器布局与无障碍 | 验证窄版与宽版详情面板、浅色与深色主题、两种语言、缩略图完整缩放、末尾附件可达,以及键盘激活和关闭查看器。按[快照存放规则](../../../../snapshots/AGENTS.md),把几何预期留在所属测试旁。 | +| 本地检查 | 实现期间运行针对性测试,然后执行客户端规则要求的 GUI 与浏览器回放检查、本地化验证、文档检查和 lint。正常 pre-push 执行类型检查;CI 负责全量覆盖率与平台检查。 | +| 演示 | 在同一个真实模型会话中录制混合提交、列表计数、概述、预览中的图片激活,以及展开的原始元数据。媒体保存在功能分支之外,按 GIF 工作流附到 PR。 | + +## 考虑过的替代方案 + +**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 + +**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。提案默认使用单列等宽列表。 + +**在原始内容中继续混排文件 JSON 和图片预览。** 这保留了现有渲染,却让同一个标签页对两种附件具有不同含义。一致的元数据折叠区用于检查已记录字段,图片视觉展示由预览负责。 + +## 验收标准 + +- 同时包含文本、一张图片和一个普通文件的消息,在列表显示两种数量,在概述和预览显示两个附件。 +- 概述与预览共用一个附件渲染器;即使间距和容器不同,也提供相同的有序附件和图片操作。 +- 纯附件消息、重复引用、无名称图片、零字节文件和长文件名保持可识别,不编造元数据。 +- 图片完整缩放到紧凑缩略图内。键盘和指针用户均可打开并关闭现有图片查看器;图片加载失败时仍有可见反馈并可重试。 +- 原始内容保留每个原始内容块及其顺序。图片与文件折叠区展示全部已记录附件字段,不以生成标签或下载的文件内容替代。 +- 窄面板、两种主题和两种语言下均可使用。现有 Chat/工具图片行为和会话级图片缓存复用保留回归覆盖。 +- 实现包含相应测试、经审阅的无密钥浏览器预期输出、双语文档,以及与演示 PR 当前提交对应的真实模型 GIF。 + +## 风险 + +在视图数据中加入文件引用,可能误将文件传给只接受图片的消费方,或丢失原始字段。需要跟踪每个受影响的类型标记,并验证混合与重复附件。紧凑缩略图降低了图表的直接可读性,因此大图激活入口必须容易发现。共享渲染器改动可能影响 Chat 和工具结果,概述滚动容器也可能遮挡末尾行;两者都需要组装浏览器检查。缺失的记录元数据必须保持明确缺失,图片读取失败不能隐藏其他文件。模型凭证不可用时,真实模型演示会受阻,必须如实说明,不能用 fixture(测试前置数据)录屏替代。 From f92f984d3dad43ae138a4c2b0b5ef2733d97fb11 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 19:48:48 +0800 Subject: [PATCH 019/225] docs(notes): propose one card per delivered changed file --- ...09-16-delivered-file-single-card.i18n.yaml | 6 +++ .../2026-09-16-delivered-file-single-card.md | 41 +++++++++++++++++++ ...026-09-16-delivered-file-single-card.zh.md | 41 +++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 .agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.i18n.yaml create mode 100644 .agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.md create mode 100644 .agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.zh.md diff --git a/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.i18n.yaml b/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.i18n.yaml new file mode 100644 index 0000000000..cc205c7391 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.md +2026-09-16-delivered-file-single-card.md: 65edb58a97a3c67550df37a4172863faa9d2eae4 +2026-09-16-delivered-file-single-card.zh.md: c21857071184fc3a5f4d97eb524606f465c82ae1 diff --git a/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.md b/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.md new file mode 100644 index 0000000000..65edb58a97 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.md @@ -0,0 +1,41 @@ +# Agent Note: One card per delivered changed file + +Status: proposed + +English | [中文](2026-09-16-delivered-file-single-card.zh.md) + +## Problem + +A finished turn ends with two file surfaces: the [changed-files card](../../implemented/feature/2026-09-11-turn-changed-files-card.md) lists every file the turn changed with its line counts and opens the [review tab](../../implemented/feature/2026-09-15-changed-file-diff-preview.md) on that file, and a delivery card for every file the model declared through [present](../../implemented/feature/2026-09-08-present-workspace-source-files.md) previews it in the Sidebar and opens it natively. A file the turn both changed and delivered appears in both, and the second appearance tells the user nothing new; the recorded `changed-files-turn` scenario shows four files as four rows and four cards. + +The two surfaces differ in what they offer and how long they live. The row carries counts and the comparison; the card carries the model's description, the preview, and the native actions. The card's content lives only in the Host process, so a conversation reopened after a Host restart has no changed-files card, while `deliverables/presented` events persist in the Session log and their cards come back. + +## Proposal + +Render a file the turn both changed and delivered once, on its delivery card. The changed-files card drops that file's row and keeps its header, which still names the turn's complete file count and summed counts and still opens the review on the turn; when every changed file was delivered only the header remains. The delivery card gains the file's added and deleted lines, or its binary or oversized label, beside the file name, and that label opens the review tab on the file, so the comparison, the preview, the native actions, and the description stay available from one place. Files only changed or only delivered render as before, and turns never share data. + +Matching is lexical, on the Client, at render time: separators normalized to `/`, `.` segments dropped, `..` folded, and an absolute path under the Session working directory stated relative to it. Repeated deliveries of one file in a turn merge under the same key, keeping the latest description. Two spellings that meet only through a symbolic link stay distinct. + +Nothing durable changes: no Session event, no Host route, and no recorder change; the merge lives in `packages/client/ui-deliverables` beside the card derivation. After a Host restart the changed-files card is absent and the delivery cards render as today, without a special case. + +## Alternatives considered + +**Keeping the row and dropping the card** costs less code, but turns a deliverable's default entry from the preview into the comparison: a report or SVG becomes one diff row, and a binary deliverable becomes a `binary` label with no preview at all. + +**Asking the model not to `present` files the changed-files card will list** cannot work: the `workspace/changes` event is never model-visible and its coverage depends on git, ignore rules, the file cap, and temporary-directory exclusion; `present` is shared by the `standard`, `ptc`, and `cordis` presets whose headless and SDK consumers have no changed-files card; and after a Host restart an undelivered file has no entry at all. The tool description also requires delivering every requested output, which the [delivery decision](../../implemented/feature/2026-09-08-present-workspace-source-files.md) settled. + +**One combined card** that lists every changed file with delivery actions on the delivered rows would merge two surfaces with different lifetimes into one and reopen the card design; the proposal keeps both surfaces and moves only the duplicate. + +## Acceptance criteria + +- A file both changed and delivered in one turn renders one delivery card and no changed-files row; the header count and totals still cover it. +- The delivery card shows the file's counts, and that label opens the turn's review tab on the file; preview, native actions, and the description remain. +- Repeated deliveries of one file in a turn render one card; files with the same basename in different directories render separately. +- Reloading the page renders the same result as the first render; after a Host restart the delivery cards render without the changed-files card. +- The `changed-files-turn` and `present-svg` recorded scenarios and the package unit tests cover the merge. + +## Risks + +- A delivery spelled through a symbolic link, or with an absolute path outside the working directory that the recorder spelled differently, is not matched and renders twice; the README lists this as a known limitation. +- A changed-files card reduced to its header may read as incomplete; the header keeps the complete count so the review still lists every file. +- The counts label on a 60px delivery card competes for space with long file names; the design of that label is open until a design is agreed. diff --git a/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.zh.md b/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.zh.md new file mode 100644 index 0000000000..c218570711 --- /dev/null +++ b/.agents/notes/proposed/feature/2026-09-16-delivered-file-single-card.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 交付的改动文件只显示一张卡片 + +Status: proposed + +[English](2026-09-16-delivered-file-single-card.md) | 中文 + +## 问题 + +一轮结束时有两个文件区块:[改动文件卡片](../../implemented/feature/2026-09-11-turn-changed-files-card.zh.md)列出本轮改动的每个文件及其增删行数,并在该文件上打开 [review tab](../../implemented/feature/2026-09-15-changed-file-diff-preview.zh.md);模型通过 [present](../../implemented/feature/2026-09-08-present-workspace-source-files.zh.md) 声明的每个文件有一张交付卡片,在 Sidebar 中预览并用原生应用打开。本轮既改动又交付的文件在两处都出现,第二次出现没有给用户任何新信息;录制的 `changed-files-turn` 场景里四个文件显示为四行加四张卡片。 + +两个区块提供的东西和寿命都不同。行有行数和对比;卡片有模型写的说明、预览和原生操作。卡片的内容只活在 Host 进程里,Host 重启后重新打开的会话没有改动文件卡片,而 `deliverables/presented` 事件持久化在 Session log 里,它们的卡片会回来。 + +## 提议 + +本轮既改动又交付的文件只显示一次,显示在交付卡片上。改动文件卡片去掉该文件的行,保留头部;头部仍写本轮完整的文件数和汇总行数,仍在本轮上打开 review;所有改动文件都被交付时只剩头部。交付卡片在文件名旁补上该文件的增删行数,或二进制、过大标签,点击该标签在该文件上打开 review tab,这样对比、预览、原生操作和说明都从同一处可达。只改动或只交付的文件照旧显示,不同轮次之间不共享数据。 + +匹配在 Client 渲染时按词法进行:分隔符统一为 `/`,去掉 `.` 段,折叠 `..`,落在 Session 工作目录下的绝对路径改写为相对路径。同一轮内重复交付同一文件按同一个键合并,保留最新的说明。只经软链才相同的两种拼写保持不同。 + +持久化的东西都不变:没有新的 Session 事件、没有 Host 路由、recorder 不改;合并逻辑放在 `packages/client/ui-deliverables` 里卡片推导的旁边。Host 重启后改动文件卡片不存在,交付卡片按现在的方式渲染,不需要特殊处理。 + +## 考虑过的替代方案 + +**保留行、去掉卡片**代码更少,但交付物的默认入口从预览变成对比:一份报告或 SVG 变成一行 diff,二进制交付物变成一个「二进制」标签,完全没有预览。 + +**让模型不要 `present` 改动文件卡片会列出的文件**行不通:`workspace/changes` 事件从不进模型上下文,它的覆盖范围取决于 git、ignore 规则、文件数上限和临时目录排除;`present` 由 `standard`、`ptc`、`cordis` 三个 preset 共用,它们的 headless 和 SDK 消费者没有改动文件卡片;Host 重启后没交付的文件就没有任何入口。工具描述也要求交付用户要的每个产出,这是[交付决定](../../implemented/feature/2026-09-08-present-workspace-source-files.zh.md)定下的。 + +**一张合并卡片**,列出每个改动文件并在被交付的行上放交付操作,会把两个寿命不同的区块并成一个,并重开卡片设计;本提议保留两个区块,只挪走重复的那份。 + +## 验收标准 + +- 同一轮既改动又交付的文件只显示一张交付卡片,没有改动文件行;头部的数量和汇总仍包含它。 +- 交付卡片显示该文件的行数,点击该标签在该文件上打开本轮的 review tab;预览、原生操作和说明都保留。 +- 同一轮重复交付同一文件只显示一张卡片;不同目录下同名的文件分别显示。 +- 重新加载页面后的渲染结果与首次一致;Host 重启后交付卡片照常渲染,没有改动文件卡片。 +- `changed-files-turn` 和 `present-svg` 录制场景以及本包单测覆盖这次合并。 + +## 风险 + +- 经软链拼写的交付,或 recorder 拼法不同的工作目录外绝对路径,不会被匹配到,仍显示两次;README 把它列为已知限制。 +- 只剩头部的改动文件卡片可能显得不完整;头部保留完整数量,review 仍列出每个文件。 +- 60px 高的交付卡片上,行数标签会和长文件名争空间;该标签的样式在设计定下之前保持开放。 From f65799a236ab206e3daa033e065e469baddbd4d6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 20:05:48 +0800 Subject: [PATCH 020/225] docs(session): clarify blank-state projection guarantees --- ...ervations-and-projection-owned-client-state.i18n.yaml | 2 +- ...-observations-and-projection-owned-client-state.zh.md | 2 +- apps/web/tests/workspace-management.e2e.ts | 1 + packages/api/session-controller/README.i18n.yaml | 2 +- packages/api/session-controller/README.md | 2 +- .../session-controller/src/client/sessions/lineage.ts | 2 +- .../session-controller/src/client/sessions/manager.ts | 3 ++- .../session-controller/src/client/sessions/service.ts | 8 ++++---- .../api/session-controller/tests/session.client.spec.ts | 9 +++++++++ 9 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 2418f0b77d..b81de50a5f 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md 2026-08-25-session-observations-and-projection-owned-client-state.md: 88a4cbed008ebbb8f6733c5c55b68b9781309820 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 9dd6ce9e2243b535c614cd5ad0862384a4536623 +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f5bd374a9e35282cb8aef03339ea95f2132f0c82 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 9dd6ce9e22..f5bd374a9e 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ### 领域应用 -- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。 Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。过期列表响应不能覆盖较新的 history 或 control projection。 +- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。过期列表响应不能覆盖较新的 history 或 control projection。 - **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。 - **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。 - **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 6525d6fd2d..6f1a7f99db 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -771,6 +771,7 @@ describe('web e2e: New Session after an outdated blank cache', () => { const stored = await scaffold.ctx.sessionPersistence.stat(id) if (stored === undefined) throw new Error('seeded Session is missing') // A durable log may advance after its last blank projection checkpoint. + // An unregistered Session writes only the checkpoint, leaving the persisted log intact. const blank = scaffold.ctx.sessions.prepare(id, { eventState: 'detached', seed: [], meta: stored.header, inheritedEventCount: SessionLogOffset(0), }) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index f92d06f911..40d9d29e75 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: ac83c31a83afb02f0b4afc3b9d35170b0eeb3b5f +README.md: 18a7567bed620254a44a9f0629154f8b13a34c26 README.zh.md: fc2d26b793e1888a64ff043bb2ba3043acded728 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ac83c31a83..18a7567bed 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -30,7 +30,7 @@ The Client journal validates exact V3 event envelopes before publishing follow s Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application. -Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. An opened conversation therefore cannot be reused by New Session because an older list response still marks it blank. +Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index 09551369b6..31b68739c3 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -19,7 +19,7 @@ export interface SessionListEntry { title?: string updatedAt: number running: boolean - /** Empty-log bit mirrored from the summary; lists hide blank sessions (filtering stays with the consumer). */ + /** Summary blankness reconciled with `sessionListMetadata`; lists hide blank sessions (filtering stays with the consumer). */ blank: boolean parentSessionId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 3439494fa7..152ad92fd7 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -334,6 +334,7 @@ export class SessionManager { store = new ProjectionValueStore() const projections = store store.subscribeAny(() => { + // Newer history or control metadata corrects a resident Session's stale list hint. if (projections.values().sessionListMetadata?.blank === false) { this.sessions.get(sessionId)?.handleBlank(false) } @@ -481,7 +482,7 @@ export class SessionManager { this.listPhase = 'ready' // Covers the empty-mutations pull (a plain baseline carries no edge). this.syncCompletedNotifications() - // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source). + // Sessions reconcile list blank hints with their current metadata projection. for (const s of this.summaries) { const session = this.sessions.get(s.sessionId) if (session === undefined) continue diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index d1d840c513..3759415d72 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -50,10 +50,10 @@ export interface SessionSummary { /** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */ completed?: boolean /** - * Empty-log bit (host summary derivation mirror). New Session reuses a blank - * one targeting the same workspace. Filtering stays with the consumer: the - * store carries every row, while the Workspace browser shows only the - * selected blank entry. + * Blankness from the Host summary reconciled with `sessionListMetadata`. + * New Session reuses a blank one targeting the same workspace. Filtering + * stays with the consumer: the store carries every row, while the Workspace + * browser shows only the selected blank entry. */ blank: boolean updatedAt: number diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index 9607c670f0..1a4fb322bf 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -48,6 +48,15 @@ describe('Session open', () => { expect(session.getSnapshot()).toMatchObject({ blank: false, running: true }) }, COLD_BOOT_TIMEOUT_MS) + it('rejects a blank list hint when current metadata records a started conversation', async ({ mock, start }) => { + const session = await sessionBench(mock, start, SID) + session.projections.apply('sessionListMetadata', { blank: false, lastPromptAt: 1200 }, SessionSeq(8)) + + session.handleBlank(true) + + expect(session.getSnapshot()).toMatchObject({ blank: false, promptAttempted: false, running: false }) + }) + it('installs the tail page: cold → loading → open with window and nodes in place', async ({ mock, start }) => { const session = await sessionBench(mock, start, SID) const page = plainTurn(SessionSeq(10), 3, '问', '答') From a4493ceb39a9e2619837876adb14f33e432fe3da Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 20:12:32 +0800 Subject: [PATCH 021/225] fix(deliverables): scroll the split review columns together on both axes Each unwrapped split column is its own scroll container, and only the horizontal offset was mirrored onto the other side, so wheel-scrolling one column left the other behind and the paired rows drifted apart. Mirror the vertical offset the same way. --- .../src/client/ReviewTab.module.css | 2 +- .../ui-deliverables/src/client/ReviewTab.tsx | 12 +++++++----- .../tests/review-tab.client.spec.tsx | 14 ++++++++------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 5ec23f198b..0405eda4fb 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,4 +1,4 @@ -/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll sideways together; wrapped ones share rows so the sides stay aligned. */ +/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll together on both axes; wrapped ones share rows so the sides stay aligned. */ .root { display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .selector { flex: 0 1 auto; min-width: 0; } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 35a4350e6c..dbf2a7b40a 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -272,17 +272,19 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string { /** * The side-by-side view without wrapping: two columns that clip their long - * lines and scroll sideways together, so a long line on one side never runs - * under the other and both sides show the same columns of text. Every line is - * one fixed-height row, which keeps the sides aligned. + * lines and scroll together on both axes, so a long line on one side never + * runs under the other and both sides show the same rows and columns of text. + * Every line is one fixed-height row, which keeps the sides aligned. */ function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode { const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) const columns = useRef>({ left: null, right: null }) - // Mirror one side's horizontal offset onto the other; the mirrored side's own scroll event then finds nothing to change. + // Mirror one side's offsets onto the other; the mirrored side's own scroll event then finds nothing to change. const follow = (side: 'left' | 'right') => (event: UIEvent): void => { const other = columns.current[side === 'left' ? 'right' : 'left'] - if (other !== null && other.scrollLeft !== event.currentTarget.scrollLeft) other.scrollLeft = event.currentTarget.scrollLeft + for (const axis of ['scrollLeft', 'scrollTop'] as const) { + if (other !== null && other[axis] !== event.currentTarget[axis]) other[axis] = event.currentTarget[axis] + } } return (
    diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 84e76ddc3a..92088e4622 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -238,14 +238,16 @@ describe('ReviewTab', () => { expect(side('left').map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del']) expect(side('left').map(line => line.textContent)).toEqual(['1a', '2b', '', '3d', '10x', '20z']) expect(side('right').map(line => line.textContent)).toEqual(['1a', '2B', '3c', '4d', '11y', '']) - // The two sides scroll sideways together, whichever side the reader drags. + // The two sides scroll together on both axes, whichever side the reader drags. const [left, right] = ['left', 'right'].map(name => view.container.querySelector(`[data-diff-side="${name}"]`) as HTMLDivElement) - fireEvent.scroll(left!, { target: { scrollLeft: 40 } }) - expect(right!.scrollLeft).toBe(40) + fireEvent.scroll(left!, { target: { scrollLeft: 40, scrollTop: 66 } }) + expect([right!.scrollLeft, right!.scrollTop]).toEqual([40, 66]) fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) - expect(left!.scrollLeft).toBe(15) - fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) - expect(left!.scrollLeft).toBe(15) + expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 66]) + fireEvent.scroll(right!, { target: { scrollTop: 22 } }) + expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22]) + fireEvent.scroll(right!, { target: { scrollLeft: 15, scrollTop: 22 } }) + expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22]) expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') // Wrapped lines vary in height, so both sides share one row per pair. fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] })) From 55d57d33b61eb0cb3b8294108ec8f4bee7b02a2b Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 16 Sep 2026 20:22:14 +0800 Subject: [PATCH 022/225] fix(session): initialize blankness from retained metadata --- ...nd-projection-owned-client-state.i18n.yaml | 4 ++-- ...tions-and-projection-owned-client-state.md | 2 +- ...ns-and-projection-owned-client-state.zh.md | 2 +- .../api/session-controller/README.i18n.yaml | 4 ++-- packages/api/session-controller/README.md | 2 +- packages/api/session-controller/README.zh.md | 2 +- .../src/client/sessions/manager.ts | 4 ++++ .../tests/manager.client.spec.ts | 22 +++++++++++++++++++ 8 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index b81de50a5f..2c2fc1b908 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: 88a4cbed008ebbb8f6733c5c55b68b9781309820 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f5bd374a9e35282cb8aef03339ea95f2132f0c82 +2026-08-25-session-observations-and-projection-owned-client-state.md: 0ff8cbf50c18285092432850ce5222430f5acf0d +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: f3fca078d089a9ee1e329fdbb661653053053a91 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index 88a4cbed00..0ff8cbf50c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -120,7 +120,7 @@ Client-local interaction state also remains local: loading and error status, an ### Domain applications -- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. A stale list response cannot override a newer history or control projection. +- **Title and list metadata.** Cached projection hints may render an existing title and determine blankness or recency. Missing hints leave those facts unknown; only the bounded small-log policy may resolve them during listing. The Client reconciles list rows with the current metadata projection: nonblank evidence excludes a Session from blank reuse, and the later prompt timestamp supplies recency. Projection stores outlive lazy Client Session instances, so instantiation reads retained metadata even before a list row is available. A stale list response cannot override a newer history or control projection. - **Model selection.** `model/selection` records a complete provider, model, and optional reasoning effort. `modelSelection` distinguishes the last request's route from a later selection pending consumption by a request header. - **Agent preset.** The projection initializes from immutable Session metadata and advances on preset-selection events. A missing or `null` value is not replaced with the deployment default for an existing Session. - **Subagent identity.** The `subagent` unit remains the sole descriptor interpreter. Listing obtains candidates from the shared corpus and resolves values through live state, projection cache, or an observation rather than scanning events itself. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index f5bd374a9e..f3fca078d0 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -120,7 +120,7 @@ Client 本地交互状态也继续留在本地:loading 和 error 状态、打 ### 领域应用 -- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。过期列表响应不能覆盖较新的 history 或 control projection。 +- **Title 与 list metadata。** Cached projection hints 可以渲染已有 title,并判断 blankness 或 recency。Hints 缺失时这些事实保持未知;listing 期间只有有界小日志策略可以解析它们。Client 使用当前 metadata projection 对账列表行:非空白事实会将 Session 排除在空白复用之外,较晚的 prompt 时间用于最近活动排序。Projection store 的生命周期长于惰性创建的 Client Session 实例,因此实例化时即使尚无列表行,也会读取已保留的元数据。过期列表响应不能覆盖较新的 history 或 control projection。 - **Model selection。** `model/selection` 记录完整 provider、model 和可选 reasoning effort。`modelSelection` 区分上一请求使用的 route,以及等待 request header 消费的较晚 selection。 - **Agent preset。** Projection 从不可变 Session metadata 初始化,并随 preset-selection event 推进。对于现有 Session,缺失或 `null` 值不会替换成部署默认值。 - **Subagent identity。** `subagent` unit 仍是唯一 descriptor interpreter。Listing 从共享 corpus 获得 candidate,并通过 live state、projection cache 或 observation 解析值,不自行扫描 event。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 40d9d29e75..83bf2de69a 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/api/session-controller/README.md -README.md: 18a7567bed620254a44a9f0629154f8b13a34c26 -README.zh.md: fc2d26b793e1888a64ff043bb2ba3043acded728 +README.md: 392c296d1b66227a76b58bc7cb13311968afd891 +README.zh.md: a141598d49d09923ada1da1a5b8116fe7e245c44 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 18a7567bed..392c296d1b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -30,7 +30,7 @@ The Client journal validates exact V3 event envelopes before publishing follow s Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the default application. -Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. +Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index fc2d26b793..a141598d49 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -30,7 +30,7 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证精 每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时打开默认应用。 -Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 +Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 152ad92fd7..ae1624b2ed 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -279,6 +279,7 @@ export class SessionManager { /** * Lazy build: return the existing instance or construct one (no auto-open — * open is triggered by the container's select callback). + * New instances reconcile retained metadata before returning. * @param sessionId - the session to get. * @returns the resident instance. */ @@ -302,6 +303,9 @@ export class SessionManager { // durable history, even though child rows do not carry `blank`. session.handleBlank(false) session.handleRunning(child.activity === 'running') + } else { + // Retained metadata may have notified before this Session existed. + session.handleBlank(true) } } } diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index cc72ac7ab9..df3709e30f 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -61,6 +61,28 @@ describe('SessionManager instances', () => { expect(session.getSnapshot().running).toBe(true) // list preceded instantiation }) + it('initializes blankness from metadata published before the Session and list exist', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + const published = Promise.withResolvers() + const stopObserving = manager.subscribe(() => { published.resolve(undefined) }) + try { + manager.handleControlFrame({ type: 'projection', sessionId: S1, + key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 1200 }, seq: 8 }) + manager.handleControlFrame({ type: 'projection', sessionId: S2, + key: 'sessionListMetadata', value: { blank: true, lastPromptAt: null }, seq: 2 }) + // The initial notification must finish while neither Session is resident. + await published.promise + + expect(manager.getListSnapshot().items).toEqual([]) + expect(manager.get(S1).getSnapshot()).toMatchObject({ blank: false, running: false }) + expect(manager.get(S2).getSnapshot()).toMatchObject({ blank: true, running: false }) + expect(manager.get('fk-unobserved' as SessionId).getSnapshot().blank).toBe(true) + } finally { + stopObserving() + await manager.dispose() + } + }) + }) describe('list lifecycle', () => { From dcf2b678963e451d8d7ef75ba1d7cad1a58ac1c4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 21:00:57 +0800 Subject: [PATCH 023/225] feat(web): open Sidebar-previewed files in the default application The document preview declares two child slots handing the previewed file and its Host path to other plugins: the header actions after its own controls, and the empty state of a file it cannot render, where Retry stood. Empty-state read failures now name their recourse: not-text and too-large offer the handoff, not-found and non-regular paths offer nothing, and the rest keep Retry. The unsupported-suffix empty state offers the same seat. ui-open-in-app fills both seats with an "Open" split button (default application, or the file manager reveal from its menu) and an empty-state "Open in default app" button. Both call the Session Remote the Host already publishes: canOpenWorkspacePath once per page decides whether they render, openWorkspacePath runs each gesture. Only the pressed control disables while its gesture settles; a failure announces once through a transient toast. The delivery cards keep their own opener this iteration. The design, slot pair, failure classification, and toast rule come from Yifffan's PR #4120. --- ...in-default-app-for-sidebar-files.i18n.yaml | 6 + ...6-open-in-default-app-for-sidebar-files.md | 29 ++++ ...pen-in-default-app-for-sidebar-files.zh.md | 29 ++++ apps/web/tests/document-preview.e2e.ts | 67 ++++++++- .../tests/fixtures/native-open-on.patch.yml | 4 + apps/web/tests/seeded-history.e2e.ts | 16 ++- apps/web/tests/seeded-history.overlay.yml | 17 +++ packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 2 +- packages/client/README.zh.md | 2 +- .../client/ui-open-in-app/README.i18n.yaml | 4 +- packages/client/ui-open-in-app/README.md | 22 ++- packages/client/ui-open-in-app/README.zh.md | 22 ++- packages/client/ui-open-in-app/package.json | 11 +- .../src/client/OpenPathAction.module.css | 62 ++++++++ .../src/client/OpenPathAction.tsx | 132 ++++++++++++++++++ .../src/client/OpenPathEmptyAction.module.css | 27 ++++ .../src/client/OpenPathEmptyAction.tsx | 40 ++++++ .../client/ui-open-in-app/src/client/index.ts | 44 +++++- .../ui-open-in-app/src/client/locales.ts | 18 ++- .../src/client/open-failure-toast.tsx | 25 ++++ .../ui-open-in-app/src/client/open-path.ts | 75 ++++++++++ .../tests/browser-plugin.client.spec.ts | 48 ++++++- .../tests/open-path-action.client.spec.tsx | 132 ++++++++++++++++++ .../tests/open-path.client.spec.ts | 71 ++++++++++ packages/client/ui-open-in-app/tsconfig.json | 9 ++ .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 8 +- .../ui-sidebar-documentpreview/README.zh.md | 8 +- .../src/client/TextPreview.tsx | 44 ++++-- .../src/client/document/contract.ts | 29 ++++ .../src/client/failure-line.ts | 22 +++ .../src/client/index.ts | 2 + .../tests/document-toolbar.client.spec.tsx | 18 ++- .../tests/failure-line.client.spec.ts | 19 ++- .../tests/fixtures.client.ts | 15 +- .../tests/text-preview.client.spec.tsx | 56 ++++++-- .../src/client/slot-catalog.ts | 120 +++++++++++++++- pnpm-lock.yaml | 9 ++ .../web/document-preview/document.expected.md | 2 + .../web/document-preview/paging.patch.yml | 4 + .../seeded-history/file-preview.expected.md | 3 + 42 files changed, 1194 insertions(+), 87 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md create mode 100644 .agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md create mode 100644 apps/web/tests/fixtures/native-open-on.patch.yml create mode 100644 apps/web/tests/seeded-history.overlay.yml create mode 100644 packages/client/ui-open-in-app/src/client/OpenPathAction.module.css create mode 100644 packages/client/ui-open-in-app/src/client/OpenPathAction.tsx create mode 100644 packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css create mode 100644 packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx create mode 100644 packages/client/ui-open-in-app/src/client/open-failure-toast.tsx create mode 100644 packages/client/ui-open-in-app/src/client/open-path.ts create mode 100644 packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx create mode 100644 packages/client/ui-open-in-app/tests/open-path.client.spec.ts diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml new file mode 100644 index 0000000000..e49b11bebd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md +2026-09-16-open-in-default-app-for-sidebar-files.md: c66d79618bea5c8bc190f50141169aea3090d833 +2026-09-16-open-in-default-app-for-sidebar-files.zh.md: 3a22f9abcc5f0c19af59efdf0be48b50facb2e40 diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md new file mode 100644 index 0000000000..c66d79618b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md @@ -0,0 +1,29 @@ +# Agent Note: Open Sidebar-previewed files in the default application + +Status: implemented + +English | [中文](2026-09-16-open-in-default-app-for-sidebar-files.zh.md) + +## Problem + +Opening a file in its Host default application or showing it in the file manager existed only on the delivery cards, through routes and a fetch controller private to `ui-deliverables` and addressed by Session, declaration-event sequence, and file index. The right Sidebar's document preview offered no native handoff: a video, an archive, an office document, a file the reader rejected as non-text, or an oversized file dead-ended on an explanation, with Retry as the only control even where a second read could not help (issue #3932). Those files have no delivery event to address, so the existing plumbing could not serve a second surface. A first implementation (PR #4120, by Yifffan) added a new `ui-open-locally` package with its own Host routes and moved the delivery cards onto it; product review asked instead that file opening live in the existing open-in-app feature and accepted an intermediate state for this iteration. + +## Decision + +[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.md) owns the browser controls. The document preview of [ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.md) declares two Session-scoped list child slots whose owner props carry the previewed file and its absolute Host path, rendered only once the file's metadata reports that path: `sidebar.right.tab.document.actions` after the header's own controls, in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` where Retry would stand. The preview classifies an empty-state read failure: `not-text` and `too-large` are readable files the preview cannot render and offer the unpreviewable slot; `not-found` and `not-regular-file` offer only their explanation; every other failure keeps Retry because a second read may resolve it. The unsupported-suffix empty state, which never reads, offers the same slot. + +ui-open-in-app fills both slots: an "Open ▾" split button whose main button opens the file in the default application and whose menu adds the file-manager reveal, and an "Open in default app" empty-state button. Neither control renders until the Host answered that a desktop exists. The controls call the Session Remote the Host already publishes, `session.canOpenWorkspacePath` once per page and `session.openWorkspacePath` per gesture with the absolute path and, for reveal, `action: 'reveal'`; no new Host route exists. Both controls share one gesture hook: only the control the user pressed disables while its call settles, a failed call resolves as a failure kind, and that control announces it once through a transient toast, so no failure state persists on a control. + +## Alternatives considered + +**A new `ui-open-locally` package with its own Host routes** (PR #4120) is where the slot pair, the failure classification, the split-button design, and the toast rule come from. It was set aside because the repository already has an open-in-app feature and a second package for opening things locally duplicates it; the routes it added re-verified through the Session filesystem what `openWorkspacePath` already accepts from any authenticated client. + +**Host routes in `dsh-host-open-in-app`** serving desktop facts and a path open would let the Host name the file manager and distinguish a missing file from a failed launch. They would make the Host half depend on `sessionController`, `workspaceFiles`, and `fs` for two calls the Session Remote already publishes, so the Remote is used directly and the reveal label stays generic. + +**Moving the delivery cards onto these controls now** would repeat the largest part of PR #4120, including the cross-plugin runtime import its review rejected and the recorded delivery scenarios it had to migrate. The cards keep their own routes this iteration. + +## Consequences + +Every previewed file with a Host path can be opened locally from the Sidebar, including files that were never delivered. The delivery cards and the preview run two openers over one Host capability; unifying them, and deciding per surface between the full application list and the default application only, is the next step the product direction names. The reveal menu item says "Show file location" on every platform because the Session Remote reports no file manager. Default-application open carries the Host's file-association execution semantics for what it opens; the guard is the explicit user gesture on a file the Session can already read, and no new Host authority was granted. + +Unit tests cover the failure classification, the preview's rendering of each recourse and its withholding of the seats before a Host path is known, the path controller's desktop read and gesture outcomes, both controls' visibility, busy isolation, and toast, and the plugin's registration and fiber-disposal removal. The keyless Web document-preview scenario drives the unsupported-suffix controls against a stubbed opener on POSIX hosts, and the seeded-history file-preview golden pins the header control. diff --git a/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md new file mode 100644 index 0000000000..3a22f9abcc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md @@ -0,0 +1,29 @@ +# Agent Note:用默认应用打开 Sidebar 预览的文件 + +Status: implemented + +[English](2026-09-16-open-in-default-app-for-sidebar-files.md) | 中文 + +## 问题 + +用 Host 默认应用打开文件、或在文件管理器中显示文件,此前只存在于交付卡片上,走的是 `ui-deliverables` 私有的路由与 fetch 控制器,按 Session、声明事件序号和文件下标定位。右侧 Sidebar 的文档预览没有任何本地交接:视频、压缩包、office 文档、被读取器判为非文本的文件、过大的文件,都停在一行说明上,唯一的控件是重试,哪怕再读一次也无济于事(issue #3932)。这些文件没有交付事件可供定位,所以现有管线无法服务第二个界面。第一版实现(PR #4120,作者 Yifffan)新增了 `ui-open-locally` 包,自带 Host 路由,并把交付卡片也迁了过去;产品评审要求改为把文件打开放进现有的 open-in-app 功能,并接受本期先落一个中间态。 + +## 决定 + +[ui-open-in-app](../../../../packages/client/ui-open-in-app/README.zh.md) 拥有浏览器侧的控件。[ui-sidebar-documentpreview](../../../../packages/client/ui-sidebar-documentpreview/README.zh.md) 的文档预览声明两个 Session 作用域的 list 子 slot,owner props 携带当前文件及其 Host 绝对路径,只在文件元数据报出该路径后渲染:`sidebar.right.tab.document.actions` 在每个显示头部的状态里跟在头部自有控件之后,`sidebar.right.tab.document.unpreviewable` 占据原本放重试的位置。预览对空态的读取失败分类:`not-text` 与 `too-large` 是可读但预览无法渲染的文件,提供 unpreviewable slot;`not-found` 与 `not-regular-file` 只给说明;其余失败保留重试,因为再读一次可能解决。不支持后缀的空态不发起读取,提供同一个 slot。 + +ui-open-in-app 填满这两个 slot:一个「打开 ▾」分体按钮,主按钮用默认应用打开文件,菜单再加文件管理器定位;一个空态的「使用默认应用打开」按钮。Host 回答有桌面之前,两个控件都不渲染。控件调用 Host 早已发布的 Session Remote:每页调用一次 `session.canOpenWorkspacePath`,每个手势调用一次 `session.openWorkspacePath`,带上绝对路径,定位时再带 `action: 'reveal'`;没有新增任何 Host 路由。两个控件共用一个手势 hook:只有用户按下的那个控件在调用结算期间禁用,失败的调用解析为一个失败种类,由该控件通过短暂 toast 提示一次,控件上不残留失败状态。 + +## 考虑过的备选 + +**新建自带 Host 路由的 `ui-open-locally` 包**(PR #4120)是这对 slot、失败分类、分体按钮设计和 toast 规则的来源。放弃它是因为仓库已经有 open-in-app 功能,再来一个负责本地打开的包属于重复;它新增的路由经由 Session 文件系统重新校验的,正是 `openWorkspacePath` 已经接受任意已认证客户端传入的东西。 + +**在 `dsh-host-open-in-app` 里加 Host 路由**提供桌面信息和按路径打开,能让 Host 报出文件管理器的名字,并区分文件不存在和启动失败。但这会让 Host 半边为了 Session Remote 已经发布的两个调用而依赖 `sessionController`、`workspaceFiles` 与 `fs`,所以直接使用 Remote,定位标签保持通用说法。 + +**现在就把交付卡片迁到这些控件上**会重做 PR #4120 里最大的那部分,包括评审否决的跨插件运行时导入,以及它不得不迁移的交付录制场景。本期卡片保留自己的路由。 + +## 后果 + +每个有 Host 路径的预览文件都能从 Sidebar 本地打开,包括从未交付过的文件。交付卡片和预览在同一项 Host 能力上跑着两套打开器;把它们统一起来,并按界面决定展示完整应用列表还是只展示默认应用,是产品方向点名的下一步。定位菜单项在所有平台都写「显示文件位置」,因为 Session Remote 不报告文件管理器。用默认应用打开带有 Host 文件关联的执行语义;护栏是用户对 Session 已可读文件的明确手势,没有授予任何新的 Host 权限。 + +单元测试覆盖失败分类、预览对每种出路的渲染以及在 Host 路径未知时对座位的保留、路径控制器的桌面读取与手势结果、两个控件的可见性、忙碌隔离与 toast,以及插件的注册与 fiber 释放时的移除。无密钥的 Web 文档预览场景在 POSIX 主机上用桩打开器驱动不支持后缀的控件,seeded-history 的文件预览 golden 固定住头部控件。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 00fcf849e0..226ae750f4 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -1,11 +1,12 @@ -/** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +/** Keyless document-preview smoke through a real Session, Files tab, shipped renderers, and the default-application controls. */ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join, relative } from 'node:path' +import { delimiter, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -18,6 +19,8 @@ const PAGE_LINES = 64 const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() +/** The stubbed opener runs as a POSIX script; Windows keeps its real file associations out of the lane. */ +const STUB_OPENER = process.platform !== 'win32' const TINY_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -57,10 +60,34 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () let page: Page let tripwire: ReturnType let outsideRoot: string | undefined + let nativeRoot: string | undefined + let openLog = '' + const opened = async (): Promise> => + (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) - scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH }) + if (STUB_OPENER) { + // Exercise the built Host through its actual OS command, replacing only the desktop application. + nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-native-')) + openLog = join(nativeRoot, 'opened.jsonl') + await writeFile(openLog, '') + const command = process.platform === 'darwin' ? 'open' : 'xdg-open' + await writeFile(join(nativeRoot, command), `#!${process.execPath} +const fs = require('node:fs'); +const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n'); +`, { mode: 0o700 }) + vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) + } + // The Open In rows carry the default-application controls; the SSH marker + // keeps the host's application catalog empty, so the Session-header split + // button stays off every platform while the pinned desktop serves the file controls. + scaffold = await launchWebScaffold({ + replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH, + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), + }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -75,7 +102,9 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () try { await scaffold?.close() } finally { + vi.unstubAllEnvs() if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true }) + if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true }) } } }) @@ -474,13 +503,39 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'clip.mp4', exact: true }).click() const unsupported = column.locator('[data-textpreview-state="unsupported"]') await unsupported.waitFor({ timeout: 15_000 }) - const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported]').innerText() + const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported] p').innerText() expect(unsupportedLine).toContain('Preview is not available for this file type yet.') expect(await unsupported.locator('[data-textpreview-path]').innerText()).toContain('clip.mp4') expect(await unsupported.locator('[data-document-viewer-menu]').count()).toBe(0) expect(await unsupported.locator('[data-textpreview-tool="reload"]').count()).toBe(0) + // The default-application controls land once the Host answered the pinned desktop read. + const headerOpen = unsupported.locator('[data-open-path-open]') + await headerOpen.waitFor({ timeout: 15_000 }) + const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]') + await emptyOpen.waitFor({ timeout: 15_000 }) await successShot(page, 'unsupported') - sections.push(['## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`].join('\n')) + sections.push([ + '## Unviewable binary', '', + '- State: unsupported', + `- Line: ${unsupportedLine.trim()}`, + `- Header control: ${await headerOpen.innerText()}`, + `- Empty-state control: ${await emptyOpen.innerText()}`, + ].join('\n')) + if (STUB_OPENER) { + // Real Host gestures against the stubbed opener: default application from the empty state, reveal from the header menu. + const clip = join(cwd, 'clip.mp4') + await emptyOpen.click() + await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1) + await unsupported.locator('[data-open-path-more]').click() + await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() + await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2) + const gestures = await opened() + expect(gestures[0]).toEqual({ path: clip, action: 'open' }) + expect(gestures[1]?.action).toBe('reveal') + expect([clip, cwd]).toContain(gestures[1]?.path) + // Gesture facts stay out of the golden: the stub does not run on Windows. + expect(await page.getByRole('alert').count()).toBe(0) + } expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) diff --git a/apps/web/tests/fixtures/native-open-on.patch.yml b/apps/web/tests/fixtures/native-open-on.patch.yml new file mode 100644 index 0000000000..b96dbfbd9c --- /dev/null +++ b/apps/web/tests/fixtures/native-open-on.patch.yml @@ -0,0 +1,4 @@ +# Pin desktop availability so native-open controls render identically in headless CI. +- id: session-controller + config: + nativeOpen: true diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 9d58ad53b2..788902d336 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -15,6 +15,7 @@ import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' @@ -206,8 +207,15 @@ describe('web e2e: seeded history renders through cold resume', () => { let seededThroughSeq = -1 beforeAll(async () => { - scaffold = await launchWebScaffold(process.platform === 'win32' ? {} : { - extraOverlayPath: fileURLToPath(new URL('./fixtures/sidebar-terminal.patch.yml', import.meta.url)), + // The POSIX terminal fixture stays off Windows; the pinned desktop applies + // everywhere. The Open In rows carry the document header's file controls, + // and the SSH marker keeps the application catalog empty so the + // Session-header split button stays out of every golden. + scaffold = await launchWebScaffold({ + extraOverlayPath: fileURLToPath(new URL(process.platform === 'win32' + ? './fixtures/native-open-on.patch.yml' + : './seeded-history.overlay.yml', import.meta.url)), + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), }) // Composer recording uses a child workspace; seedSession owns the scaffold root. const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd @@ -447,6 +455,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => path.textContent()).toBe(absolutePath) expect(await path.getAttribute('title')).toBe(absolutePath) await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') + // The header's "Open" control lands once the Host answered the desktop read. + await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 }) const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE) // Put the column back so the later goldens see the default frame. @@ -737,12 +747,14 @@ describe('web e2e: seeded history renders through cold resume', () => { const column = page.locator('[data-rightbar-col]') await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') const tabId = await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab') + await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 }) const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) const warningStart = tripwire.warnings.length await page.reload({ waitUntil: 'load' }) acknowledgeReloadConnectionLoss(tripwire, warningStart) await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') expect(await column.locator('[data-dockkit-tab]').getAttribute('data-dockkit-tab')).toBe(tabId) + await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 }) const restoredPreview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) expect(restoredPreview).toBe(preview) }) diff --git a/apps/web/tests/seeded-history.overlay.yml b/apps/web/tests/seeded-history.overlay.yml new file mode 100644 index 0000000000..be32e87fc5 --- /dev/null +++ b/apps/web/tests/seeded-history.overlay.yml @@ -0,0 +1,17 @@ +# The cold-resume lane's own overlay: the bounded terminal fixture the terminal +# replacement case drives, plus a pinned desktop so the document header's +# "Open" control lands in the file-preview golden on every platform. +- id: terminal-controller + config: + maxTerminals: 2 + unattendedTimeoutMs: 2000 + activityPollIntervalMs: 100 + cleanupRetryMs: 100 + shellCandidates: [/bin/bash, /bin/sh] + shell: + path: /bin/bash + name: bash + args: [--noprofile, --norc, -i] +- id: session-controller + config: + nativeOpen: true diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 348648a215..a2ebb863dd 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: d3aee40a79e5d57029d8af42c333abcd5a9112a9 -README.zh.md: a3776a2c1034cb747fb191c206b52d570ec47aaf +README.md: ff0acc7da1be69e0fb76ed3ce72d31d490dcd32c +README.zh.md: 22ab17872ae3a0f62de05bc7ace41fd92332b355 diff --git a/packages/client/README.md b/packages/client/README.md index d3aee40a79..ff0acc7da1 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -78,7 +78,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — | -| [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application | — | +| [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file | — | ----- diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index a3776a2c10..22ab17872a 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -78,7 +78,7 @@ kind: "package-group" | [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — | -| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮 | — | +| [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮,以及文档预览里用默认应用打开单个文件的控件 | — | ----- diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index bab8f0bd17..aa155f7e22 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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-open-in-app/README.md -README.md: d242d5a84dffbf0895485f7c79eafd5e332fe446 -README.zh.md: e954b7c71e8c4dbd1b19a041aafb7fe154631fbb +README.md: 91d259c73ad73460bca151d9e4cacfb6338c62ab +README.zh.md: 30d28f0681c6abfd7b3ea3ab3dc341df7fb3bdaf diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index d242d5a84d..91d259c73a 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -1,5 +1,5 @@ --- -description: "Web Session-header \"Open In...\" split button: launches the remembered application on the session workspace directory and lists every application the host probed as installed." +description: "Web \"Open In...\" controls: the Session-header split button launching the remembered application on the workspace directory, and the document preview's default-application open and reveal controls for one file." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the browser surface of the open-in-app feature: a Session-header split button whose main button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and whose chevron lists every catalog application the host probed as installed. Availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md); mount the two packages together. A session without a workspace directory, or a host where nothing nameable is installed, renders no button at all. +This package provides the browser surface of the open-in-app feature. A Session-header split button opens the current session's workspace directory (the summary's `cwd`) in the remembered application, and its chevron lists every catalog application the host probed as installed; availability, icons, and launches come from the host routes of [`dsh-host-open-in-app`](../../host/open-in-app/README.md), so mount the two packages together. In the right Sidebar's document preview, an "Open" split button and an empty-state button open the previewed file in its default application or show its location, through the Session Remote. A host without the capability renders none of these controls. ## Table of Contents @@ -25,12 +25,14 @@ This package provides the browser surface of the open-in-app feature: a Session- ## Use this package -Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory. +Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../host/open-in-app/README.md); the pair composes the whole feature in two cordis.yml rows and this row takes no config. The Session header grows an "Open In..." split button whenever the host probed at least one installed catalog application and the session has a known workspace directory. The document preview of [`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.md) grows its file controls whenever the Host reports a desktop through the Session Remote's `session.canOpenWorkspacePath`; removing this row removes every control at once. ### What to expect The main button shows the remembered application's icon — the real application icon wherever the host extracts one (macOS bundle icons, Windows executable icons, Linux theme icons), a generic glyph where it serves none — and a design-system tooltip ("Open locally"); clicking launches immediately. The chevron opens a dense menu of the installed applications with the remembered one marked by a filled row. Availability is read once per page from the host; the last chosen application persists in the browser (`dsh.open-in-app.choice`), and a choice that is no longer installed falls back to the first available entry. A launch that finishes quickly leaves the button untouched — the dimmed busy treatment appears only after 250 ms in flight — and a failed launch shows the error tooltip and a red outline for two seconds. All copy lives in the bilingual `open-in-app` locale namespace; an application id the dictionaries cannot name is not offered. +In the document preview, the header's "Open ▾" split button opens the previewed file in the Host's default application from its main button, and its menu offers that action and "Show file location" (the file manager's reveal). A file the preview cannot render, whether an unsupported suffix such as video or an archive or a file the reader rejected as non-text or oversized, shows an "Open in default app" button in its empty state where Retry would otherwise stand. Both controls hand the Host the absolute path the file's metadata reports, appear only after the Host answered that a desktop is available, disable only while their own gesture settles, and announce a failed gesture once through a transient toast ("Could not open. Try again." or "Could not show the file location. Try again."); nothing stays on the control afterwards. + ----- @@ -39,7 +41,9 @@ The main button shows the remembered application's icon — the real application
    Implementation internals — click to expand -The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster. +The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. + +The file controls register on the document preview's `sidebar.right.tab.document.actions` and `sidebar.right.tab.document.unpreviewable` child slots, whose owner props carry the previewed file and its absolute Host path. A second page-lifetime controller ([`src/client/open-path.ts`](src/client/open-path.ts)) reads the Session Remote's `session.canOpenWorkspacePath` once per page into a desktop snapshot store both controls share, and runs each gesture through `session.openWorkspacePath` with the path and, for reveal, `action: 'reveal'`; a refused or rejected call resolves as the failure kind the control announces. The controls share one gesture hook that owns the pending flag and the toast, so a gesture from one control never restyles the other. The node half is an empty `apply` that keeps the plugin on the host roster.
    @@ -50,6 +54,8 @@ The plugin registers the split button on `conversation.session.header.utilities` - [dsh-host-open-in-app](../../host/open-in-app/README.md) — the host routes serving availability, icons, and launches, and the catalog behind them. - [dsh-session-log-export](../../session-query/session-log-export/README.md) — the sibling Session-header action. +- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.md) — the document preview declaring the header and empty-state child slots the file controls occupy. +- [ui-deliverables](../ui-deliverables/README.md) — the delivery cards, which still open declared files through their own routes. - [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots. ----- @@ -68,7 +74,9 @@ None; this package neither assembles nor sends a provider request. - **The dictionaries gate the menu.** A host catalog extension without a matching `app.` entry in both dictionaries stays invisible instead of showing a raw id; extending the catalog means extending [`dsh-host-open-in-app`](../../host/open-in-app/README.md) and this package's locales together. -- **Availability is read once per page.** An application installed while the page is open appears after a reload (and, host-side, after a host restart). +- **Availability is read once per page.** An application installed while the page is open appears after a reload (and, host-side, after a host restart); the desktop answer behind the file controls is read once per page as well. +- **One reveal label for every platform.** The Session Remote reports whether a desktop exists, not which file manager it runs, so the menu says "Show file location" rather than naming Finder or File Explorer as the delivery cards do. +- **The delivery cards keep their own opener.** [`ui-deliverables`](../ui-deliverables/README.md) still opens declared files through its own Session-and-event routes; folding those cards onto the file controls here is deferred to the [Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md). ### Dev Note @@ -76,8 +84,8 @@ None; this package neither assembles nor sends a provider request.
    Working context for maintainers — click to expand -The feature-level decisions, including the split into the host package and this surface, are recorded in the [promotion Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md). +The feature-level decisions, including the split into the host package and this surface, are recorded in the [promotion Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md); the document preview's file controls are recorded in the [default-application Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.md).
    -**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and one header-slot entry whose disposal the HMR-safety spec proves; availability and choice live in the controller's snapshot stores with no second copy to diverge. +**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and three slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index e954b7c71e..30d28f0681 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web 会话头部 \"Open In...\" 分体按钮:在记住的应用中打开会话 workspace 目录,并列出主机探测到已安装的全部应用。" +description: "Web \"Open In...\" 控件:会话头部在记住的应用中打开 workspace 目录的分体按钮,以及文档预览里用默认应用打开、显示单个文件位置的控件。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 open-in-app 功能的浏览器表面:会话头部的一个分体按钮,主按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用。可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由;两个包应一起挂载。没有 workspace 目录的会话、或没装任何可命名应用的主机,完全不渲染按钮。 +本包提供 open-in-app 功能的浏览器表面。会话头部的分体按钮在记住的应用中打开当前会话的 workspace 目录(会话摘要的 `cwd`),下拉箭头列出主机探测到已安装的全部 catalog 应用;可用性、图标与启动均来自 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 的主机路由,所以两个包要一起挂载。在右侧 Sidebar 的文档预览里,一个「打开」分体按钮和一个空态按钮经由 Session Remote 用默认应用打开当前文件或显示其位置。主机没有这项能力时,这些控件一个都不渲染。 ## 目录 @@ -25,12 +25,14 @@ kind: "package-reference" ## 使用本包 -把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。 +把本插件与 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 并排挂进 Web 组合;这对包用两行 cordis.yml 组成完整功能,本行不接受任何配置。只要主机探测到至少一个已安装的 catalog 应用且会话有已知的 workspace 目录,会话头部就会出现 "Open In..." 分体按钮。只要 Host 通过 Session Remote 的 `session.canOpenWorkspacePath` 报告有桌面,[`ui-sidebar-documentpreview`](../ui-sidebar-documentpreview/README.zh.md) 的文档预览就会长出文件控件;删掉这一行会一次去掉所有控件。 ### 预期行为 主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。 +在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「使用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。 + ----- @@ -39,7 +41,9 @@ kind: "package-reference"
    实现内幕——点击展开 -插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。 +插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 + +文件控件注册在文档预览的 `sidebar.right.tab.document.actions` 与 `sidebar.right.tab.document.unpreviewable` 子 slot 上,owner props 携带当前文件及其 Host 绝对路径。第二个页面生命周期的 controller([`src/client/open-path.ts`](src/client/open-path.ts))每页读取一次 Session Remote 的 `session.canOpenWorkspacePath`,写进两个控件共用的桌面 snapshot store,并把每个手势交给 `session.openWorkspacePath`,带上路径,定位时再带 `action: 'reveal'`;被拒绝或抛错的调用解析为控件要提示的失败种类。两个控件共用一个手势 hook,由它持有 pending 标志和 toast,所以一个控件的手势不会改变另一个控件的样子。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
    @@ -50,6 +54,8 @@ kind: "package-reference" - [dsh-host-open-in-app](../../host/open-in-app/README.zh.md)——提供可用性、图标与启动的主机路由,及其背后的目录。 - [dsh-session-log-export](../../session-query/session-log-export/README.zh.md)——会话头部的姊妹动作。 +- [ui-sidebar-documentpreview](../ui-sidebar-documentpreview/README.zh.md)——声明文件控件所占头部与空态子 slot 的文档预览。 +- [ui-deliverables](../ui-deliverables/README.zh.md)——交付卡片,仍通过自己的路由打开声明过的文件。 - [Web client 架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册 slot。 ----- @@ -68,7 +74,9 @@ kind: "package-reference" - **词典把守菜单。** 主机目录的新条目若在两份词典中没有对应的 `app.` 条目,将保持不可见而不是显示裸 id;扩展目录意味着同时扩展 [`dsh-host-open-in-app`](../../host/open-in-app/README.zh.md) 与本包的 locale。 -- **可用性每页只读一次。** 页面打开期间安装的应用要重新加载页面后才出现(主机侧还需主机重启)。 +- **可用性每页只读一次。** 页面打开期间安装的应用要重新加载页面后才出现(主机侧还需主机重启);文件控件背后的桌面回答同样每页只读一次。 +- **所有平台共用一个定位标签。** Session Remote 只报告有没有桌面,不报告它跑的是哪个文件管理器,所以菜单写「显示文件位置」,而不像交付卡片那样点名访达或文件资源管理器。 +- **交付卡片保留自己的打开器。** [`ui-deliverables`](../ui-deliverables/README.zh.md) 仍通过自己按 Session 与事件定位的路由打开声明过的文件;把这些卡片并到这里的文件控件上,延后到 [Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md) 记录的后续工作。 ### 开发备注 @@ -76,8 +84,8 @@ kind: "package-reference"
    维护者工作语境——点击展开 -功能层面的各项决定,包括拆分为主机包与本表面包,记录在[转正 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md)。 +功能层面的各项决定,包括拆分为主机包与本表面包,记录在[转正 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md);文档预览的文件控件记录在[默认应用 Agent Note](../../../.agents/notes/implemented/feature/2026-09-16-open-in-default-app-for-sidebar-files.zh.md)。
    -**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和一个 header slot 条目,HMR 安全性 spec 证明二者都会在资源释放时撤销;可用性与选择存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 +**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和三个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 0b8b93b67d..35fe0dacd0 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-open-in-app", - "description": "Web Session-header \"Open In...\" split button opening the session workspace directory in a locally installed application", + "description": "Web \"Open In...\" controls: the Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file", "version": "0.1.6-alpha.1", "publishConfig": { "access": "public" @@ -38,10 +38,14 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", - "@deepseek-ai/dsh-client-ui-session" + "@deepseek-ai/dsh-client-ui-session", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview" ], "platform": "web" } @@ -51,6 +55,8 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", @@ -59,6 +65,7 @@ "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-open-in-app": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css b/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css new file mode 100644 index 0000000000..f12b033102 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css @@ -0,0 +1,62 @@ +/* Sized to sit in the document header's 28px control row beside its round + icon buttons: a hairline pill with a 12px label and a narrow chevron half. */ + +.menuAnchor { + display: inline-flex; + flex: none; + align-self: center; +} + +.split { + display: inline-flex; + align-items: stretch; + box-sizing: border-box; + height: 28px; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l3); + border-radius: 14px; + font-family: var(--dsw-font-family); +} + +.main, +.chevron { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: none; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 16px; + white-space: nowrap; + cursor: pointer; +} + +.main { + padding: 5px 8px 5px 10px; +} + +.chevron { + padding: 5px 6px 5px 4px; + border-left: 0.5px solid var(--dsw-alias-border-l3); + color: var(--dsw-alias-label-secondary); +} + +.main:hover:not(:disabled), +.main:focus-visible, +.chevron:hover:not(:disabled), +.chevron:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Pending disables clicks without a restyle; only hover feedback pauses. */ +.main:disabled, +.chevron:disabled { + cursor: default; +} + +.menuIcon { + display: block; + width: 16px; + height: 16px; +} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx new file mode 100644 index 0000000000..8df8374dde --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -0,0 +1,132 @@ +/** + * Document-header split button: the main button opens the previewed file in + * its default application, the chevron's menu adds the file-manager reveal. + * Both path controls share the gesture hook declared here. + */ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { + IconChevronDownOutline14, IconFolderOpenOutline16, IconRightUpOutline16, Menu, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' +import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts' +import { useOpenFailureToast } from './open-failure-toast.tsx' +import type { NS } from './locales.ts' +import css from './OpenPathAction.module.css' + +/** Desktop availability and the gesture carrier injected into both path controls. */ +export interface OpenPathInjected { + hooks: { + openInAppDesktop: ObservableSnapshot + } + loadDesktop: () => Promise + openPath: (path: string, action: OpenInAppPathAction) => Promise +} + +/** Full props of the document-header contribution. */ +export type OpenPathActionProps = + PropsRuntime<'sidebar.right.tab.document.actions'> + & PropsLocale + & InjectFace + +/** What one path control needs from its props: the file, the injected face, and copy. */ +type PathGestureProps = Pick & { + t: TranslateNS +} + +/** + * Desktop availability, this control's own pending state, and a gesture + * runner announcing failures through this control's toast. Only the control + * that ran the gesture disables while it settles; failure never persists on + * the control. + * @param props - the file's Host path, the injected face, and copy. + * @returns availability, pending, the toast to render, and the runner. + */ +export function usePathGesture({ absolutePath, useOpenInAppDesktop, loadDesktop, openPath, t }: PathGestureProps): { + available: boolean + pending: boolean + toast: ReactNode + act: (action: OpenInAppPathAction) => void +} { + const desktop = useOpenInAppDesktop(value => value) + const [pending, setPending] = useState(false) + const { toast, show } = useOpenFailureToast() + useEffect(() => { + if (desktop === null) void loadDesktop() + }, [desktop, loadDesktop]) + return { + available: desktop === true, + pending, + toast, + act: (action) => { + setPending(true) + void openPath(absolutePath, action).then((failure) => { + if (failure !== null) show(t(`path.${failure}`)) + }).finally(() => { setPending(false) }) + }, + } +} + +/** + * Render the split button, or nothing until the Host reports a desktop. + * @param props - the previewed file, the injected face, and copy. + * @returns the split button with its menu and toast, or null. + */ +export function OpenPathAction(props: OpenPathActionProps): ReactNode { + const { t } = props + const [menuOpen, setMenuOpen] = useState(false) + const { available, pending, toast, act } = usePathGesture(props) + if (!available) return null + const run = (action: OpenInAppPathAction): void => { + setMenuOpen(false) + act(action) + } + return ( + <> + { setMenuOpen(false) }} + items={[ + { id: 'open', icon: , label: t('path.defaultApp') }, + { id: 'reveal', icon: , label: t('path.reveal') }, + ]} + onSelect={(id) => { run(id === 'reveal' ? 'reveal' : 'open') }} + anchor={( +
    + + + + +
    + )} + /> + {toast} + + ) +} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css new file mode 100644 index 0000000000..252c870293 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css @@ -0,0 +1,27 @@ +/* The empty state's capsule, sized like the preview's own Retry + (ui-sidebar-documentpreview TextPreview `.retry`) so the two recourses read + as one family. */ +.open { + display: inline-flex; + flex: none; + gap: 6px; + align-items: center; + height: 32px; + padding: 0 12px 0 14px; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + font-family: inherit; + background: transparent; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 16px; + cursor: pointer; +} + +.open:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Pending disables clicks without a restyle; only hover feedback pauses. */ +.open:disabled { + cursor: default; +} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx new file mode 100644 index 0000000000..0f2b752139 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx @@ -0,0 +1,40 @@ +/** Empty-state contribution: open a file the document preview cannot render in its default application. */ +import type { ReactNode } from 'react' +import { IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' +import type { NS } from './locales.ts' +import { usePathGesture, type OpenPathInjected } from './OpenPathAction.tsx' +import css from './OpenPathEmptyAction.module.css' + +/** Full props of the unpreviewable empty-state contribution. */ +export type OpenPathEmptyActionProps = + PropsRuntime<'sidebar.right.tab.document.unpreviewable'> + & PropsLocale + & InjectFace + +/** + * Render the default-application open button, or nothing until the Host reports a desktop. + * @param props - the unpreviewable file, the injected face, and copy. + * @returns the open button and its toast, or null. + */ +export function OpenPathEmptyAction(props: OpenPathEmptyActionProps): ReactNode { + const { t } = props + const { available, pending, toast, act } = usePathGesture(props) + if (!available) return null + return ( + <> + + {toast} + + ) +} diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index c3e4d88f98..05aae1e925 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -1,18 +1,28 @@ /** * Browser half of open-in-app: one Session-header split button opening the * session's workspace directory (the summary's `cwd`) in the remembered - * installed application. Availability arrives once per page from the host - * apps route; the last choice persists in the browser through the controller's - * persisted snapshot store. + * installed application, and the document preview's default-application + * controls opening one previewed file. Application availability arrives once + * per page from the host apps route and the last choice persists in the + * browser through the controller's persisted snapshot store; desktop + * availability for file paths arrives once per page from the Session Remote, + * which also runs the open and reveal gestures. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/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-client-ui-sidebar-documentpreview/client' +import type {} from '@deepseek-ai/dsh-api-gateway/client' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import type {} from '@deepseek-ai/dsh-api-session-controller/remote' import { OPEN_IN_APP_ICON_PREFIX } from '@deepseek-ai/dsh-host-open-in-app/shared' import { OpenInAppController } from './controller.ts' import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx' +import { OpenInAppPathController } from './open-path.ts' +import { OpenPathAction, type OpenPathInjected } from './OpenPathAction.tsx' +import { OpenPathEmptyAction } from './OpenPathEmptyAction.tsx' import { en, NS, zh, type OpenInAppKey } from './locales.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -23,17 +33,22 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } export type { OpenInAppActionInjected, OpenInAppActionProps } from './OpenInAppAction.tsx' +export type { OpenInAppPathAction, OpenInAppPathFailure, OpenInAppPathRemote } from './open-path.ts' +export type { OpenPathActionProps, OpenPathInjected } from './OpenPathAction.tsx' +export type { OpenPathEmptyActionProps } from './OpenPathEmptyAction.tsx' -/** Required services for locale registration and the header-slot contribution. */ -export const inject = ['sessions', 'slots', 'locale'] +/** Required services: sessions, the slot registry, copy, and the Remote carrier with its `session` namespace. */ +export const inject = ['sessions', 'slots', 'locale', 'remote', 'remote.session'] /** - * Client plugin body: register the dictionaries and the header split button. + * Client plugin body: register the dictionaries, the header split button, and + * the document preview's path controls. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { const controller = new OpenInAppController() void controller.load() + const paths = new OpenInAppPathController(ctx.remote) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'open-in-app: dictionaries') ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({ name: 'conversation.session.header.utilities', @@ -50,4 +65,21 @@ export function apply(ctx: ClientContext): void { iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX}/${appId}`, }), }, OpenInAppAction)) + const pathInjected = (): OpenPathInjected => ({ + hooks: { openInAppDesktop: paths.desktop }, + loadDesktop: () => paths.load(), + openPath: (path, action) => paths.openPath(path, action), + }) + ctx.slots.inject('sidebar.right.tab.document.actions', () => ctx.slots.register({ + name: 'sidebar.right.tab.document.actions', + id: 'open-in-app', + locale: NS, + inject: pathInjected, + }, OpenPathAction)) + ctx.slots.inject('sidebar.right.tab.document.unpreviewable', () => ctx.slots.register({ + name: 'sidebar.right.tab.document.unpreviewable', + id: 'open-in-app', + locale: NS, + inject: pathInjected, + }, OpenPathEmptyAction)) } diff --git a/packages/client/ui-open-in-app/src/client/locales.ts b/packages/client/ui-open-in-app/src/client/locales.ts index a5cadacd05..3142716615 100644 --- a/packages/client/ui-open-in-app/src/client/locales.ts +++ b/packages/client/ui-open-in-app/src/client/locales.ts @@ -1,4 +1,4 @@ -/** `open-in-app` namespace dictionaries. */ +/** `open-in-app` namespace dictionaries: the workspace split button and the document-preview path controls. */ /** Dictionary namespace owned by this plugin. */ export const NS = 'open-in-app' @@ -44,6 +44,14 @@ export const zh = { 'open.error': '打开失败', 'menu.toggle': '选择打开方式', 'menu.aria': '打开方式', + 'path.open': '打开', + 'path.open.tooltip': '用默认应用打开', + 'path.more': '更多打开方式', + 'path.defaultApp': '用默认应用打开', + 'path.reveal': '显示文件位置', + 'path.unpreviewable': '使用默认应用打开', + 'path.openError': '打开失败,请重试', + 'path.revealError': '无法显示文件位置,请重试', ...PRODUCT_NAMES, 'app.finder': '访达', 'app.explorer': '文件资源管理器', @@ -58,6 +66,14 @@ export const en: Record = { 'open.error': 'Failed to open', 'menu.toggle': 'Choose an app to open in', 'menu.aria': 'Open in', + 'path.open': 'Open', + 'path.open.tooltip': 'Open in the default app', + 'path.more': 'More ways to open', + 'path.defaultApp': 'Open in default app', + 'path.reveal': 'Show file location', + 'path.unpreviewable': 'Open in default app', + 'path.openError': 'Could not open. Try again.', + 'path.revealError': 'Could not show the file location. Try again.', ...PRODUCT_NAMES, 'app.finder': 'Finder', 'app.explorer': 'File Explorer', diff --git a/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx b/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx new file mode 100644 index 0000000000..721e2b9f6f --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/open-failure-toast.tsx @@ -0,0 +1,25 @@ +/** Per-control failure banner for path gestures. */ +import { useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { IconWarningOutline16, Toast } from '@deepseek-ai/dsh-client-ui-primitives' + +/** + * Transient failure banner owned by the control that initiated the gesture, + * so one failed request announces once, from the control the user pressed. + * @returns `toast` to render inside the control and `show` to announce one + * failure with resolved copy; announcing again replays the banner. + */ +export function useOpenFailureToast(): { toast: ReactNode; show: (text: string) => void } { + // The seq keys the banner so the same failure text replays instead of staying silently in place. + const seq = useRef(0) + const [banner, setBanner] = useState<{ seq: number; text: string } | null>(null) + return { + toast: banner === null + ? null + : } onDone={() => { setBanner(null) }} />, + show: (text) => { + seq.current += 1 + setBanner({ seq: seq.current, text }) + }, + } +} diff --git a/packages/client/ui-open-in-app/src/client/open-path.ts b/packages/client/ui-open-in-app/src/client/open-path.ts new file mode 100644 index 0000000000..161fcf104f --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/open-path.ts @@ -0,0 +1,75 @@ +/** + * Default-application actions on one Host file path, over the Session Remote: + * whether this Host can hand a path to a desktop, and the open or reveal call. + * The desktop answer is read once per page; a failed read renders no control. + */ +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionOpenWorkspacePathRequest } from '@deepseek-ai/dsh-api-session-controller/types' + +/** What a path gesture asks of the Host desktop: the default application, or the file manager showing the file. */ +export type OpenInAppPathAction = 'open' | 'reveal' + +/** Failure kind of one settled path gesture, for the initiating control to announce. */ +export type OpenInAppPathFailure = 'openError' | 'revealError' + +/** The slice of the Client Remote the path actions call. */ +export interface OpenInAppPathRemote { + readonly session: { + /** Whether the Host can hand a workspace path to a native desktop. */ + canOpenWorkspacePath(): Promise> + /** Open one Host path in its default application, or reveal it in the file manager. */ + openWorkspacePath(request: SessionOpenWorkspacePathRequest, signal?: AbortSignal): Promise> + } +} + +/** Page-lifetime desktop availability and the open/reveal carrier shared by every path control. */ +export class OpenInAppPathController { + /** Whether the Host desktop can open paths; null until the Host answered, false also after a failed read. */ + readonly desktop: SnapshotStore = createSnapshotStore(null) + private loading: Promise | undefined + + /** + * @param remote - the Session Remote namespace answering availability and running gestures. + */ + constructor(private readonly remote: OpenInAppPathRemote) {} + + /** + * Read desktop availability once per controller life; concurrent calls share the read. + * @returns after availability is published. + */ + load(): Promise { + this.loading ??= this.run() + return this.loading + } + + /** + * Open one Host path in its default application, or reveal it in the file manager. + * @param path - absolute path on the Host, as the file's metadata reports it. + * @param action - default application open, or file-manager reveal. + * @returns the failure kind to announce, or `null` once the Host acknowledged. + */ + async openPath(path: string, action: OpenInAppPathAction): Promise { + const request: SessionOpenWorkspacePathRequest = action === 'reveal' ? { path, action } : { path } + let ok = false + try { + ok = (await this.remote.session.openWorkspacePath(request)).ok + } catch { + // Swallows carrier rejections: a Host that cannot be reached failed the + // gesture like a Host that refused it, and the control announces that. + } + return ok ? null : action === 'open' ? 'openError' : 'revealError' + } + + private async run(): Promise { + let available = false + try { + const result = await this.remote.session.canOpenWorkspacePath() + available = result.ok && result.value + } catch { + // Swallows carrier rejections: an unreachable Host reads as no desktop, + // so the preview shows no path control rather than a broken one. + } + this.desktop.set(available) + } +} diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index eefdae245d..9b289c7b64 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -1,7 +1,7 @@ /** - * Browser-half lifecycle over the real SlotRegistry: the dictionary and - * header-slot registrations with fiber teardown proving removal (HMR safety) - * and the injected controller face. + * Browser-half lifecycle over the real SlotRegistry: the dictionary, + * header-slot, and document-preview path registrations with fiber teardown + * proving removal (HMR safety) and the injected controller faces. */ import { Context } from '@deepseek-ai/cordis' @@ -9,16 +9,26 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import { apply, inject, type OpenInAppActionInjected } from '../src/client/index.ts' +import { apply, inject, type OpenInAppActionInjected, type OpenPathInjected } from '../src/client/index.ts' import { apply as nodeApply } from '../src/index.ts' import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx' +import { OpenPathAction } from '../src/client/OpenPathAction.tsx' +import { OpenPathEmptyAction } from '../src/client/OpenPathEmptyAction.tsx' import { en, NS, zh } from '../src/client/locales.ts' afterEach(() => { vi.unstubAllGlobals() }) -/** Boot the browser half over a real slot tree that declares the header list. */ +/** The Session Remote slice the path controls call; answers a desktop and acknowledges every gesture. */ +const remote = { + session: { + canOpenWorkspacePath: vi.fn(async () => ({ ok: true as const, value: true })), + openWorkspacePath: vi.fn(async () => ({ ok: true as const, value: { opened: true as const } })), + }, +} + +/** Boot the browser half over a real slot tree that declares the header list and the document-preview seats. */ async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { const ctx = new Context() await ctx.plugin(SlotRegistry).await() @@ -26,10 +36,14 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) ctx.provide('sessions', {}) ctx.provide('locale', new LocaleRuntime(ctx)) + ctx.provide('remote', remote as never) + ctx.provide('remote.session', remote.session as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() return { ctx, fiber } @@ -41,7 +55,29 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { describe('open-in-app browser half', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'slots', 'locale', 'remote', 'remote.session']) + }) + + it('registers both document-preview path controls behind one desktop answer, and fiber teardown removes them', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 }))) + const { ctx, fiber } = await bench() + const header = ctx.slots.entries('sidebar.right.tab.document.actions')[0] + const empty = ctx.slots.entries('sidebar.right.tab.document.unpreviewable')[0] + expect(header?.component).toBe(OpenPathAction) + expect(empty?.component).toBe(OpenPathEmptyAction) + expect(header?.options).toMatchObject({ id: 'open-in-app' }) + const face = (header?.inject as unknown as () => OpenPathInjected)() + const emptyFace = (empty?.inject as unknown as () => OpenPathInjected)() + expect(emptyFace.hooks.openInAppDesktop).toBe(face.hooks.openInAppDesktop) + expect(face.hooks.openInAppDesktop.getSnapshot()).toBeNull() + await Promise.all([face.loadDesktop(), emptyFace.loadDesktop()]) + expect(remote.session.canOpenWorkspacePath).toHaveBeenCalledOnce() + expect(face.hooks.openInAppDesktop.getSnapshot()).toBe(true) + expect(await face.openPath('/w/clip.mp4', 'reveal')).toBeNull() + expect(remote.session.openWorkspacePath).toHaveBeenLastCalledWith({ path: '/w/clip.mp4', action: 'reveal' }) + await fiber.dispose() + expect(ctx.slots.entries('sidebar.right.tab.document.actions').map(entry => entry.options.id)).not.toContain('open-in-app') + expect(ctx.slots.entries('sidebar.right.tab.document.unpreviewable').map(entry => entry.options.id)).not.toContain('open-in-app') }) it('registers the header split button, and fiber teardown removes it (HMR safety)', async () => { diff --git a/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx new file mode 100644 index 0000000000..819b6473e2 --- /dev/null +++ b/packages/client/ui-open-in-app/tests/open-path-action.client.spec.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +/** + * The two document-preview path controls: hidden until the Host reports a + * desktop, busy only for their own gesture, and announcing a failure once + * through their own toast. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { OpenPathAction, type OpenPathActionProps } from '../src/client/OpenPathAction.tsx' +import { OpenPathEmptyAction, type OpenPathEmptyActionProps } from '../src/client/OpenPathEmptyAction.tsx' +import type { OpenInAppPathAction, OpenInAppPathFailure } from '../src/client/open-path.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() + vi.useRealTimers() +}) + +const t = makeTranslate(zh) +const FILE = { sessionId: 's-1' as SessionId, path: 'work/clip.mp4' } +const ABSOLUTE_PATH = '/host/project/work/clip.mp4' + +interface Bench { + props: OpenPathActionProps & OpenPathEmptyActionProps + desktop: ReturnType> + loadDesktop: ReturnType + openPath: ReturnType Promise>> +} + +function bench(over: { desktop?: boolean | null; openPath?: Bench['openPath'] } = {}): Bench { + const desktop = createSnapshotStore(over.desktop === undefined ? true : over.desktop) + const loadDesktop = vi.fn(async () => {}) + const openPath = over.openPath ?? vi.fn(async () => null) + function useSelector(source: { getSnapshot(): T }): (select: (value: T) => R) => R { + return select => select(source.getSnapshot()) + } + const props = { + file: FILE, + absolutePath: ABSOLUTE_PATH, + useOpenInAppDesktop: useSelector(desktop), + loadDesktop, + openPath, + t, + } as unknown as Bench['props'] + return { props, desktop, loadDesktop, openPath } +} + +describe('OpenPathAction visibility', () => { + it('renders nothing and asks for the desktop answer while it is unknown, and nothing without a desktop', () => { + const unknown = bench({ desktop: null }) + const { container } = render() + expect(container.innerHTML).toBe('') + expect(unknown.loadDesktop).toHaveBeenCalledOnce() + cleanup() + const absent = bench({ desktop: false }) + expect(render().container.innerHTML).toBe('') + expect(absent.loadDesktop).not.toHaveBeenCalled() + }) +}) + +describe('OpenPathAction gestures', () => { + it('opens the file in its default application from the main button and reveals it from the menu', async () => { + const b = bench() + render() + const main = screen.getByRole('button', { name: zh['path.open'] }) + await act(async () => { fireEvent.click(main) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open') + fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.reveal'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'reveal') + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('offers the default application from the menu too, and Escape closes the menu without a gesture', async () => { + const b = bench() + render() + const more = screen.getByRole('button', { name: zh['path.more'] }) + fireEvent.click(more) + expect(more.getAttribute('aria-expanded')).toBe('true') + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.openPath).not.toHaveBeenCalled() + fireEvent.click(more) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: zh['path.defaultApp'] })) }) + expect(b.openPath).toHaveBeenLastCalledWith(ABSOLUTE_PATH, 'open') + }) + + it('disables both halves while its own gesture settles and leaves no failure on the control', async () => { + const settled = Promise.withResolvers() + const b = bench({ openPath: vi.fn(() => settled.promise) }) + const { container } = render() + const main = screen.getByRole('button', { name: zh['path.open'] }) + fireEvent.click(main) + expect(main).toHaveProperty('disabled', true) + expect(screen.getByRole('button', { name: zh['path.more'] })).toHaveProperty('disabled', true) + expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('busy') + fireEvent.click(main) + expect(b.openPath).toHaveBeenCalledOnce() + await act(async () => { settled.resolve('openError'); await settled.promise }) + expect(main).toHaveProperty('disabled', false) + expect(container.querySelector('[data-open-path]')?.getAttribute('data-state')).toBe('idle') + expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) + }) +}) + +describe('OpenPathEmptyAction', () => { + it('renders nothing without a desktop and opens the file in its default application with one', async () => { + const absent = bench({ desktop: false }) + expect(render().container.innerHTML).toBe('') + cleanup() + const b = bench() + render() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + expect(b.openPath).toHaveBeenCalledWith(ABSOLUTE_PATH, 'open') + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('announces a failed open once through its own toast, which leaves after its hold and fade', async () => { + vi.useFakeTimers() + const b = bench({ openPath: vi.fn(async () => 'openError' as const) }) + render() + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.unpreviewable'] })) }) + expect(screen.getAllByRole('alert')).toHaveLength(1) + expect(screen.getByRole('alert').textContent).toContain(zh['path.openError']) + act(() => { vi.advanceTimersByTime(4_000) }) + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/packages/client/ui-open-in-app/tests/open-path.client.spec.ts b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts new file mode 100644 index 0000000000..2e8560f7cf --- /dev/null +++ b/packages/client/ui-open-in-app/tests/open-path.client.spec.ts @@ -0,0 +1,71 @@ +/** Path controller behavior: the once-per-page desktop read and the open/reveal outcomes over the Session Remote. */ + +import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionOpenWorkspacePathRequest } from '@deepseek-ai/dsh-api-session-controller/types' +import { OpenInAppPathController, type OpenInAppPathRemote } from '../src/client/open-path.ts' + +type SessionRemote = OpenInAppPathRemote['session'] + +function remoteOf(over: Partial = {}): OpenInAppPathRemote { + return { + session: { + canOpenWorkspacePath: async () => ({ ok: true, value: true }), + openWorkspacePath: async () => ({ ok: true, value: { opened: true } }), + ...over, + }, + } +} + +describe('OpenInAppPathController desktop availability', () => { + it('starts unanswered and shares one read across concurrent loads', async () => { + const canOpenWorkspacePath = vi.fn(async () => ({ ok: true as const, value: true })) + const controller = new OpenInAppPathController(remoteOf({ canOpenWorkspacePath })) + expect(controller.desktop.getSnapshot()).toBeNull() + await Promise.all([controller.load(), controller.load()]) + await controller.load() + expect(canOpenWorkspacePath).toHaveBeenCalledOnce() + expect(controller.desktop.getSnapshot()).toBe(true) + }) + + it('publishes no desktop for a refusing Host, a failed answer, and a rejecting carrier', async () => { + const refusing = new OpenInAppPathController(remoteOf({ canOpenWorkspacePath: async () => ({ ok: true, value: false }) })) + await refusing.load() + expect(refusing.desktop.getSnapshot()).toBe(false) + + const failed = new OpenInAppPathController(remoteOf({ + canOpenWorkspacePath: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'boom', {}) }), + })) + await failed.load() + expect(failed.desktop.getSnapshot()).toBe(false) + + const rejecting = new OpenInAppPathController(remoteOf({ canOpenWorkspacePath: () => Promise.reject(new Error('down')) })) + await rejecting.load() + expect(rejecting.desktop.getSnapshot()).toBe(false) + }) +}) + +describe('OpenInAppPathController gestures', () => { + it('sends the default-application open without an action and the reveal with one', async () => { + const openWorkspacePath = vi.fn(async (_request: SessionOpenWorkspacePathRequest, _signal?: AbortSignal) => + ({ ok: true as const, value: { opened: true as const } })) + const controller = new OpenInAppPathController(remoteOf({ openWorkspacePath })) + expect(await controller.openPath('/w/clip.mp4', 'open')).toBeNull() + expect(await controller.openPath('/w/clip.mp4', 'reveal')).toBeNull() + expect(openWorkspacePath.mock.calls.map(call => call[0])).toEqual([ + { path: '/w/clip.mp4' }, + { path: '/w/clip.mp4', action: 'reveal' }, + ]) + }) + + it('names the failed gesture for a refused answer and for a rejecting carrier', async () => { + const refused = new OpenInAppPathController(remoteOf({ + openWorkspacePath: async () => ({ ok: false, error: new RemoteError('gateway/internal', 'boom', {}) }), + })) + expect(await refused.openPath('/w/clip.mp4', 'open')).toBe('openError') + expect(await refused.openPath('/w/clip.mp4', 'reveal')).toBe('revealError') + + const rejecting = new OpenInAppPathController(remoteOf({ openWorkspacePath: () => Promise.reject(new Error('down')) })) + expect(await rejecting.openPath('/w/clip.mp4', 'open')).toBe('openError') + }) +}) diff --git a/packages/client/ui-open-in-app/tsconfig.json b/packages/client/ui-open-in-app/tsconfig.json index 4264bb7cab..55f2ded3e1 100644 --- a/packages/client/ui-open-in-app/tsconfig.json +++ b/packages/client/ui-open-in-app/tsconfig.json @@ -11,6 +11,12 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../api/gateway/tsconfig.client.json" + }, + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, @@ -35,6 +41,9 @@ { "path": "../ui-session" }, + { + "path": "../ui-sidebar-documentpreview" + }, { "path": "../ui-slots" } diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 6bf77e5972..07be1c50ac 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -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-sidebar-documentpreview/README.md -README.md: d4e114aa6e12171d61f8a5284fdbe923916fe0d9 -README.zh.md: 32cb704d29fd0009fdf7a51235617c25029295f0 +README.md: c0cb4796036d3ca20e7f3bbd239c9816a177feeb +README.zh.md: 7b47c00b85b6c128946e3070d805b52b2a0781ef diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index d4e114aa6e..c0cb479603 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -1,5 +1,5 @@ --- -description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, and plain-text fallback." +description: "Document previews in the right Sidebar: shared file loading and controls, selectable Markdown, code, image, PDF and HTML renderers, plain-text fallback, and child slots handing a file to the Host desktop." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. The Sidebar tab kind is `text`. +Preview readable files in the right Sidebar and choose among registered renderers without opening another tab. Markdown and code receive accumulated text pages; PDF, HTML, and common images receive complete bytes; unknown file extensions use plain text. The tab owns loading, file status, renderer selection, wrap, and reload, while document bodies register through the same metadata registry and child slot. Two further child slots let another plugin hand the previewed file to the Host desktop from the header and from the empty state of a file the preview cannot render. The Sidebar tab kind is `text`. ## Table of Contents @@ -27,7 +27,7 @@ Preview readable files in the right Sidebar and choose among registered renderer ## What it registers - **The type** — `ctx.sidebarRightTabs.register(...)` with id `@deepseek-ai/dsh-client-ui-sidebar-documentpreview` (this implementation's identity in the tab system, and the key its body registers under), kind `text`, pattern `dsh-resource://file/**`, band `fallback`. `canOpen` accepts only Session addresses, whose paths may be relative or absolute; bare `absolute` addresses are not claimed. A type registered at the `extension` or `builtin` band for a narrower pattern (say `*.png`) takes those addresses; other supported files land here. The whole address is the content identity, so two files with one name in different directories, or one path under two sessions, are two tabs; the decoded basename is the tab title, and the keyed `sidebar.right.pane.tab.title` seat places its extension-specific `FileTypeIcon` before that title. -- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. +- **The body** — the keyed `sidebar.right.pane.tab` seat under the type's id. Its fixed header shows the Host's absolute path when available, otherwise the requested path; directories use tertiary label colour, the name uses primary label colour, and a clipped path retains and fades toward its final segment while its tooltip exposes the full value. A dropdown selects among matching renderers and plain text; a suffix declared binary (bitmap images, PDF) drops the plain-text fallback, and a single remaining candidate renders no viewer control at all. A known binary container suffix with no registered renderer (audio, video, archives, office documents) shows an unsupported empty state under the path header — the file-type icon and one explanatory line — and never issues a read. Two Session-scoped list child slots hand the previewed file to other plugins once its metadata reports a Host path, both with `file` and `absolutePath` as owner props: `sidebar.right.tab.document.actions` renders after the header's own controls in every state that shows the header, and `sidebar.right.tab.document.unpreviewable` renders in the unsupported empty state and in the empty state of a readable file the preview cannot render (`not-text`, `too-large`), where Retry would otherwise stand; a carrier or unclassified read failure keeps Retry, and a missing or non-regular path offers only its explanation. The shipped occupant of both is [`ui-open-in-app`](../ui-open-in-app/README.md). A wrap toggle appears only when the selected renderer declares `wrap: true`; its glyph describes the mode the click selects, and the per-tab preference starts on. Reload stays in this header, not the Sidebar's tab strip. The body reaches every pane edge; each renderer owns its content inset and may own an inner scrollport. This intentionally differs from the Files tab's 2px right-side scrollbar offset: previews keep the full pane width so edge-to-edge HTML and code scrollports end at the pane edge. - **Shared loading and view state**, session-scoped and bucketed by tab id. The store holds accumulated pages or complete bytes, read and observed versions, loading/failure state, renderer choice, scroll offset, wrap, and the answered navigation revision. The ordinary inject face calls Remote readers and writes through declared store actions. Reloads and loading-mode changes retire older requests; the tab's abort signal forgets its state. Document implementations register metadata with `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` and a body under the same `id` in the keyed, Session-scoped `sidebar.right.tab.document` child slot. `binaryExtensions` names the suffixes among `extensions` whose bytes are not readable text; a file matching one loses the plain-text fallback among its viewer choices. Own both registrations with effects and wait for the child slot through `ctx.slots.inject`. Bodies receive `resourceAddress`, prepared `content`, `wrap`, `scrollportRef`, and the standard `useTabInfo`/`useResource` hooks; they do not receive a custom resource loader. A renderer that owns an inner scrolling element attaches `scrollportRef` to it, and the owner returns to the shared body when that element unmounts. Metadata declares `loading: 'text-pages'` or `'bytes-complete'`. The registry retains all matching alternatives: `extension` (the default) ranks above `builtin`, then longer suffixes rank first, then registration order. The dropdown preserves a selected implementation while it remains available; removing it selects the next candidate. Builtin bodies use these same registrations. @@ -43,7 +43,7 @@ A tab uses the Session address built by `fileAddressFor`, carrying a relative or The body reads its record, navigation and lifetime through `useTabInfo().tab`. `useResource<'file'>(tab.contentId)` supplies metadata; ordinary inject callbacks supply content reads: - The resource snapshot contains only `status`, `value`, and `failure`; `value` is `WorkspaceFileStat` metadata. Content reads do not wait for the first metadata frame once the provider is available. Observation failures take precedence over Preview's change notice; neither automatically replaces loaded content. -- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, explanation, and retry; a later failure retains loaded content and adds the retry below it. +- **Text pages** — plain text, Markdown, and code read through an inject callback to `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`. The first mount reads page one; scrolling to the body end or **Load more** requests the next page until `eof`. The owner delivers the accumulated prefix as `{ kind: 'text', text, pages, eof }`, including source offsets and line counts. Markdown and code render that prefix incrementally; they do not render each page as a separate document. A newer-version page past page one restarts from the beginning rather than mixing versions. A failure before any content fills the body with the file-type icon, the explanation, and the recourse the failure names (Retry, the unpreviewable child slot, or nothing); a later failure retains loaded content and adds the retry below it. - **Complete bytes** — PDF, HTML, and common images use an inject callback to `remote.workspaceFiles.readAll(sessionId, path, signal)`. `rpc.ts` decodes the wire base64 into `data: Uint8Array` for `{ kind: 'bytes', data }`. The Host's `maxFileBytes` cap rejects oversized files rather than truncating them. PDF copies retained bytes before worker transfer, keeping the Preview buffer usable. Bytes stay in transient view state, never persisted layouts or Session JSONL. Loading-mode changes retire previous results. - **Reload** — only the current Preview tab rereads through its Remote callbacks, preserving its scroll preference and retiring older requests. Its change notice compares the read version and the observation captured at read start with later `resource.value.version`; an already observed version does not become a new change after refresh. Reads neither refresh shared metadata nor clear another tab's notice. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index 32cb704d29..7b47c00b85 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -1,5 +1,5 @@ --- -description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,并以纯文本兜底。" +description: "右侧 Sidebar 的文档预览:共享文件加载与控件,可选 Markdown、代码、图片、PDF 和 HTML 渲染器,以纯文本兜底,并提供把文件交给 Host 桌面的子 slot。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。Sidebar tab 的 kind 为 `text`。 +在右侧 Sidebar 预览可读文件,无需另开 tab 即可切换已注册的渲染器。Markdown 和代码接收累计文本页;PDF、HTML 和常见图片接收完整字节;未知文件扩展名使用纯文本。tab 负责加载、文件状态、渲染器选择、换行和重新载入,文档正文通过同一元数据注册表与子 slot 注册。另有两个子 slot 让别的插件从头部和从无法渲染文件的空态把当前文件交给 Host 桌面。Sidebar tab 的 kind 为 `text`。 ## 目录 @@ -27,7 +27,7 @@ kind: "package-reference" ## 注册了什么 - **类型** —— `ctx.sidebarRightTabs.register(...)`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-documentpreview`(这个实现在 tab 系统中的身份,也是其正文注册所用的键),kind `text`,pattern `dsh-resource://file/**`,档位 `fallback`。`canOpen` 只接受 Session 地址,其中路径可为相对或绝对路径;不认领裸 `absolute` 地址。在 `extension` 或 `builtin` 档以更窄 pattern(比如 `*.png`)注册的类型接走那些地址;其他受支持文件落到这里。整个地址就是内容身份,所以不同目录下同名的两个文件、或同一路径在两个会话之下,是两个 tab;解码后的 basename 是 tab 标题,keyed slot `sidebar.right.pane.tab.title` 会在标题前放置按扩展名选择的 `FileTypeIcon`。 -- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 +- **正文** —— keyed slot `sidebar.right.pane.tab`,键为类型的 id。固定头部在可用时显示 Host 的绝对路径,否则显示请求路径;目录使用三级标签色,文件名使用一级标签色,路径过长时保留末段并向开头淡出,提示中仍提供完整值。下拉菜单可在匹配的渲染器与纯文本间切换;声明为二进制的后缀(位图图片、PDF)不提供纯文本兜底,只剩一个候选时完全不渲染查看器控件。已知的二进制容器后缀(音频、视频、压缩包、office 文档)没有注册渲染器时,在路径头部下方显示不支持预览的空态——文件类型图标加一行说明——并且不会发起读取。两个 Session 作用域的 list 子 slot 在文件元数据报出 Host 路径后把当前文件交给其他插件,owner props 都是 `file` 与 `absolutePath`:`sidebar.right.tab.document.actions` 在每个显示头部的状态里渲染在头部自有控件之后;`sidebar.right.tab.document.unpreviewable` 渲染在不支持预览的空态里,以及可读但本预览无法渲染的文件(`not-text`、`too-large`)的空态里,占据原本放重试的位置;载体或未分类的读取失败仍保留重试,不存在或非普通文件的路径只给说明。两者已发布的占位者都是 [`ui-open-in-app`](../ui-open-in-app/README.zh.md)。仅当所选渲染器声明 `wrap: true` 时显示换行开关;图标表示点击后切换到的模式,该偏好按 tab 保存,初始开启。重新载入仍在此头部,不放入 Sidebar 的 tab 条。正文贴合格的每条边,各渲染器自行提供内容留白,并可拥有内部滚动区。这与 Files tab 右侧预留 2px 滚动条间距的布局有意不同:Preview 使用格的完整宽度,使贴边 HTML 与代码滚动区终止于格的边缘。 - **共享加载与视图状态**,会话作用域、按 tab id 分桶。store 持有累计页或完整字节、读取与观察版本、加载/失败状态、渲染器选择、滚动位置、换行和已响应的导航 revision。普通 inject face 调用 Remote 读取,并经声明的 store action 写入。重新载入和加载模式变化会淘汰旧请求;tab 的中止信号清理其状态。 文档实现在 `ctx.documentPreviews.register({ id, extensions, binaryExtensions?, priority, title, loading, wrap? })` 注册元数据,并以相同 `id` 向 keyed、Session 作用域的子 slot `sidebar.right.tab.document` 注册正文。`binaryExtensions` 列出 `extensions` 中字节不可按文本阅读的后缀;匹配这类后缀的文件在其查看器选项中不再提供纯文本兜底。两处注册都由 effect 持有,通过 `ctx.slots.inject` 等待子 slot。正文接收 `resourceAddress`、准备好的 `content`、`wrap`、`scrollportRef` 和标准 `useTabInfo`/`useResource` 钩子,不接收自定义资源加载器。拥有内部滚动元素的渲染器把 `scrollportRef` 挂到该元素上;该元素卸载后,owner 恢复使用共享正文。元数据声明 `loading: 'text-pages'` 或 `'bytes-complete'`。注册表保留所有匹配备选:`extension`(默认)优先于 `builtin`,随后按更长的后缀、再按注册顺序排列。所选实现仍可用时,下拉选择保持不变;移除后选择下一个候选。内置正文也使用相同注册方式。 @@ -43,7 +43,7 @@ tab 使用 `fileAddressFor` 构造的 Session 地址,携带相对或绝对路 正文通过 `useTabInfo().tab` 读取记录、导航和生命周期。`useResource<'file'>(tab.contentId)` 提供元数据,普通 inject 回调提供内容读取: - 资源快照仅包含 `status`、`value` 和 `failure`;`value` 是 `WorkspaceFileStat` 元数据。提供方可用后,内容读取无需等待首个元数据帧。观察失败优先于 Preview 的变更提示显示;两者都不会自动替换已加载内容。 -- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明与重试按钮填满正文;较晚的失败保留已有内容并在其下提供重试。 +- **文本页** —— 纯文本、Markdown 和代码通过 inject 回调调用 `remote.workspaceFiles.read(sessionId, path, { offset }, signal)`。首次挂载读取第一页;滚动到正文末尾或点击 **加载更多** 会读取下一页,直到 `eof`。owner 以 `{ kind: 'text', text, pages, eof }` 提供累计前缀,包含源码偏移和行数。Markdown 和代码增量渲染此前缀,不把每页当成独立文档。第一页之后到达的更新版本页会使读取从头开始,避免混合版本。尚无内容时,失败会以文件类型图标、说明和该失败对应的出路(重试、unpreviewable 子 slot,或什么都不给)填满正文;较晚的失败保留已有内容并在其下提供重试。 - **完整字节** —— PDF、HTML 和常见图片通过 inject 回调调用 `remote.workspaceFiles.readAll(sessionId, path, signal)`。`rpc.ts` 将线路上的 base64 解码为 `data: Uint8Array`,供 `{ kind: 'bytes', data }` 使用。Host 的 `maxFileBytes` 上限拒绝超大文件,不截断。PDF 在传给 worker 前复制保留的字节,使 Preview 缓冲区仍可使用。字节仅保存在临时视图状态中,绝不进入持久布局或 Session JSONL。加载模式变化会淘汰先前结果。 - **重新载入** —— 仅当前 Preview tab 通过自己的 Remote 回调重读,保留滚动偏好并淘汰旧请求。变更提示将读取版本及起读时的观察版本与后续 `resource.value.version` 比较;刷新前已观察到的版本不会被当成新变化。读取既不刷新共享元数据,也不清除其它 tab 的提示。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx index 9430ec53a8..e98863b09c 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.tsx @@ -18,7 +18,7 @@ import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStor import { FileTypeIcon, IconRefreshOutline16, Menu, Tooltip, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { TextInjected } from './face.ts' -import { failureLine } from './failure-line.ts' +import { emptyFailureRecourse, failureLine } from './failure-line.ts' import { IconNowrapFill16, IconWrapFill16 } from './icons.tsx' import { LoadingIndicator } from './LoadingIndicator.tsx' import { hostFileOf } from './rpc.ts' @@ -82,7 +82,7 @@ export interface TextPreviewInjected extends TextInjected { /** The body's composed props: the tab, its navigation, the shared store and face, and copy. */ export type TextPreviewProps = & PropsRuntime<'sidebar.right.pane.tab'> - & PropsRenderSlots<'sidebar.right.tab.document'> + & PropsRenderSlots<'sidebar.right.tab.document' | 'sidebar.right.tab.document.actions' | 'sidebar.right.tab.document.unpreviewable'> & PropsStore & InjectFace & PropsLocale<'sidebarDocumentPreview'> @@ -120,7 +120,10 @@ export function TextPreview({ const pathRef = useRef(null) const pathTextRef = useRef(null) const [menuOpen, setMenuOpen] = useState(false) - const displayPath = meta.value?.absolutePath ?? current?.complete?.absolutePath ?? file.path + const absolutePath = meta.value?.absolutePath ?? current?.complete?.absolutePath + const displayPath = absolutePath ?? file.path + // Contributions that hand the file to the Host wait for its Host path. + const fileOwner = absolutePath === undefined ? undefined : { file, absolutePath } usePathClipped(pathRef, pathTextRef, displayPath, state !== undefined) // Every tab of this type is a `file` resource address, so its params are the // `file` type's; the union is narrowed on the one field read, not validated. @@ -195,18 +198,21 @@ export function TextPreview({ }, [mode, loaded, current?.complete, current?.eof]) // A known binary suffix with no matching renderer never reads: no plain-text - // fallback, no viewer control, only the path and the unsupported line. + // fallback, no viewer control, only the path, the unsupported line, and the + // contributions that hand the file to the Host. if (selected === undefined && unviewable) { const { name: unsupportedName } = pathPartsOf(displayPath) return (
    + {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}

    {t('unsupportedFile')}

    + {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.unpreviewable', fileOwner)}
    @@ -315,6 +321,7 @@ export function TextPreview({ + {fileOwner !== undefined && renderSlot('sidebar.right.tab.document.actions', fileOwner)}
    ) : ( - // With no content, retry the selected renderer's read; metadata - // observation remains owned by the resource provider. + // With no content, the failure names its recourse: retry the + // selected renderer's read when a second read may resolve it, hand + // a file this preview cannot render to the unpreviewable + // contributions, and offer nothing for a path with nothing to show. + // Metadata observation remains owned by the resource provider.

    {failureLine(t, current.failure)}

    - + {emptyFailureRecourse(current.failure) === 'open' && fileOwner !== undefined + && renderSlot('sidebar.right.tab.document.unpreviewable', fileOwner)} + {emptyFailureRecourse(current.failure) === 'retry' && ( + + )}
    ))} {mode === 'text-pages' && current !== undefined && loaded.length > 0 && !current.eof && current.failure === undefined && ( diff --git a/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts index 11f7cd1c24..2f76b49bf4 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts @@ -2,6 +2,7 @@ import type { PropsRuntime, SlotHookFactory } from '@deepseek-ai/dsh-client-ui-slots' import type { UseSidebarRightTabInfo } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import type { RefCallback } from 'react' +import type { SessionFile } from '../rpc.ts' /** One loaded text window, retaining source line positions. */ export interface DocumentTextPage { @@ -41,6 +42,34 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } } + /** + * Header toolbar contributions acting on the previewed file, rendered + * after the preview's own controls once the file's Host path is known. + */ + 'sidebar.right.tab.document.actions': { + kind: 'list' + scope: 'session' + owner: { + /** The previewed file: the Session the read runs under and the path handed to the Host. */ + readonly file: SessionFile + /** The file's absolute path on the Host, from its metadata. */ + readonly absolutePath: string + } + } + /** + * Empty-state contributions for a file this preview cannot render, + * offered where Retry would stand once the file's Host path is known. + */ + 'sidebar.right.tab.document.unpreviewable': { + kind: 'list' + scope: 'session' + owner: { + /** The previewed file: the Session the read runs under and the path handed to the Host. */ + readonly file: SessionFile + /** The file's absolute path on the Host, from its metadata. */ + readonly absolutePath: string + } + } } } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/failure-line.ts b/packages/client/ui-sidebar-documentpreview/src/client/failure-line.ts index edf82ae9c6..625e3843cf 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/failure-line.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/failure-line.ts @@ -33,3 +33,25 @@ export function failureLine(t: TranslateNS<'sidebarDocumentPreview'>, failure: R default: return t('error.unavailable', { message: failure.message }) } } + +/** The action an empty preview offers beside its failure line. */ +export type EmptyFailureRecourse = 'open' | 'retry' | 'none' + +/** + * Choose the empty state's action for one settled read failure. + * @param failure - the settled Remote failure. + * @returns `open` for a readable file this preview cannot render, `none` for a + * path with nothing to show or open, `retry` for the rest: carrier and + * unclassified failures a second read may resolve. + */ +export function emptyFailureRecourse(failure: RemoteFailure): EmptyFailureRecourse { + switch (failure.code) { + case 'workspace-file/not-text': + case 'workspace-file/too-large': + return 'open' + case 'workspace-file/not-found': + case 'workspace-file/not-regular-file': + return 'none' + default: return 'retry' + } +} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/index.ts b/packages/client/ui-sidebar-documentpreview/src/client/index.ts index ef76084e07..e677e0e0b5 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/index.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/index.ts @@ -100,6 +100,8 @@ export function apply(ctx: ClientContext): void { name: 'sidebar.right.pane.tab', key: TEXTPREVIEW_ID, locale: NS, store, children: { 'sidebar.right.tab.document': { kind: 'keyed', scope: 'session', inject: { hooks: { tabInfo: documentTabInfoFactory } } }, + 'sidebar.right.tab.document.actions': { kind: 'list', scope: 'session' }, + 'sidebar.right.tab.document.unpreviewable': { kind: 'list', scope: 'session' }, }, inject: (sessionId, actions): TextPreviewInjected => ({ ...face(sessionId, actions), hooks: { documentPreviews: source } }), }, diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx index 0fa5189b44..8b31dce6ac 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-toolbar.client.spec.tsx @@ -25,7 +25,9 @@ function codeProps(h: ReturnType): TextPreviewProps { ...props, useDocumentPreviews: selector => selector([definition]), // This adapter only receives the concrete document slot, not an arbitrary generic key. - renderSlot: (_key, owner) => } t={key => key} />, + renderSlot: (key: string, owner: unknown) => key === 'sidebar.right.tab.document' + ? } t={key => key} /> + : null, } } @@ -104,18 +106,19 @@ describe('document toolbar', () => { await pending.promise }) h.bytes.mockReturnValueOnce(pending.promise) - const renderSlot = vi.fn(() => null) + const renderSlot = vi.fn((_key: string) => null) + const documentRenders = (): number => renderSlot.mock.calls.filter(call => call[0] === 'sidebar.right.tab.document').length const props: TextPreviewProps = { ...h.props(), useDocumentPreviews: selector => selector([binary]), renderSlot } const view = render() expect(view.getByRole('status').hasAttribute('data-document-loading')).toBe(true) expect(view.getByRole('status').getAttribute('aria-label')).toBe('loading') expect(view.container.querySelector('[data-textpreview-body]')?.firstElementChild).toBe(view.getByRole('status')) - expect(renderSlot).not.toHaveBeenCalled() + expect(documentRenders()).toBe(0) await act(async () => { pending.resolve(result) await pending.promise }) - expect(renderSlot).toHaveBeenCalled() + expect(documentRenders()).toBeGreaterThan(0) expect(view.queryByRole('status')).toBeNull() }) @@ -123,7 +126,7 @@ describe('document toolbar', () => { const h = harness() const result = { ok: true as const, value: { absolutePath: ABSOLUTE_PATH, version: 'v1', offset: 0, data: btoa('all'), bytes: 3, eof: true } } const read = h.bytes.mockResolvedValueOnce({ - ok: false, error: new RemoteError('workspace-file/not-found', 'Missing file', { path: ABSOLUTE_PATH }), + ok: false, error: new RemoteError('gateway/internal', 'socket closed', {}), }).mockResolvedValue(result) h.useResource.mockReturnValue({ status: 'loading', value: undefined, failure: undefined }) const props: TextPreviewProps = { @@ -245,6 +248,11 @@ describe('document toolbar', () => { expect(view.container.querySelector('[data-textpreview-path]')).not.toBeNull() expect(view.container.querySelector('[data-document-viewer-menu]')).toBeNull() expect(view.container.querySelector('[data-textpreview-tool="reload"]')).toBeNull() + // Both handoff seats receive the file once its Host path is known. + const header = view.container.querySelector('[data-textpreview-path]')?.parentElement + expect(header?.querySelector('[data-slot="sidebar.right.tab.document.actions"]')?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + const empty = view.container.querySelector('[data-textpreview-unsupported]') + expect(empty?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')?.getAttribute('data-slot-file')).toBe('work/clip.mp4') expect(h.read).not.toHaveBeenCalled() expect(h.bytes).not.toHaveBeenCalled() h.controller.abort() diff --git a/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts index c43804d8ef..134cf3e822 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/failure-line.client.spec.ts @@ -6,7 +6,7 @@ import type { RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' // The namespace declaration `TranslateNS<'sidebarDocumentPreview'>` resolves against. import type {} from '../src/client/index.ts' -import { failureLine } from '../src/client/failure-line.ts' +import { emptyFailureRecourse, failureLine } from '../src/client/failure-line.ts' /** Key-echoing translate that also shows its parameters, so a formatted value is visible. */ const t: TranslateNS<'sidebarDocumentPreview'> = (key, params) => @@ -33,3 +33,20 @@ describe('failureLine', () => { expect(failureLine(t, failure('gateway/internal', {}, 'socket closed'))).toBe('error.unavailable(message=socket closed)') }) }) + +describe('emptyFailureRecourse', () => { + it('hands a readable file the preview cannot render to the Host', () => { + expect(emptyFailureRecourse(failure('workspace-file/not-text'))).toBe('open') + expect(emptyFailureRecourse(failure('workspace-file/too-large', { limit: 512 }))).toBe('open') + }) + + it('offers nothing for a path with nothing to show or open', () => { + expect(emptyFailureRecourse(failure('workspace-file/not-found'))).toBe('none') + expect(emptyFailureRecourse(failure('workspace-file/not-regular-file'))).toBe('none') + }) + + it('retries carrier and unclassified failures', () => { + expect(emptyFailureRecourse(failure('gateway/internal'))).toBe('retry') + expect(emptyFailureRecourse(failure('someday/a-new-code'))).toBe('retry') + }) +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts index 200626232c..a240fa7c9a 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/fixtures.client.ts @@ -122,9 +122,18 @@ export function harness(script: Record> onTestFinished(() => { controller.abort() }) const tabActions = { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn(), replace: vi.fn() } const definitions = [textBodyDefinition(() => t('viewer.text'))] - const renderSlot: TextPreviewProps['renderSlot'] = (_key, owner, opts) => createElement(TextBody, { - ...owner, useTabInfo: opts.hookContext, sessionId: SESSION, useResource, - } as unknown as DocumentPreviewProps) + // The document seat renders the plain body; the file-handoff seats render a + // marker carrying what the owner handed them. + const renderSlot: TextPreviewProps['renderSlot'] = (key: string, owner: unknown, opts?: { hookContext?: unknown }) => + key === 'sidebar.right.tab.document' + ? createElement(TextBody, { + ...owner as object, useTabInfo: opts?.hookContext, sessionId: SESSION, useResource, + } as unknown as DocumentPreviewProps) + : createElement('div', { + 'data-slot': key, + 'data-slot-file': (owner as { file: SessionFile }).file.path, + 'data-slot-path': (owner as { absolutePath: string }).absolutePath, + }) const props = (navigation: { params?: unknown; revision: number } = { revision: 1 }) => ({ useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, diff --git a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx index 733bc6decc..b29d663a27 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/text-preview.client.spec.tsx @@ -88,7 +88,9 @@ function codeProps(h: ReturnType, navigation: { params?: unknown return { ...props, useDocumentPreviews: selector => selector([definition]), - renderSlot: (_key, owner) => } t={key => key} />, + renderSlot: (key: string, owner: unknown) => key === 'sidebar.right.tab.document' + ? } t={key => key} /> + : null, } } @@ -253,15 +255,16 @@ describe('TextPreview — pages', () => { }) it('says why a page failed and retries the same page', async () => { - const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) + const h = harness({ 1: failure('gateway/internal', { path: PATH }) }) const view = render() await settle() const failed = view.container.querySelector('[data-textpreview-failed]') - expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text') - expect(view.container.textContent).toContain('error.notText') + expect(failed?.getAttribute('data-textpreview-failed')).toBe('gateway/internal') + expect(view.container.textContent).toContain('error.unavailable(message=boom)') // Nothing read yet: the failure stands as the body, under the file's type sheet. expect(failed?.querySelector('svg')).not.toBeNull() expect(view.container.querySelector('[data-textpreview-more]')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull() h.script(1, page(1, ['one'], true)) click(view.container, '[data-textpreview-retry]') await settle() @@ -270,6 +273,40 @@ describe('TextPreview — pages', () => { expect(view.container.querySelector('[data-textpreview-failed]')).toBeNull() }) + it('hands a readable file it cannot render to the unpreviewable seat instead of Retry', async () => { + const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) + const view = render() + await settle() + const failed = view.container.querySelector('[data-textpreview-failed]') + expect(failed?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-text') + expect(view.container.textContent).toContain('error.notText') + expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull() + const seat = failed?.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]') + expect(seat?.getAttribute('data-slot-file')).toBe(PATH) + expect(seat?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + // The header's own handoff seat receives the same file. + const actions = view.container.querySelector('[data-slot="sidebar.right.tab.document.actions"]') + expect(actions?.getAttribute('data-slot-path')).toBe(ABSOLUTE_PATH) + }) + + it('offers neither Retry nor a handoff for a path with nothing to show', async () => { + const h = harness({ 1: failure('workspace-file/not-found', { path: PATH }) }) + const view = render() + await settle() + expect(view.container.querySelector('[data-textpreview-failed]')?.getAttribute('data-textpreview-failed')).toBe('workspace-file/not-found') + expect(view.container.querySelector('[data-textpreview-retry]')).toBeNull() + expect(view.container.querySelector('[data-slot="sidebar.right.tab.document.unpreviewable"]')).toBeNull() + }) + + it('withholds the handoff seats until the file reports a Host path', async () => { + const h = harness({ 1: failure('workspace-file/not-text', { path: PATH }) }) + h.setVersion(undefined) + const view = render() + await settle() + expect(view.container.querySelector('[data-textpreview-failed]')).not.toBeNull() + expect(view.container.querySelector('[data-slot]')).toBeNull() + }) + it('says why a later page failed on a line under the pages already read', async () => { const h = harness({ 1: page(1, ['a'], false), 2: failure('workspace-file/too-large', { path: PATH, limit: 1024 }) }) const view = render() @@ -429,7 +466,7 @@ describe('TextPreview — navigation and view', () => { it('rebinds scrolling when the selected Slot body is replaced without changing the renderer id', async () => { const h = harness({ 1: page(1, ['a', 'b', 'c'], true) }) const code = codeProps(h, { revision: 1 }) - const fallback: TextPreviewProps = { ...code, renderSlot: () =>
    } + const fallback: TextPreviewProps = { ...code, renderSlot: (key: string) => key === 'sidebar.right.tab.document' ?
    : null } const view = render() await settle() const outer = body(view.container) @@ -465,10 +502,11 @@ describe('TextPreview — navigation and view', () => { const props: TextPreviewProps = { ...base, useDocumentPreviews: selector => selector(definitions), - renderSlot: (_key, owner, opts) => { - const documentOwner = owner as unknown as OwnerOf<'sidebar.right.tab.document'> - if (opts.entryKey === 'code') return key} /> - if (opts.entryKey === PLAIN_BODY_ID) return + renderSlot: (key: string, owner: unknown, opts?: { entryKey?: string }) => { + if (key !== 'sidebar.right.tab.document') return null + const documentOwner = owner as OwnerOf<'sidebar.right.tab.document'> + if (opts?.entryKey === 'code') return key} /> + if (opts?.entryKey === PLAIN_BODY_ID) return return
    }, } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 8752a7de89..8600979078 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2449,7 +2449,125 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:24', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:25', + }, + { + key: 'sidebar.right.tab.document.actions', + kind: 'list', + scope: 'session', + summary: 'Header toolbar contributions acting on the previewed file, rendered after the preview\'s own controls once the file\'s Host path is known.', + doc: 'Header toolbar contributions acting on the previewed file, rendered after the preview\'s own controls once the file\'s Host path is known.', + 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: [ + '/** The file one tab reads: the session the read runs under and the path handed to the Host. */\nexport interface SessionFile {\n /** The Session whose workspace resolves relative paths. */\n readonly sessionId: SessionId\n /** The path the Host receives, absolute or relative to the addressed Session\'s workspace. */\n readonly path: string\n}', + ], + ownerPropsReferences: [ + 'SessionId', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted', + occupants: [ + 'client-ui-open-in-app OpenPathAction id \'open-in-app\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.actions\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:46', + }, + { + key: 'sidebar.right.tab.document.unpreviewable', + kind: 'list', + scope: 'session', + summary: 'Empty-state contributions for a file this preview cannot render, offered where Retry would stand once the file\'s Host path is known.', + doc: 'Empty-state contributions for a file this preview cannot render, offered where Retry would stand once the file\'s Host path is known.', + 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: [ + '/** The file one tab reads: the session the read runs under and the path handed to the Host. */\nexport interface SessionFile {\n /** The Session whose workspace resolves relative paths. */\n readonly sessionId: SessionId\n /** The path the Host receives, absolute or relative to the addressed Session\'s workspace. */\n readonly path: string\n}', + ], + ownerPropsReferences: [ + 'SessionId', + ], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-sidebar-documentpreview), so it exists while that entry is mounted', + occupants: [ + 'client-ui-open-in-app OpenPathEmptyAction id \'open-in-app\'', + ], + replaceRisk: 'none', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.tab.document.unpreviewable\', () => ctx.slots.register(\n { name: \'sidebar.right.tab.document.unpreviewable\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-sidebar-documentpreview/src/client/document/contract.ts:57', }, { key: 'sidebar.right.tab.guide', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6e695e59b..6e4598a6db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3488,6 +3488,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-api-gateway': + specifier: workspace:^ + version: link:../../api/gateway + '@deepseek-ai/dsh-api-remotes': + specifier: workspace:^ + version: link:../../api/remotes '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller @@ -3512,6 +3518,9 @@ importers: '@deepseek-ai/dsh-client-ui-session': specifier: workspace:^ version: link:../ui-session + '@deepseek-ai/dsh-client-ui-sidebar-documentpreview': + specifier: workspace:^ + version: link:../ui-sidebar-documentpreview '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots diff --git a/snapshots/web/document-preview/document.expected.md b/snapshots/web/document-preview/document.expected.md index 03286e76b8..203b6d2ff4 100644 --- a/snapshots/web/document-preview/document.expected.md +++ b/snapshots/web/document-preview/document.expected.md @@ -51,3 +51,5 @@ - State: unsupported - Line: Preview is not available for this file type yet. +- Header control: Open +- Empty-state control: Open in default app diff --git a/snapshots/web/document-preview/paging.patch.yml b/snapshots/web/document-preview/paging.patch.yml index b75ea4838d..96788cc5aa 100644 --- a/snapshots/web/document-preview/paging.patch.yml +++ b/snapshots/web/document-preview/paging.patch.yml @@ -3,3 +3,7 @@ name: '@deepseek-ai/dsh-api-workspace-files' config: maxLines: 64 +# Pin desktop availability so the default-application controls render identically in headless CI. +- id: session-controller + config: + nativeOpen: true diff --git a/snapshots/web/seeded-history/file-preview.expected.md b/snapshots/web/seeded-history/file-preview.expected.md index d11b2479b2..44d3d39044 100644 --- a/snapshots/web/seeded-history/file-preview.expected.md +++ b/snapshots/web/seeded-history/file-preview.expected.md @@ -3,4 +3,7 @@ - img - button "Read the file again": - img +- button "Open" +- button "More ways to open": + - img - text: alpha From 4233590de6d1a3cbaa4d8db888116a85f634315d Mon Sep 17 00:00:00 2001 From: turtle1999 <301751225+turtle1999@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:16:33 +0800 Subject: [PATCH 024/225] fix(workspace-changes): ignore scrubbed ambient Git config --- .../workspace-changes/README.i18n.yaml | 4 ++-- .../deliverables/workspace-changes/README.md | 2 +- .../workspace-changes/README.zh.md | 2 +- .../deliverables/workspace-changes/src/git.ts | 3 ++- .../workspace-changes/tests/git.spec.ts | 18 +++++++++++++++++- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/deliverables/workspace-changes/README.i18n.yaml b/packages/deliverables/workspace-changes/README.i18n.yaml index aaf8176288..bb8403671e 100644 --- a/packages/deliverables/workspace-changes/README.i18n.yaml +++ b/packages/deliverables/workspace-changes/README.i18n.yaml @@ -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/deliverables/workspace-changes/README.md -README.md: 06b40006750cb6fb8cad614af9c275f9b7243e02 -README.zh.md: 8c49cfebb8cf79117c55150d8cc4b0de8ba3ac3a +README.md: 168a30cd993c4b53a34785dd6f4e31bd0f007d8a +README.zh.md: 42a96ffdd72efc6fcfee11eb973fa687021d6a0b diff --git a/packages/deliverables/workspace-changes/README.md b/packages/deliverables/workspace-changes/README.md index 06b4000675..168a30cd99 100644 --- a/packages/deliverables/workspace-changes/README.md +++ b/packages/deliverables/workspace-changes/README.md @@ -57,7 +57,7 @@ Each file carries a durable `path` — relative to the working directory inside One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository once per Session and the Session's temporary object directory is created, then `add --all --ignore-errors` into a temporary index seeded from the repository's index and `write-tree` produce the tree id; an unreadable file is skipped and reported through git's exit code 1, which the snapshot accepts. Each turn holds its own state object, so a record still running for an interrupted turn keeps that turn's files when the next turn starts. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the temporary directory and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline; the same step queues the whole-file capture of the path a `write`, `edit`, or mutating `str_replace_editor` call names, so the copy precedes the edit, and `tool/result` events only mark that the turn has results to record. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for captured paths inside the work tree, a second copy and line comparison of each uncovered path, the appended event, and the summary kept under the event's sequence beside each listed file's two content sources, a path in a snapshot tree or a copy. A comparison is computed when asked for: `ls-tree -l` locates and sizes a snapshot side and `cat-file blob` reads it under `maxFileBytes`, copies are read from disk, and both sides go through the same line comparison with its timeout. `turn/end` records again only when tool results settled after the last record attempt, which covers aborted, failed, and steered turns without repeating a failed attempt; an empty list after an earlier record supersedes it. The repository's index is only read. -Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work, forget the summaries, and remove the temporary directory. +Git runs through the `subprocess` capability with a scrubbed environment, `GIT_CONFIG_COUNT=0` (ambient indexed configuration is excluded because the credential scrub removes its key entries), `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work, forget the summaries, and remove the temporary directory. **Runtime invariant:** No companion is published. Event listeners are effect-owned and the recorder owns the summaries, the snapshot trees, and the captured copies for its Session's lifetime; no independent observation can diverge from them. diff --git a/packages/deliverables/workspace-changes/README.zh.md b/packages/deliverables/workspace-changes/README.zh.md index 8c49cfebb8..42a96ffdd7 100644 --- a/packages/deliverables/workspace-changes/README.zh.md +++ b/packages/deliverables/workspace-changes/README.zh.md @@ -57,7 +57,7 @@ kind: "package-reference" 每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 每个 Session 只定位一次仓库并创建 Session 的临时对象目录,然后以仓库 index 为种子在临时 index 上执行 `add --all --ignore-errors` 与 `write-tree` 得到 tree id;不可读的文件被跳过并以 git 的退出码 1 报告,快照接受这个退出码。每轮持有自己的状态对象,因此被中断的轮次仍在运行的记录会在下一轮开始后保留自己那一轮的文件。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向临时目录、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生;同一步骤还排入对 `write`、`edit` 或有修改作用的 `str_replace_editor` 调用所指路径的整文件捕获,因此副本先于编辑,`tool/result` 事件只标记本轮有结果需要记录。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内已捕获路径的 `check-ignore`、对每个未覆盖路径的第二次复制与逐行对比、追加事件,以及按事件序号保存摘要和每个所列文件两侧的内容来源,即快照树中的路径或一份副本。对比在被请求时计算:`ls-tree -l` 定位快照一侧并取其大小,`cat-file blob` 在 `maxFileBytes` 之内读出,副本从磁盘读取,两侧随后走同一个带超时的逐行对比。`turn/end` 仅在最后一次记录尝试之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次,且不会重复一次失败的尝试;早先记录之后的空列表会取代它。仓库的 index 只读取。 -git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。 +git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_CONFIG_COUNT=0`(凭据清理会移除索引配置的键,因此不继承这些环境配置)、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作、忘记摘要并删除临时目录。 **运行时不变式:** 不发布伴生入口。事件监听归 effect 所有,记录器在其 Session 存活期间同时拥有摘要、快照树与捕获的副本;没有独立观察会与它们分歧。 diff --git a/packages/deliverables/workspace-changes/src/git.ts b/packages/deliverables/workspace-changes/src/git.ts index c49ad66897..3766a2f082 100644 --- a/packages/deliverables/workspace-changes/src/git.ts +++ b/packages/deliverables/workspace-changes/src/git.ts @@ -65,7 +65,8 @@ export class GitRunner { }, graceMs: TERMINATE_GRACE_MS, signal, - env: { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env }, + // The subprocess credential scrub removes ambient GIT_CONFIG_KEY_n entries. + env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env }, }) const outcome = await handle.done if (signal.aborted) { diff --git a/packages/deliverables/workspace-changes/tests/git.spec.ts b/packages/deliverables/workspace-changes/tests/git.spec.ts index 35982a681b..ea7948939f 100644 --- a/packages/deliverables/workspace-changes/tests/git.spec.ts +++ b/packages/deliverables/workspace-changes/tests/git.spec.ts @@ -1,7 +1,7 @@ /** Git command bounds, snapshot recovery, and diff failure reporting. */ import { chmod, mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local' import { GitRunner, blobText, diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, treeBlob } from '../src/git.ts' @@ -29,6 +29,22 @@ async function runner(limits = { timeoutMs: 30_000, outputMaxBytes: 1024 * 1024 const signal = new AbortController().signal describe('GitRunner', () => { + it('ignores ambient indexed Git configuration after the credential scrub', async () => { + const cwd = await scratchDir('dsh-git-env-', cleanups) + git(cwd, 'init', '-q', '-b', 'main') + const { git: command } = await runner() + vi.stubEnv('GIT_CONFIG_COUNT', '1') + vi.stubEnv('GIT_CONFIG_KEY_0', 'core.bare') + vi.stubEnv('GIT_CONFIG_VALUE_0', 'true') + try { + const result = await command.run(['rev-parse', '--is-bare-repository'], { cwd, signal }) + expect(result.exitCode, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('false') + } finally { + vi.unstubAllEnvs() + } + }) + it('reports timeouts and external aborts as failures', async () => { const cwd = await scratchDir('dsh-git-runner-', cleanups) const { git: slow } = await runner({ timeoutMs: 1, outputMaxBytes: 1024 }) From 51e9e1599d416ceeef5bcce13f210e8bae52bb57 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 17 Sep 2026 10:38:09 +0800 Subject: [PATCH 025/225] fix(web): address file-reference review feedback --- ...9-15-markdown-file-preview-links.i18n.yaml | 4 +- .../2026-09-15-markdown-file-preview-links.md | 4 +- ...26-09-15-markdown-file-preview-links.zh.md | 4 +- .github/workflows/ci.yml | 3 + .../evals/file-references/README.i18n.yaml | 4 +- .../evals/file-references/README.md | 8 +-- .../evals/file-references/README.zh.md | 8 +-- .../evals/file-references/run.py | 17 +++--- .../evals/file-references/test_eval.py | 55 ++++++++++++++++--- .../ui-deliverables/tests/prompt.host.spec.ts | 8 +++ .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/MarkdownText.module.css | 8 ++- .../ui-primitives/src/markdown/file-link.ts | 1 + .../ui-primitives/src/markdown/render.tsx | 2 +- .../tests/markdown-file-links.client.spec.tsx | 12 +++- .../web/markdown-file-links/session.v3.jsonl | 13 +---- .../system-prompt.expected.md | 2 +- 19 files changed, 112 insertions(+), 49 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml index d0db537fe3..df0d00e82b 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md -2026-09-15-markdown-file-preview-links.md: 337385c2218d8b38b292094e90f12a46f0a7297c -2026-09-15-markdown-file-preview-links.zh.md: a14974bbd078f895b2598fe18943c8a8d55cbb88 +2026-09-15-markdown-file-preview-links.md: d35f1d11cb303eb0b1e47b3d37f8e8b0aedc1937 +2026-09-15-markdown-file-preview-links.zh.md: 8eae197032abbe833a6e6655799043412c1c10b9 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md index 337385c221..d35f1d11cb 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -28,7 +28,7 @@ The Web file-reference prompt asks for a link on every existing-file mention out ## Consequences -Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. Web prompt sidecars pin the assembled guidance. +Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar and keep the final colon treatment aligned with the shipped paragraph. Historical input hashes and the evaluation runner’s keyless checks run in Linux PR CI. The evaluation runner waits for live process-group members to terminate, excluding zombies that can retain a group id after exit; a PID-existence probe cannot establish running work. ## Prompt evaluation @@ -44,6 +44,6 @@ The development comparison ran four read-only tasks three times per variant. E c | Full-path labels / named labels | 44/373 | 89/471 | 118/403 | | Balanced 11-answer coverage | 95.2% | 93.0% | 79.0% | -The colon revision is a separate development run. Its first three answers contained 28 mixed `:start-Lend` suffixes, retained as failures. Explicitly forbidding both `#` and `L` in the suffix yielded 51 structurally valid links in one repeated explanation, but no colon labels, three missing links, and 27 unnecessary directory qualifiers. A subsequent user-accepted demo produced 18 structurally valid links, including three colon labels and no mixed suffix. Neither run establishes reliable compliance or exhaustive factual accuracy. Authentication failures, the timeout, and all first answers remain in the local experiment archive; PR #4255 carries the extended 69-row metrics and task-rubric tables. +The colon revision is a separate development run. Its first three answers contained 28 mixed `:start-Lend` suffixes, retained as failures. Explicitly forbidding both `#` and `L` in the suffix yielded 51 structurally valid links in one repeated explanation, but no colon labels, three missing links, and 27 unnecessary directory qualifiers. A subsequent user-accepted demo produced 18 structurally valid links, including three colon labels and no mixed suffix. Neither run establishes reliable compliance or exhaustive factual accuracy. Authentication failures, the timeout, and all first answers remain in the local experiment archive; the [committed metrics](../../../../packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md) carry the extended 69-row metrics and task-rubric tables. Reusable [evaluation inputs and rubrics](../../../../packages/client/ui-deliverables/evals/file-references/README.md) live beside the prompt owner, with compact historical observations and failure records. Their manual runner uses the supported headless profile and keeps generated outputs outside version control. Package ownership keeps prompt changes and their regression criteria together; the repository performance benchmark tree has different input and timing requirements. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md index a14974bbd0..8eae197032 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -28,7 +28,7 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外 ## Consequences -源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。Web 提示词 sidecar 固定组合后的指导。 +源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导,并保持最终冒号评测文本与正式段落一致。历史输入哈希和评测运行器的无密钥检查在 Linux PR CI 中运行。评测运行器等待仍在运行的进程组成员终止,排除退出后仍可能保留组 id 的僵尸进程;PID 存在性检查无法确认进程仍在运行。 ## Prompt evaluation @@ -44,6 +44,6 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外 | 完整路径标签 / 名称标签 | 44/373 | 89/471 | 118/403 | | 平衡的 11 份回答覆盖率 | 95.2% | 93.0% | 79.0% | -冒号修订是独立的开发验证。最初三份回答包含 28 个混合 `:start-Lend` 后缀,均保留为失败。明确禁止后缀包含 `#` 和 `L` 后,一次重复讲解生成 51 个结构有效的链接,但没有冒号标签,仍有三次漏链和 27 个不必要的目录限定。随后用户接受的 demo 生成 18 个结构有效的链接,其中三个使用冒号标签,没有混合后缀。这些运行都不能证明稳定遵循或完整事实正确性。认证失败、超时和所有首答仍保留在本地实验归档中;PR #4255 包含扩展的 69 行指标及任务 rubric 表。 +冒号修订是独立的开发验证。最初三份回答包含 28 个混合 `:start-Lend` 后缀,均保留为失败。明确禁止后缀包含 `#` 和 `L` 后,一次重复讲解生成 51 个结构有效的链接,但没有冒号标签,仍有三次漏链和 27 个不必要的目录限定。随后用户接受的 demo 生成 18 个结构有效的链接,其中三个使用冒号标签,没有混合后缀。这些运行都不能证明稳定遵循或完整事实正确性。认证失败、超时和所有首答仍保留在本地实验归档中;[已提交的指标](../../../../packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md)包含扩展的 69 行指标及任务 rubric 表。 可复用的[评测输入和 rubric](../../../../packages/client/ui-deliverables/evals/file-references/README.zh.md) 与提示词所有者放在一起,并保留精简历史观察和失败记录。人工触发的运行器使用受支持的 headless profile,生成输出不进入版本控制。包内归属让提示词变化与回归标准一起维护;仓库性能 benchmark 目录有不同的输入和计时要求。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2139231c06..d56db14598 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -475,6 +475,9 @@ jobs: - name: Test production blame scoring run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py' + - name: Test file-reference evaluation tools + run: python -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v + - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest diff --git a/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml b/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml index fa43b76322..e4252b2dcb 100644 --- a/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml +++ b/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml @@ -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/evals/file-references/README.md -README.md: e6c5520cf92cacc80d917f600fdeb0352f6c5c7b -README.zh.md: ea94964ec996ee6e206b6f141e6b71063e2e2a88 +README.md: d4ad2a81209ad2786d491ddf7aab3858543c5328 +README.zh.md: 7a542e2fa3d4789b323037bf3a10d362ecb83b02 diff --git a/packages/client/ui-deliverables/evals/file-references/README.md b/packages/client/ui-deliverables/evals/file-references/README.md index e6c5520cf9..d4ad2a8120 100644 --- a/packages/client/ui-deliverables/evals/file-references/README.md +++ b/packages/client/ui-deliverables/evals/file-references/README.md @@ -21,7 +21,7 @@ This owner-local evaluation compares file-reference guidance using four unchange ## Run a cohort -Requires Python 3.9+, a POSIX host, Node and repository dependencies, and a built checkout (`pnpm run build`). Run from the repository root. Supply the normal DSH environment file with `--env-file`, or inherit its environment; the runner does not read credential values. `--model` is explicit, with the DeepSeek provider and high reasoning effort. Each attempt gets fresh DSH home, agent home, Session, and an archived HEAD workspace; the executable is this checkout's built `dsh --profile headless` launcher. +Requires Python 3.9+, a POSIX host with `ps`, Node and repository dependencies, and a built checkout (`pnpm run build`). Run from the repository root. Supply the normal DSH environment file with `--env-file`, or inherit its environment; the runner does not read credential values. `--model` is explicit, with the DeepSeek provider and high reasoning effort. Each attempt gets fresh DSH home, agent home, Session, and an archived HEAD workspace; the executable is this checkout's built `dsh --profile headless` launcher. For one real smoke, set `DSH_EVAL_ENV` to your normal environment-file path and run: @@ -29,9 +29,9 @@ For one real smoke, set `DSH_EVAL_ENV` to your normal environment-file path and python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV" ``` -Omit `--variant`, `--case`, and `--repetitions` for the original three variants, four tasks, and three repetitions. `--prepare-only` records inputs without calling the API. The default 480-second attempt bound can be shortened with `--timeout`. Runs execute serially and stop at the first failed attempt; no retry or resume replaces an output. The printed directory lives under ignored `.artifacts/file-reference-evals/`, retaining input hashes, source archive, outputs, workspaces, Sessions, and failures. A supplied `--output` must be new and inside `.artifacts/`. +Omit `--variant`, `--case`, and `--repetitions` for the original three variants, four tasks, and three repetitions. `--prepare-only` records inputs without calling the API. The default 480-second attempt bound can be shortened with `--timeout`. Runs execute serially and stop at the first failed attempt; no retry or resume replaces an output. The printed directory lives under ignored `.artifacts/file-reference-evals/`, retaining input hashes, source archive, outputs, workspaces, Sessions, and failures. A supplied `--output` must be new and inside `.artifacts/`; missing parent directories are created. The runner reaps the launcher and waits for every process-group member to terminate; zombies awaiting their parent’s reap do not count as running work. -The portable runner uses the normal read-only sandbox and never-approval policy. Evaluator files are removed from the task workspace, but host/runtime/network resources are shared: this is not hermetic isolation. Inspect recorded tool activity for out-of-workspace access. Its controls differ from historical R3's macOS-specific guards and parallel scheduling; compare only within the new cohort, not by pooling it with R3. The archive pins source HEAD while the launcher hash identifies the build; rebuild after runtime changes. +The portable runner uses the normal read-only sandbox and never-approval policy. Evaluator files are removed from the task workspace, but host/runtime/network resources are shared: this is not hermetic isolation. Inspect recorded tool activity for out-of-workspace access. Its controls differ from historical R3's macOS-specific guards and parallel scheduling; compare only within the new cohort, not by pooling it with R3. The archive pins source HEAD while the launcher hash identifies the build; rebuild after runtime changes. Treatment files retain their embedded newline and omit the shipped section’s leading output reminder, so the evaluated text is not byte-identical to the shipped section and cohort numbers do not measure that full section. ## Review and summarize @@ -45,7 +45,7 @@ python3 packages/client/ui-deliverables/evals/file-references/summarize.py packa python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v ``` -It validates nonnegative counts, E = L + M + I, duplicate attempts, and fresh-answer hashes. It reports planned/attempted/answered counts, missing audits, valid-link coverage, perfect audited answers, and timeouts. It does not automatically infer missing-file mentions or judge semantic correctness. These keyless Python checks validate the evaluation tools; they are manually invoked and do not add a real-API CI job. +It validates nonnegative counts, E = L + M + I, duplicate attempts, and fresh-answer hashes. It reports planned/attempted/answered counts, missing audits, valid-link coverage, perfect audited answers, and timeouts. It does not automatically infer missing-file mentions or judge semantic correctness. These keyless Python checks validate the evaluation tools, historical input hashes, and process cleanup in the Linux PR CI job. The package’s normal Vitest suite checks the final colon treatment against the shipped guidance and that guidance in every Web prompt sidecar. No real-API CI job is added. ## Preserved results diff --git a/packages/client/ui-deliverables/evals/file-references/README.zh.md b/packages/client/ui-deliverables/evals/file-references/README.zh.md index ea94964ec9..7a542e2fa3 100644 --- a/packages/client/ui-deliverables/evals/file-references/README.zh.md +++ b/packages/client/ui-deliverables/evals/file-references/README.zh.md @@ -21,7 +21,7 @@ kind: "package-reference" ## 运行一组评测 -需要 Python 3.9+、POSIX 主机、Node 和仓库依赖,以及已构建的检出目录(`pnpm run build`)。从仓库根目录执行。通过 `--env-file` 指定正常使用的 DSH 环境文件,或继承其环境;运行器不读取凭据值。`--model` 必须显式提供,使用 DeepSeek provider 和 high 推理强度。每次尝试使用新的 DSH home、agent home、Session 和 HEAD 归档工作区;可执行程序是本检出目录构建的 `dsh --profile headless` 启动器。 +需要 Python 3.9+、提供 `ps` 的 POSIX 主机、Node 和仓库依赖,以及已构建的检出目录(`pnpm run build`)。从仓库根目录执行。通过 `--env-file` 指定正常使用的 DSH 环境文件,或继承其环境;运行器不读取凭据值。`--model` 必须显式提供,使用 DeepSeek provider 和 high 推理强度。每次尝试使用新的 DSH home、agent home、Session 和 HEAD 归档工作区;可执行程序是本检出目录构建的 `dsh --profile headless` 启动器。 执行一次真实冒烟验证时,将 `DSH_EVAL_ENV` 设为正常环境文件路径,然后运行: @@ -29,9 +29,9 @@ kind: "package-reference" python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV" ``` -省略 `--variant`、`--case` 和 `--repetitions` 即使用原三版、四个任务和三次重复。`--prepare-only` 只记录输入,不调用 API。默认每次尝试上限为 480 秒,可用 `--timeout` 缩短。运行串行执行,遇到首次失败即停止;重试或恢复不会替换输出。打印的目录位于被忽略的 `.artifacts/file-reference-evals/` 下,保留输入哈希、源码归档、输出、工作区、Session 和失败。指定的 `--output` 必须是 `.artifacts/` 内的新目录。 +省略 `--variant`、`--case` 和 `--repetitions` 即使用原三版、四个任务和三次重复。`--prepare-only` 只记录输入,不调用 API。默认每次尝试上限为 480 秒,可用 `--timeout` 缩短。运行串行执行,遇到首次失败即停止;重试或恢复不会替换输出。打印的目录位于被忽略的 `.artifacts/file-reference-evals/` 下,保留输入哈希、源码归档、输出、工作区、Session 和失败。指定的 `--output` 必须是 `.artifacts/` 内的新目录;缺失的父目录会自动创建。运行器回收启动器,并等待进程组的所有成员终止;等待父进程回收的僵尸进程不计为仍在运行的工作。 -可移植运行器使用正常的只读 sandbox 和 never approval 策略。评测文件会从任务工作区移除,但宿主、运行时和网络资源仍共享,因此不是完全隔离环境。应检查记录的工具活动中是否存在工作区外访问。它的控制措施与历史 R3 的 macOS 专用防护及并行调度不同;只在新组内比较,不与 R3 合并统计。归档固定 HEAD 源码,启动器哈希标识构建;运行时代码变化后应重新构建。 +可移植运行器使用正常的只读 sandbox 和 never approval 策略。评测文件会从任务工作区移除,但宿主、运行时和网络资源仍共享,因此不是完全隔离环境。应检查记录的工具活动中是否存在工作区外访问。它的控制措施与历史 R3 的 macOS 专用防护及并行调度不同;只在新组内比较,不与 R3 合并统计。归档固定 HEAD 源码,启动器哈希标识构建;运行时代码变化后应重新构建。评测文本保留内嵌换行,并省略正式提示词段开头的产出提醒,因此评测文本与正式段落并非逐字节相同,组内指标不衡量该完整段落。 ## 评审与汇总 @@ -45,7 +45,7 @@ python3 packages/client/ui-deliverables/evals/file-references/summarize.py packa python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v ``` -它验证非负计数、E = L + M + I、重复尝试和新答案哈希,并报告计划数、尝试数、回答数、缺失审计、有效链接覆盖、已审计完美回答和超时。它不自动推断遗漏的文件提及,也不判断语义正确性。这些无密钥 Python 检查验证评测工具,由人工调用,不增加真实 API CI 作业。 +它验证非负计数、E = L + M + I、重复尝试和新答案哈希,并报告计划数、尝试数、回答数、缺失审计、有效链接覆盖、已审计完美回答和超时。它不自动推断遗漏的文件提及,也不判断语义正确性。这些无密钥 Python 检查在 Linux PR CI 作业中验证评测工具、历史输入哈希和进程清理。包的常规 Vitest 测试校验最终冒号评测文本与正式指导的一致性,以及每份 Web 提示词 sidecar 中的指导。不增加真实 API CI 作业。 ## 保留的结果 diff --git a/packages/client/ui-deliverables/evals/file-references/run.py b/packages/client/ui-deliverables/evals/file-references/run.py index 945be8359b..e68e2ad7a9 100644 --- a/packages/client/ui-deliverables/evals/file-references/run.py +++ b/packages/client/ui-deliverables/evals/file-references/run.py @@ -19,8 +19,15 @@ def digest(path): return hashlib.sha256(path.read_bytes()).hexdigest() +def process_group_running(pgid): + """Report live group members; zombies have exited and await their parent's reap.""" + rows = subprocess.check_output(['ps', '-A', '-o', 'pgid=', '-o', 'stat='], text=True) + return any(int(group) == pgid and not state.startswith('Z') + for group, state in (row.split() for row in rows.splitlines())) + + def execute(command, workspace, task, slot, timeout): - """Preserve first output and reap the owned process group, including on interruption.""" + """Preserve first output, reap the launcher, and await termination of its process group.""" started = time.monotonic() timed_out = False interrupted = False @@ -51,11 +58,7 @@ def execute(command, workspace, task, slot, timeout): pass deadline = time.monotonic() + 5 teardown_error = None - while True: - try: - os.killpg(child.pid, 0) - except ProcessLookupError: - break + while process_group_running(child.pid): if time.monotonic() >= deadline: teardown_error = 'Owned process group did not finish teardown' break @@ -108,7 +111,7 @@ def main(): if args.output: if not output.is_relative_to(REPO / '.artifacts'): parser.error('--output must be inside repository .artifacts/') - output.mkdir(mode=0o700) + output.mkdir(mode=0o700, parents=True) output.chmod(0o700) env_args = ['--env-file=' + str(args.env_file.resolve())] if args.env_file else [] revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip() diff --git a/packages/client/ui-deliverables/evals/file-references/test_eval.py b/packages/client/ui-deliverables/evals/file-references/test_eval.py index 0db2f546cc..07ed08cdd9 100644 --- a/packages/client/ui-deliverables/evals/file-references/test_eval.py +++ b/packages/client/ui-deliverables/evals/file-references/test_eval.py @@ -1,11 +1,14 @@ """Keyless checks for retained failures and honest evaluation denominators.""" import json import os +import signal +import subprocess +import time from pathlib import Path import sys import tempfile import unittest -from run import execute, digest +from run import execute, digest, process_group_running from summarize import aggregate, observations @@ -50,17 +53,53 @@ class EvaluationTests(unittest.TestCase): for name, value in expected.items(): self.assertEqual(digest(owner / folder / (name + '.txt')), value) - def test_descendant_ignoring_term_is_reaped(self): + def test_zombie_group_has_no_running_work(self): + child = subprocess.Popen([sys.executable, '-c', + 'import time; print("ready", flush=True); time.sleep(60)'], + stdout=subprocess.PIPE, text=True, start_new_session=True) + try: + self.assertEqual(child.stdout.readline(), 'ready\n') + self.assertTrue(process_group_running(child.pid)) + child.kill() + deadline = time.monotonic() + 5 + while not subprocess.check_output(['ps', '-p', str(child.pid), '-o', 'stat='], text=True).strip().startswith('Z'): + if time.monotonic() >= deadline: + self.fail('Killed child did not reach zombie state') + time.sleep(.01) + # Deliberately leave our child unreaped while checking the same group. + if sys.platform == 'linux': + os.killpg(child.pid, 0) + self.assertFalse(process_group_running(child.pid)) + finally: + child.kill() + child.communicate() + + def test_successful_leader_does_not_leave_a_term_ignoring_descendant_running(self): with tempfile.TemporaryDirectory() as root: slot = Path(root) task = slot / 'task.txt' task.write_text('task') - code = 'import json, subprocess, sys, time; child=subprocess.Popen([sys.executable,"-c","import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); time.sleep(60)"]); print(json.dumps({"pid":child.pid}),flush=True); time.sleep(60)' - result = execute([sys.executable, '-c', code], slot, task, slot, .3) - self.assertTrue(result['timedOut']) - pid = json.loads((slot / 'stdout.jsonl').read_text())['pid'] - with self.assertRaises(ProcessLookupError): - os.kill(pid, 0) + descendant = 'import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); print("ready",flush=True); time.sleep(60)' + code = f"""import json, subprocess, sys +child = subprocess.Popen([sys.executable, '-c', {descendant!r}], stdout=subprocess.PIPE, text=True) +assert child.stdout.readline() == 'ready\\n' +print(json.dumps({{'pid': child.pid}}), flush=True) +print(json.dumps({{'type': 'final', 'text': 'done'}}), flush=True) +""" + result = execute([sys.executable, '-c', code], slot, task, slot, 10) + pid = json.loads((slot / 'stdout.jsonl').read_text().splitlines()[0])['pid'] + try: + self.assertFalse(result['timedOut']) + self.assertEqual(result['exitCode'], 0) + self.assertTrue(result['nonemptyFinal']) + self.assertIsNone(result['teardownError']) + state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], capture_output=True, text=True, check=False) + self.assertTrue(state.returncode == 1 or state.stdout.strip().startswith('Z')) + finally: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass def test_historical_counts_reproduce_report(self): source = Path(__file__).parent / 'results/2026-09-16/observations.json' diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index c50d266ee2..703572482f 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -1,5 +1,6 @@ /** Node-half coverage for the model guidance paired with Web file references. */ +import { readFile } from 'node:fs/promises' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -30,6 +31,13 @@ describe('ui-deliverables node plugin', () => { .find(entry => entry.name === 'ui:deliverable-file-references') expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') + const treatment = await readFile(new URL('../evals/file-references/prompts/a-colon.txt', import.meta.url), 'utf8') + expect(section?.text).toBe(`When you successfully create or modify files, mention the primary outputs in your final response. ${treatment.trim().split('\n').join(' ')}`) + for (const scenario of ['cordis-tool-round', 'fresh-round-trip', 'ptc-round', 'schedule-catalog']) { + const sidecar = await readFile(new URL(`../../../../snapshots/web/${scenario}/system-prompt.expected.md`, import.meta.url), 'utf8') + expect(sidecar, scenario).toContain(section!.text) + } + await mounted.dispose() expect((await ctx.systemPrompt.assemble()).sections .some(entry => entry.name === 'ui:deliverable-file-references')).toBe(false) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index a9f53b2518..d7768f1d5d 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -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-primitives/README.md -README.md: 71176026981767edb948537e98ea4567cfe2cc84 -README.zh.md: df128643dc1780716070fdbe98164e08243ee2ed +README.md: c601550a0d2b880555347c6fa5c56caeeee5f34a +README.zh.md: 3bcb84cc23cbd13149b25996bf05bce9ad28f966 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 7117602698..c601550a0d 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -72,7 +72,7 @@ The catalog above lists what each export is for; this section covers the behavio ### Rendering agent output -Passing `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. +Passing `openFile` makes local Markdown links clickable after settlement. Absolute and workspace-relative paths support percent escapes and `#L24` / `#L24-L30` fragments; ranges open at their first line. Literal `?` and `#` in filenames must be percent-encoded. The tooltip uses the decoded path and supplies the accessible name when the label is empty. The callback receives the decoded path and optional line, while the renderer preserves the label and displays a file icon. Without a callback, local links remain text. URL schemes, queries, unsupported fragments, and malformed destinations never reach the file opener. `MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index df128643dc..3bcb84cc23 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -72,7 +72,7 @@ kind: "package-library" ### 渲染 agent 输出 -传入 `openFile` 后,本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 +传入 `openFile` 后,本地 Markdown 链接在落定后可点击。绝对路径和工作区相对路径支持百分号转义以及 `#L24` / `#L24-L30` 片段;范围定位到起始行。文件名中的字面 `?` 和 `#` 必须百分号编码。悬停提示使用解码后的路径,并在标签为空时提供可访问名称。回调接收解码后的路径和可选行号,渲染器保留标签并显示文件图标。不传回调时,本地链接仍为文本。URL 协议、查询串、不支持的片段及格式错误的目标不会传给文件打开器。 `MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 76a0343215..80f5b25e7c 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -55,7 +55,8 @@ margin-bottom: 8px; } -.markdown a { +.markdown a, +.markdown .fileLink { /* deepsuite markdown.css uses brand-text (blue in newDesign); this sheet keeps design-platform brand-text as near-black, so links use the dedicated link alias instead. */ @@ -315,6 +316,11 @@ cursor: pointer; } +.fileLink { + max-width: 100%; + text-align: inherit; +} + .fileMention:hover, .fileMention:focus { outline: none; diff --git a/packages/client/ui-primitives/src/markdown/file-link.ts b/packages/client/ui-primitives/src/markdown/file-link.ts index 3f82b5fb41..bb1ca89b0b 100644 --- a/packages/client/ui-primitives/src/markdown/file-link.ts +++ b/packages/client/ui-primitives/src/markdown/file-link.ts @@ -2,6 +2,7 @@ /** * Decode a file destination and its optional GitHub-style line fragment. + * Literal `?` and `#` in filenames must be percent-encoded. * @param value - Parsed Markdown link destination. * @returns A local path and optional first line, or undefined for URLs, * fragment-only links, queries, malformed escapes, or invalid line ranges. diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index dce251e042..a46481276a 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -556,7 +556,7 @@ function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true return ( <> @@ -139,16 +144,17 @@ export function MessageImage({ image, load, variant, labels }: { } /** Wrapping image group shared by user and assistant history: a lone image - * renders large unless its owning mixed-attachment row requests compact tiles. */ -export function ImageGallery({ images, load, align, compact = false, labels }: { + * renders large unless its owner requests compact tiles or contained list thumbnails. */ +export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' compact?: boolean + thumbnail?: boolean labels: MessageImageLabels }) { if (images.length === 0) return null - const variant = compact || images.length > 1 ? 'tile' : 'single' + const variant = thumbnail ? 'thumbnail' : compact || images.length > 1 ? 'tile' : 'single' return (
    {images.map((image, index) => ( diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 92cdaa455b..192147f877 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -3,13 +3,14 @@ import { ImageGallery } from '../MessageImage.tsx' import { messageImageLabels } from './labels.ts' /** Historical message-image slot entry. */ -export function MessageImages({ images, loadImage, align, compact = false, t }: MessageImagesProps) { +export function MessageImages({ images, loadImage, align, compact = false, thumbnail = false, t }: MessageImagesProps) { return ( ) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 3e86094c74..a733012356 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -144,11 +144,29 @@ describe('MessageImage', () => { expect(frame.getAttribute('style')).toBeNull() }) - it('keeps the tile variant on the failed-load retry control', async () => { + it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) - expect(retry.getAttribute('data-variant')).toBe('tile') + expect(retry.getAttribute('data-variant')).toBe(variant) + }) + + it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => { + const { name: _name, ...unnamed } = attachment + const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail') + const view = render() + fireEvent.click(await view.findByRole('button', { name: labels.loadFailed })) + const image = await view.findByAltText('Image 2') + expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail') + const opener = view.getByRole('button', { name: 'Image 2,点击查看原图' }) + opener.focus() + fireEvent.click(opener) + expect(view.getByRole('dialog')).toBeTruthy() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(view.queryByRole('dialog')).toBeNull() + expect(document.activeElement).toBe(opener) + expect(load).toHaveBeenLastCalledWith(unnamed) + expect(unnamed).not.toHaveProperty('name') }) it('ignores a load settling after unmount', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 9ca39b0338..efd218a56a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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-conversation/README.md -README.md: 3c2697487ffe5b79bc8209eb9be953cc70977d79 -README.zh.md: 137357eb133a42debbce3232ca7b0858a26ee8c1 +README.md: 9ba86daebc31bb7e3fa63b21b3770d2998439ee6 +README.zh.md: 889b414057391139c41559fd9075d1a3334822e3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3c2697487f..9ba86daebc 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -36,6 +36,8 @@ Target packages declaration-merge their snapshot and Location data maps, then re ## Shell and standard props +The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. [ui-attachment](../ui-attachment/README.md) owns rendering and the lightbox. + The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module. `SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 137357eb13..889b414057 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -36,6 +36,8 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, ## Shell 与标准 props +共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。[ui-attachment](../ui-attachment/README.zh.md) 负责渲染与灯箱。 + 输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。 `SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index b9bafadb4e..f0c7470431 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -76,7 +76,11 @@ export interface ComposerAttachmentsOwnerProps { * local preview of a submission echo whose admission is still in flight. */ export type MessageImageSource = - | { readonly attachment: ImageAttachmentRef } + | { + readonly attachment: ImageAttachmentRef + /** Presentation-only name for the thumbnail and lightbox; loading uses the original reference. */ + readonly label?: string + } | { readonly preview: { /** Browser-owned preview URL (lifecycle stays with the submitter). */ @@ -104,6 +108,8 @@ export interface MessageImagesOwnerProps { align: 'start' | 'end' /** Force every image into the compact message-attachment tile size. */ compact?: boolean + /** Fixed, uncropped thumbnail for an attachment list row. */ + thumbnail?: boolean } /** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 1dae344665..1834635f5a 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -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-trajectory/README.md -README.md: 63bd664b902d8d40f8a05153770306d3b03dbe4e -README.zh.md: 89db03a99ac68065211f92ff7e47307813c1ab76 +README.md: f11bfa3188ce616e392158f9f2cf33920edea8ba +README.zh.md: 53672cc8e4c8891ff6ea9b985b4efafc0368e1bf diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 63bd664b90..f11bfa3188 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -31,7 +31,9 @@ Open the Trajectory tab in the conversation's view ring to inspect agent activit Calls are identified by their recorded tool name. For `run_code`, the row shows the program description and the inspector opens numbered, highlighted source. The Code tab provides wrapping, a `{}` toggle for the original JSON arguments, and exact source copying, including trailing newlines. Copying the original arguments retains their recorded JSON whitespace. Each newly opened code view takes the last wrapping choice; changing it leaves other open views as they are. Output preserves the recorded text, using a tree for complete JSON objects or arrays. Highlighting uses only an unambiguous TypeScript or Python hint in the recorded tool schema; missing or conflicting hints leave plain source. See the [PTC inspection decision](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.md) for replay constraints. -Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows the generic-file count beside its text, while a record without text shows its image and file counts. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. +Selection, timeline navigation, folding, and search cover the React-visible window. Request numbers and cumulative usage cover the complete resident snapshot. Selecting a record opens a local inspector for token usage, duration, Input, Output, Timing, and durable images. Image URLs use the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. A user record shows both nonzero image and ordinary-file counts beside its text, including attachment-only records. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. + +Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 89db03a99a..53672cc8e4 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -31,7 +31,9 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 调用按已记录的工具名识别。对于 `run_code`,记录行展示程序说明,检查器显示带行号和语法高亮的源码。“代码”页提供换行、查看原始 JSON 参数的 `{}` 切换按钮,以及保留末尾换行的源码原样复制。复制原始参数时保留记录中 JSON 的空白。每个新打开的代码视图采用上次选择的换行状态;切换时,其他已打开视图保持原状。输出保留记录中的文本,完整 JSON 对象或数组使用树形展示。高亮只采用已记录工具 Schema 中明确且无冲突的 TypeScript 或 Python 提示;提示缺失或冲突时显示纯文本源码。回放约束见 [PTC 检查决策](../../../.agents/notes/implemented/feature/2026-09-09-ptc-trajectory-code-inspection.zh.md)。 -选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁显示通用文件数量;记录没有文本时,则显示图片与文件数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 +选择、时间线导航、折叠与搜索只覆盖 React 可见窗口。请求编号与累计用量覆盖完整的驻留快照。选择记录会打开局部检查器,查看 token 用量、耗时、输入、输出、计时与持久保留的图片。图片 URL 使用 Conversation 拥有的逐会话缓存,因此 Chat 与 Trajectory 对每个附件共享一次已授权读取。用户记录会在文本旁同时显示非零的图片与普通文件数量,仅含附件的记录也显示这些数量。独立运行的压缩请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。 + +概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 ### 时间概览 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index c743e0e503..6c1287fca7 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1708,11 +1708,88 @@ gap: 8px; } -.messageImagesPreview { - margin: 6px 14px 8px; +.attachments { + display: flex; + flex-direction: column; + margin: 8px 14px 14px; + padding: 0; + gap: 8px; + list-style: none; +} + +.attachmentsPreview { + margin-block: 6px 8px; gap: 6px; } +.attachmentRow { + display: flex; + align-items: center; + min-width: 0; + padding: 8px; + gap: 10px; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; +} + +.attachmentIcon { + display: grid; + flex: 0 0 48px; + width: 48px; + height: 48px; + place-items: center; +} + +.attachmentInfo { + display: flex; + flex: 1; + flex-direction: column; + min-width: 0; + gap: 3px; +} + +.attachmentName { + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-size: 12px; + line-height: 18px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentMetadata { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentDisclosure { + margin-block: 8px; + border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 8px; +} + +.attachmentDisclosure > summary { + padding: 8px; + cursor: pointer; +} + +.attachmentDisclosure > summary .attachmentName { + display: block; +} + +.attachmentDisclosure > summary:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.attachmentDisclosure > pre { + padding: 0 8px 8px; +} + .assistantToolCalls { box-sizing: border-box; max-width: 100%; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 6babf88c4e..53fac3a647 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -5,6 +5,9 @@ import type { CSSProperties, ReactNode } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { CodeBlock, + FileTypeIcon, + fileExtension, + fileSizeText, IconCheckOutline16, IconChevronRightOutline14, IconCodeOutline16, @@ -20,7 +23,6 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, JsonTreeProps, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -1155,75 +1157,120 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, - renderImages, t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void - renderImages: RenderMessageImages t: TrajectoryTranslate }) { + const attachments = new Map(recordAttachments(blocks, t).map(entry => [entry.index, entry])) return (
    - {blocks.map((block, index) => ( -
    - {block.callId !== undefined - ? ( - - ) - : ( -
    - - {t('block.label', { index: index + 1, type: block.type })} - -
    - )} - {/* The Raw view keeps model block order and granularity: one - gallery per image block, unlike the aggregated record gallery. */} - {block.attachment !== undefined - ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) - :
    {block.content}
    } -
    - ))} + {blocks.map((block, index) => { + const attachment = attachments.get(index) + return attachment !== undefined ? ( +
    + + + {t('block.label', { index: index + 1, type: block.type })} + + + {attachment.name} + + +
    {block.content}
    +
    + ) + : ( +
    + {block.callId !== undefined + ? ( + + ) + : ( +
    + + {t('block.label', { index: index + 1, type: block.type })} + +
    + )} +
    {block.content}
    +
    + ) + })}
    ) } -function recordImages( +function recordAttachments( blocks: readonly TrajectorySourceBlock[] | undefined, -): { readonly attachment: ImageAttachmentRef }[] { - return (blocks ?? []).flatMap(block => - block.attachment !== undefined ? [{ attachment: block.attachment }] : []) + t: TrajectoryTranslate, +) { + let imageIndex = 0 + return (blocks ?? []).flatMap((block, index) => { + const ref = block.attachment ?? block.file + if (ref === undefined) return [] + if (block.attachment !== undefined) imageIndex += 1 + return [{ + block, + index, + name: ref.name ?? t('attachment.imageName', { index: imageIndex }), + metadata: [ + block.attachment?.mediaType ?? fileExtension(ref.name ?? '').toUpperCase(), + fileSizeText(ref.bytes), + ...(block.attachment === undefined ? [] : [`${block.attachment.width} × ${block.attachment.height}`]), + ].filter(Boolean).join(' · '), + }] + }) } -function MessageImages({ +function RecordAttachments({ blocks, preview, renderImages, + t, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean renderImages: RenderMessageImages + t: TrajectoryTranslate }) { - const images = recordImages(blocks) - if (images.length === 0) return null + const attachments = recordAttachments(blocks, t) + if (attachments.length === 0) return null return ( -
    - {renderImages({ images, align: 'start' })} -
    +
      + {attachments.map(({ block, index, name, metadata }) => ( +
    • + {block.attachment !== undefined + ? renderImages({ + images: [{ attachment: block.attachment, label: name }], + align: 'start', + thumbnail: true, + }) + : } +
      + {name} + {metadata} +
      +
    • + ))} +
    ) } @@ -1486,7 +1533,6 @@ function MarkdownRecordContent({ ) @@ -1541,25 +1587,26 @@ function MarkdownRecordContent({ onOpenCall={onOpenCall} t={t} /> -
    ) } const source = markdownSource(record) - const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true + const hasAttachments = record.cell.sourceBlocks?.some(block => block.attachment !== undefined || block.file !== undefined) === true const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true - if (!source && !hasImages && !hasToolCalls) { + if (!source && !hasAttachments && !hasToolCalls) { const emptyLabel = isToolCallOnly(record.cell, t) ? t('record.toolCallOnly') : record.cell.text || t('record.noContent') return

    {emptyLabel}

    } - if (!rendered || (!hasImages && !hasToolCalls)) { + if (!rendered || (!hasAttachments && !hasToolCalls)) { return } return ( @@ -1573,7 +1620,7 @@ function MarkdownRecordContent({ t={t} /> )} - +
    ) } diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 7691b7f74a..432ba0653d 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -12,7 +12,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { TrajectoryCellProps, TrajectorySourceBlock, @@ -129,7 +129,7 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< const images = imageBlockCount(node.content) const files = fileBlockCount(node.content) const attachmentSummary = [ - previewMarkdown === undefined && images > 0 + images > 0 ? t('layout.imageOnly', { count: images }) : undefined, files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, @@ -829,7 +829,7 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { callId: block.callId, toolName: block.name, } - case 'image': return { type: 'image', content: '', attachment: block.attachment } + case 'image': return sourceBlock({ type: 'image', attachment: block.attachment }) case 'other': return sourceBlock(block.block) } } @@ -844,14 +844,20 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } if ( - type === 'image' + (type === 'image' || type === 'file') && typeof block.attachment === 'object' && block.attachment !== null && typeof (block.attachment as Record).attachmentId === 'string' ) { // Session-log content is validated into core ContentBlocks by the // Conversation node assembly; the `attachmentId` guard only keeps // wire-shaped 'other' blocks with an unrelated `attachment` member out. - return { type, content: '', attachment: block.attachment as ImageAttachmentRef } + return { + type, + content: stringifySourceValue(value), + ...(type === 'image' + ? { attachment: block.attachment as ImageAttachmentRef } + : { file: block.attachment as FileAttachmentRef }), + } } return { type, content: stringifySourceValue(value) } } diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 7e76444ca1..80ddf8fe7d 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -180,6 +180,8 @@ export const zh = { 'layout.compactionFailed': '上下文压缩失败', 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', + 'attachment.list': '附件', + 'attachment.imageName': '图片 {index}', 'layout.imageOnly': '图片 ×{count}', 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', @@ -380,6 +382,8 @@ export const en: Record = { 'layout.compactionFailed': 'Compaction failed', 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', + 'attachment.list': 'Attachments', + 'attachment.imageName': 'Image {index}', 'layout.imageOnly': 'Images ×{count}', 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 017143c487..bd37189fa4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,7 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryTranslate } from './locales.ts' @@ -30,6 +30,8 @@ export interface TrajectorySourceBlock { type: string content: string attachment?: ImageAttachmentRef + /** Ordinary file metadata; never passed to the image loader. */ + file?: FileAttachmentRef callId?: string toolName?: string } diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index f9130ab4f7..df27f38e65 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -592,8 +592,8 @@ describe('durable image attachments', () => { expect(user?.text).toBe('Images ×2') expect(user?.previewMarkdown).toBeUndefined() expect(user?.sourceBlocks).toEqual([ - { type: 'image', content: '', attachment }, - { type: 'image', content: '', attachment }, + { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }, + { type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }, ]) }) @@ -619,9 +619,9 @@ describe('durable image attachments', () => { ] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const user = turns[0]?.groups[0]?.cells[0] - expect(user?.text).toBe('') + expect(user?.text).toBe('Images ×1') expect(user?.previewMarkdown).toBe('look at this') - expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment }) + expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }) }) it('maps assistant image blocks to attachment source blocks and labels image-only output', () => { @@ -634,7 +634,7 @@ describe('durable image attachments', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') expect(message?.text).toBe('Images ×1') - expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }]) + expect(message?.sourceBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }]) }) it('carries tool-result image refs into outputBlocks and labels the result', () => { @@ -653,7 +653,7 @@ describe('durable image attachments', () => { const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') expect(tool?.result).toBe('Images ×1') expect(tool?.outputDetail).toBe('Images ×1') - expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }]) + expect(tool?.outputBlocks).toEqual([{ type: 'image', content: JSON.stringify({ type: 'image', attachment }, null, 2), attachment }]) }) it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => { @@ -677,7 +677,7 @@ describe('durable file attachments', () => { bytes: 2447 * 1024 * 1024, } - it('labels file-only and mixed-text user records without rendering file cards', () => { + it('labels file-only and mixed-text user records', () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, source: null, @@ -696,7 +696,7 @@ describe('durable file attachments', () => { expect(users[1]).toMatchObject({ text: 'Files ×1', previewMarkdown: 'review these' }) }) - it('keeps image and file counts in a file-only attachment summary', () => { + it.each([undefined, '', 'review these'])('keeps both counts and repeated attachments with text %j', (text) => { const image = { attachmentId: `sha256:${'e'.repeat(64)}`, mediaType: 'image/png', @@ -706,9 +706,22 @@ describe('durable file attachments', () => { } const nodes = [{ kind: 'user', seq: 1, time: 1_000, source: null, - content: [{ type: 'image', attachment: image }, { type: 'file', attachment }], + content: [ + ...(text === undefined ? [] : [{ type: 'text', text }]), + { type: 'image', attachment: image }, + { type: 'file', attachment }, + { type: 'image', attachment: image }, + ], }] as unknown as LegacyConversationSlice['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) - expect(turns[0]?.groups[0]?.cells[0]?.text).toBe('Images ×1 · Files ×1') + const cell = turns[0]?.groups[0]?.cells[0] + expect(cell?.text).toBe('Images ×2 · Files ×1') + const blocks = cell?.sourceBlocks?.filter(block => block.type !== 'text') + expect(blocks?.map(block => block.attachment ?? block.file)).toEqual([image, attachment, image]) + expect(blocks?.map((block): unknown => JSON.parse(block.content))).toEqual([ + { type: 'image', attachment: image }, + { type: 'file', attachment }, + { type: 'image', attachment: image }, + ]) }) }) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 5a882141bb..32771e3565 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -2,7 +2,8 @@ /** Trajectory ledger selection, details, status, and fold behavior. */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ComponentProps } from 'react' import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' @@ -1008,47 +1009,60 @@ describe('TrajectoryTable', () => { expect(screen.getByText('value:')).toBeTruthy() }) - it('renders user image attachments through the shared gallery in the details panel', () => { - const attachment = { - attachmentId: `sha256:${'a'.repeat(64)}`, - mediaType: 'image/png', - bytes: 68, - width: 640, - height: 320, - name: 'screenshot.png', - } as unknown as NonNullable< - NonNullable[number]['attachment'] - > - const turns: readonly TrajectoryTurnModel[] = [{ - turn: 1, - groups: [{ - title: 'Message', - cells: [{ - index: 1, - kind: 'user', - text: 'Images ×2', - sourceBlocks: [ - { type: 'image', content: '', attachment }, - { type: 'image', content: '', attachment }, - ], - timeSeconds: 0, - }], - }], - }] - - render() - fireEvent.click(screen.getByRole('row', { name: /USER/ })) - - const preview = screen.getAllByTestId('record-images') - expect(preview.length).toBeGreaterThan(0) - expect(preview[0]?.getAttribute('data-count')).toBe('2') - - fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) - const rawGalleries = screen.getAllByTestId('record-images') - expect(rawGalleries).toHaveLength(2) - expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) - .toBe(String(attachment.attachmentId)) - }) + it.each([['English', t, 'Attachments', 'Image 1', 'Summary', 'Preview', 'Raw'], + ['Chinese', tZh, '附件', '图片 1', '概述', '预览', '原始内容']] as const)( + 'keeps mixed attachments ordered and raw fields complete in %s', + (_locale, translate, listLabel, imageLabel, summaryTab, previewTab, rawTab) => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 40, + height: 800, + originalDimensions: { width: 400, height: 8000 }, + } + const file = { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + name: `${'long-filename-'.repeat(12)}.txt`, + bytes: 0, + } + const content = [ + { type: 'text' as const, text: '**before**' }, + { type: 'image' as const, attachment }, + { type: 'text' as const, text: 'after' }, + { type: 'file' as const, attachment: file }, + { type: 'image' as const, attachment }, + ] + const turns = deriveTrajectoryLayout({ + nodes: [{ kind: 'user', seq: 1, time: 1000, source: { kind: 'user' }, content }], + partial: null, + runningCalls: [], + }, translate) + const renderImages = vi.fn(renderImagesStub) + const view = render() + fireEvent.click(view.container.querySelector('[data-trajectory-row-key]')!) + for (const tab of [summaryTab, previewTab]) { + fireEvent.click(screen.getByRole('tab', { name: tab })) + const list = screen.getByRole('list', { name: listLabel }) + const names = [...list.querySelectorAll('[title]')].map(el => el.getAttribute('title')) + expect(names).toEqual([imageLabel, file.name, imageLabel.replace('1', '2')]) + expect(within(list).getByText('TXT · 0B')).toBeTruthy() + expect(within(list).getAllByText('image/png · 68B · 40 × 800')).toHaveLength(2) + expect(renderImages).toHaveBeenCalledWith({ images: [{ attachment, label: imageLabel }], align: 'start', thumbnail: true }) + } + fireEvent.click(screen.getByRole('tab', { name: rawTab })) + expect(screen.queryByTestId('record-images')).toBeNull() + const disclosures = [...view.container.querySelectorAll('details')] + expect(disclosures).toHaveLength(3) + expect(disclosures.every(el => !el.open)).toBe(true) + expect(disclosures.map((el): unknown => JSON.parse(el.querySelector('pre')!.textContent))) + .toEqual([content[1], content[3], content[4]]) + const blocks = [...disclosures[0]!.parentElement!.children] + expect(blocks.map(el => el.tagName)).toEqual(['SECTION', 'DETAILS', 'SECTION', 'DETAILS', 'DETAILS']) + expect(blocks[0]!.querySelector('pre')!.textContent).toBe('**before**') + expect(blocks[2]!.querySelector('pre')!.textContent).toBe('after') + }, + ) it('renders a tool-result image through the shared gallery in the Result tab', () => { const attachment = { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e080b567cd..0ea2684d65 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -387,7 +387,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:169', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', }, { key: 'conversation.composer.bar', @@ -425,7 +425,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:193', }, { key: 'conversation.composer.dock', @@ -480,7 +480,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:187', }, { key: 'conversation.hero.agentPreset', @@ -510,7 +510,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:175', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:181', }, { key: 'conversation.hero.brand.mark', @@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', }, { key: 'conversation.hero.workspace', @@ -570,7 +570,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:171', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -641,7 +641,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', }, { key: 'conversation.input.dock', @@ -703,7 +703,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:177', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', }, { key: 'conversation.input.left', @@ -756,7 +756,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:183', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:189', }, { key: 'conversation.input.model', @@ -794,7 +794,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:199', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:205', }, { key: 'conversation.input.overlay', @@ -851,7 +851,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:179', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', }, { key: 'conversation.input.permission', @@ -889,7 +889,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:197', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:203', }, { key: 'conversation.input.plan', @@ -927,7 +927,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:195', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:201', }, { key: 'conversation.input.right', @@ -980,7 +980,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:185', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:191', }, { key: 'conversation.message.images', @@ -990,7 +990,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1058,7 +1058,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:122', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:128', }, { key: 'conversation.session.header', @@ -1094,7 +1094,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:124', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:130', }, { key: 'conversation.session.header.actions', @@ -1155,7 +1155,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:132', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:138', }, { key: 'conversation.session.header.corner', @@ -1193,7 +1193,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', }, { key: 'conversation.session.header.leading', @@ -1231,7 +1231,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.leading\', () => ctx.slots.register(\n { name: \'conversation.session.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:150', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:156', }, { key: 'conversation.session.header.lineage', @@ -1271,7 +1271,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:126', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:132', }, { key: 'conversation.session.header.utilities', @@ -1329,7 +1329,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:138', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:144', }, { key: 'conversation.trajectory.images', @@ -1339,7 +1339,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', registerOptions: [], ownerProps: [ - '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n}', + '/** Message image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable references or submission-echo previews in source order. */\n images: readonly MessageImageSource[]\n /** Session-authorized image URL loader for the durable arm. */\n loadImage: MessageImageLoader\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n /** Force every image into the compact message-attachment tile size. */\n compact?: boolean\n /** Fixed, uncropped thumbnail for an attachment list row. */\n thumbnail?: boolean\n}', ], ownerPropsReferences: [ 'Message', @@ -1431,7 +1431,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:167', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:173', }, { key: 'main', @@ -1501,7 +1501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:126', }, { key: 'rightbar', From cba87e84d5b53f3f353fb1007e534f429214a2a7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 17 Sep 2026 11:21:11 +0800 Subject: [PATCH 027/225] fix(client): distinguish recorded tool status colors --- packages/client/ui-tool/README.i18n.yaml | 4 +-- packages/client/ui-tool/README.md | 2 ++ packages/client/ui-tool/README.zh.md | 2 ++ .../tool/components/ToolDetails.module.css | 1 + .../client/tool/components/ToolDetails.tsx | 2 +- .../tool/models/control-details-model.ts | 30 ++++++++++--------- .../client/tool/models/detail-model-shared.ts | 3 +- 7 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 0f7443d1ca..bd11e79708 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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-tool/README.md -README.md: b76ab3d19568bbad043a0dd005130eaa378c1fff -README.zh.md: de6e12ca0d073ccc6ec113c91d163e01afaebdca +README.md: 4964828126d426fe6f8491a2526de09aa1fbef5d +README.zh.md: 5c8f9b0ac53ec1e1180010a1f63333001b2a0d39 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index b76ab3d195..4964828126 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -66,6 +66,8 @@ Every card is read in place in the call tree; there is no second, full-height pr The Details view covers the 36 registered tools: goal and schedule tools, Cordis inspection, workflow and Ralph reports, Session event/search/trace queries, agent and teammate controls, background jobs, persistent terminals, and LSP navigation. These expanded bodies read successful logged results, preserve generic input/output for failures or unsupported data, and keep Inspect available. Dates include the viewer's time zone, and statuses reflect the call result rather than current session state. The browser adapter consumes recorded producer text and JSON; Host service objects and presenter callbacks do not cross into the Client. [Compact tool details](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md) records the presentation trade-offs. +Expanded status dots and labels use static semantic colors. An interruption receipt confirms only that interruption was requested. + The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched.
    diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index de6e12ca0d..5c8f9b0ac5 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -66,6 +66,8 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` Details 视图覆盖已注册的 36 个工具:目标和定时任务工具、Cordis 检查、workflow 与 Ralph 报告、Session 事件/搜索/轨迹查询、Agent 与 teammate 控制、后台作业、持久终端以及 LSP 导航。展开内容读取成功的记录结果,为失败或不支持的数据保留通用输入/输出,并保留 Inspect。日期包含查看者的时区,状态反映调用结果而非当前会话状态。浏览器适配器消费已记录的 producer 文本与 JSON;Host service 对象和 presenter 回调不会进入 Client。[紧凑工具详情](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md)记录了呈现取舍。 +展开后的状态圆点和文字使用静态语义色。中断回执仅确认已发出中断请求。 + terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。
    diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index e600b8a36c..2348cf2f11 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -168,6 +168,7 @@ background: currentColor; } +.badge[data-tone='info'] { color: var(--dsw-alias-state-business-primary); } .badge[data-tone='success'] { color: var(--dsw-alias-state-success-primary); } .badge[data-tone='warning'] { color: var(--dsw-alias-state-warn-primary); } .badge[data-tone='error'] { color: var(--dsw-alias-state-error-primary); } diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx index 6510677541..64b593a9b6 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx @@ -10,7 +10,7 @@ export interface ToolDetailItem { title?: string subtitle?: string description?: string - badge?: { label: string; tone: 'neutral' | 'success' | 'warning' | 'error' } + badge?: { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'error' } status?: { value: 'completed' | 'in_progress' | 'pending'; label: string } previousStatus?: string change?: { value: 'added' | 'removed' | 'updated'; label: string } diff --git a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts index 31fb25b2c1..d869070431 100644 --- a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts @@ -12,8 +12,8 @@ function arg(args: Record, key: string): string { return typeof value === 'string' ? value : '' } -function receipt(title: string, label: string, t: DetailTranslate, fields: DetailItem['fields'] = [], description?: string): ToolDetailsModel { - return detailList([{ title, badge: { label, tone: 'neutral' }, fields, ...(description === undefined ? {} : { description }) }], `${title} · ${label}`, t) +function receipt(title: string, badge: NonNullable, t: DetailTranslate, fields: DetailItem['fields'] = [], description?: string): ToolDetailsModel { + return detailList([{ title, badge, fields, ...(description === undefined ? {} : { description }) }], `${title} · ${badge.label}`, t) } function agentList(text: string, t: DetailTranslate): ToolDetailsModel | null { @@ -127,19 +127,21 @@ export function controlDetails(name: string, args: Record, text } case 'send_message': { const status = detailRecord(json) ? json.status : undefined - if (status === 'accepted' || status === 'queued') return receipt(target, t(status === 'queued' ? 'detail.status.queued' : 'detail.receipt.delivered'), t, [], arg(args, 'message')) - return text === `message delivered to agent ${target}` ? receipt(target, t('detail.receipt.delivered'), t, [], arg(args, 'message')) : null + if (status === 'accepted' || status === 'queued') return receipt(target, { + label: t(status === 'queued' ? 'detail.status.queued' : 'detail.receipt.delivered'), tone: status === 'queued' ? 'warning' : 'success', + }, t, [], arg(args, 'message')) + return text === `message delivered to agent ${target}` ? receipt(target, { label: t('detail.receipt.delivered'), tone: 'success' }, t, [], arg(args, 'message')) : null } case 'interrupt_agent': - if (detailRecord(json) && typeof json.previousStatus === 'string') return receipt(target, t('detail.receipt.interrupt'), t, [{ label: t('detail.field.previousStatus'), value: detailBadge(json.previousStatus, t).label }]) - return text === `interrupt requested for agent ${target}` ? receipt(target, t('detail.receipt.interrupt'), t) : null + if (detailRecord(json) && typeof json.previousStatus === 'string') return receipt(target, { label: t('detail.receipt.interrupt'), tone: 'warning' }, t, [{ label: t('detail.field.previousStatus'), value: detailBadge(json.previousStatus, t).label }]) + return text === `interrupt requested for agent ${target}` ? receipt(target, { label: t('detail.receipt.interrupt'), tone: 'warning' }, t) : null case 'wait_agent': if (!detailRecord(json) || typeof json.timedOut !== 'boolean') return null if (detailRecord(json.noProgress) && typeof json.noProgress.message === 'string') return detailList([{ title: t('detail.wait.noProgress'), description: json.noProgress.message, fields: [] }], t('detail.wait.noProgress'), t) - return receipt(t('detail.wait.title'), t(json.timedOut ? 'detail.wait.timeout' : 'detail.wait.changed'), t) + return receipt(t('detail.wait.title'), { label: t(json.timedOut ? 'detail.wait.timeout' : 'detail.wait.changed'), tone: 'neutral' }, t) case 'subagent': { const started = /^started (background subagent job|subagent) (\S+)$/u.exec(text) - if (started !== null) return receipt(arg(args, 'prompt'), t('detail.receipt.started'), t, [{ label: t(started[1] === 'subagent' ? 'detail.field.agent' : 'detail.field.job'), value: started[2] ?? '' }]) + if (started !== null) return receipt(arg(args, 'prompt'), { label: t('detail.receipt.started'), tone: 'info' }, t, [{ label: t(started[1] === 'subagent' ? 'detail.field.agent' : 'detail.field.job'), value: started[2] ?? '' }]) return detailList([{ title: t('detail.agent.reply'), markdown: text, fields: [], groups: [{ label: t('detail.field.task'), items: [{ description: arg(args, 'prompt'), fields: [] }] }] }], t('detail.agent.reply'), t) } case 'list_subagent_models': { @@ -162,13 +164,13 @@ export function controlDetails(name: string, args: Record, text return detailList([{ title: target, badge: detailBadge(match[1], t), fields: [], ...(description === '' ? {} : { description }), code: { text: code } }], `${target} · ${detailBadge(match[1], t).label}`, t) } case 'job_kill': - if (text === `requested cancellation of job ${target}`) return receipt(target, t('detail.receipt.cancel'), t, [], arg(args, 'reason')) - if (text.startsWith(`job ${target} had already finished `)) return receipt(target, t('detail.receipt.alreadyFinished'), t) + if (text === `requested cancellation of job ${target}`) return receipt(target, { label: t('detail.receipt.cancel'), tone: 'warning' }, t, [], arg(args, 'reason')) + if (text.startsWith(`job ${target} had already finished `)) return receipt(target, { label: t('detail.receipt.alreadyFinished'), tone: 'neutral' }, t) return null case 'terminal_open': { const match = /^started terminal session (\S+)(?: \((.*?)\))? \[type: ([^\]]+)\]\n([\s\S]*)$/u.exec(text) if (match === null || match[1] === undefined || match[3] === undefined || match[4] === undefined) return null - return detailList([{ title: match[2] ?? match[1], ...(match[2] === undefined ? {} : { subtitle: match[1] }), badge: { label: t('detail.receipt.started'), tone: 'neutral' }, fields: [{ label: t('detail.field.type'), value: match[3] }], code: { text: match[4] } }], match[2] ?? match[1], t) + return detailList([{ title: match[2] ?? match[1], ...(match[2] === undefined ? {} : { subtitle: match[1] }), badge: { label: t('detail.receipt.started'), tone: 'info' }, fields: [{ label: t('detail.field.type'), value: match[3] }], code: { text: match[4] } }], match[2] ?? match[1], t) } case 'terminal_read': { const match = /\n\[lines: (\d+)-(\d+) of (\d+)\](\n\[output truncated\])?$/u.exec(text) @@ -178,11 +180,11 @@ export function controlDetails(name: string, args: Record, text case 'terminal_signal': { const match = /^delivered (\S+) to foreground process group (\d+)$/u.exec(text) if (match === null || match[1] === undefined || match[2] === undefined) return null - return receipt(target, t('detail.receipt.signal'), t, [{ label: t('detail.field.signal'), value: match[1] }, { label: t('detail.field.processGroup'), value: match[2] }]) + return receipt(target, { label: t('detail.receipt.signal'), tone: 'success' }, t, [{ label: t('detail.field.signal'), value: match[1] }, { label: t('detail.field.processGroup'), value: match[2] }]) } case 'terminal_close': - if (text === `closed terminal session ${target}`) return receipt(target, t('detail.receipt.closed'), t) - if (text === `terminal session ${target} was already closing`) return receipt(target, t('detail.receipt.closing'), t) + if (text === `closed terminal session ${target}`) return receipt(target, { label: t('detail.receipt.closed'), tone: 'neutral' }, t) + if (text === `terminal session ${target} was already closing`) return receipt(target, { label: t('detail.receipt.closing'), tone: 'neutral' }, t) return null default: return null } diff --git a/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts b/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts index faca9a2190..46c6b6b886 100644 --- a/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts +++ b/packages/client/ui-tool/src/client/tool/models/detail-model-shared.ts @@ -58,7 +58,8 @@ export function detailBadge(status: string, t: DetailTranslate): NonNullable Date: Thu, 17 Sep 2026 11:21:33 +0800 Subject: [PATCH 028/225] fix(client): avoid duplicate expanded tool receipt statuses --- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../client/tool/components/ToolDetails.tsx | 2 + .../src/client/tool/components/ToolRow.tsx | 2 +- .../tool/models/control-details-model.ts | 10 ++++- snapshots/web/tool-details/ui.expected.md | 40 +++++++++---------- 7 files changed, 35 insertions(+), 27 deletions(-) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index bd11e79708..433db2d7b9 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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-tool/README.md -README.md: 4964828126d426fe6f8491a2526de09aa1fbef5d -README.zh.md: 5c8f9b0ac53ec1e1180010a1f63333001b2a0d39 +README.md: 38cee0071c41178199604819608e32c0e846cc41 +README.zh.md: f2ef384b8fe5e5948a74dd9796fcae3077ac2094 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 4964828126..38cee0071c 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -66,7 +66,7 @@ Every card is read in place in the call tree; there is no second, full-height pr The Details view covers the 36 registered tools: goal and schedule tools, Cordis inspection, workflow and Ralph reports, Session event/search/trace queries, agent and teammate controls, background jobs, persistent terminals, and LSP navigation. These expanded bodies read successful logged results, preserve generic input/output for failures or unsupported data, and keep Inspect available. Dates include the viewer's time zone, and statuses reflect the call result rather than current session state. The browser adapter consumes recorded producer text and JSON; Host service objects and presenter callbacks do not cross into the Client. [Compact tool details](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md) records the presentation trade-offs. -Expanded status dots and labels use static semantic colors. An interruption receipt confirms only that interruption was requested. +Expanded status dots and labels use static semantic colors. Receipt and job-output headers keep neutral text and omit the status while expanded. An interruption receipt confirms only that interruption was requested. The terminal model uses `hasSpillNotice` from the browser-safe `@deepseek-ai/dsh-spill-policy/notice` entry, not an independent UI pattern. The [spill-policy README](../../spill/spill-policy/README.md#shared-notice-ownership) owns notice formatting and recognition. This check conservatively selects generic output; matching text cannot authenticate its source, and replay leaves recorded result bytes untouched. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 5c8f9b0ac5..f2ef384b8f 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -66,7 +66,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` Details 视图覆盖已注册的 36 个工具:目标和定时任务工具、Cordis 检查、workflow 与 Ralph 报告、Session 事件/搜索/轨迹查询、Agent 与 teammate 控制、后台作业、持久终端以及 LSP 导航。展开内容读取成功的记录结果,为失败或不支持的数据保留通用输入/输出,并保留 Inspect。日期包含查看者的时区,状态反映调用结果而非当前会话状态。浏览器适配器消费已记录的 producer 文本与 JSON;Host service 对象和 presenter 回调不会进入 Client。[紧凑工具详情](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md)记录了呈现取舍。 -展开后的状态圆点和文字使用静态语义色。中断回执仅确认已发出中断请求。 +展开后的状态圆点和文字使用静态语义色。操作回执和任务输出的标题保持中性色,展开时省略标题中的状态。中断回执仅确认已发出中断请求。 terminal model 使用浏览器安全入口 `@deepseek-ai/dsh-spill-policy/notice` 的 `hasSpillNotice`,而非独立的 UI 匹配规则。[spill-policy README](../../spill/spill-policy/README.zh.md#shared-notice-ownership) 负责提示文本的格式化与识别。该检查保守地选择通用输出;匹配的文本无法证明其来源,回放也不改变已记录的结果字节。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx index 64b593a9b6..b6bac6af4a 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.tsx @@ -26,6 +26,8 @@ export interface ToolDetailItem { export interface ToolDetailsModel { items: readonly ToolDetailItem[] summary?: string + /** Header text while expanded, omitting a receipt status shown in the body. */ + expandedSummary?: string empty?: string caption?: string unchanged?: { label: string; items: ToolDetailsModel['items'] } diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 9a67b36ff6..d86f730e0f 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -167,7 +167,7 @@ export function ToolRow({ const status = stateStatus(state, t) // A failure must replace, not supplement, the normal summary. const failureLine = state === 'error' ? errorSummary ?? null : null - const summaryText = failureLine ?? terminalBody?.description ?? summary + const summaryText = failureLine ?? terminalBody?.description ?? (open ? detailsBody?.expandedSummary ?? summary : summary) // A diff row's collapsed line carries the card's +/- totals (the same // numbers the expanded footer prints) so the change size reads without // expanding; an explicit summarySuffix (none today on diff rows) wins. diff --git a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts index d869070431..830d16be36 100644 --- a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts @@ -13,7 +13,10 @@ function arg(args: Record, key: string): string { } function receipt(title: string, badge: NonNullable, t: DetailTranslate, fields: DetailItem['fields'] = [], description?: string): ToolDetailsModel { - return detailList([{ title, badge, fields, ...(description === undefined ? {} : { description }) }], `${title} · ${badge.label}`, t) + return { + ...detailList([{ title, badge, fields, ...(description === undefined ? {} : { description }) }], `${title} · ${badge.label}`, t), + expandedSummary: title, + } } function agentList(text: string, t: DetailTranslate): ToolDetailsModel | null { @@ -161,7 +164,10 @@ export function controlDetails(name: string, args: Record, text const code = truncated ? output.slice(0, -OUTPUT_TRUNCATED.length) : output const description = [match[2], truncated ? t('detail.output.truncated') : undefined] .filter((value): value is string => value !== undefined).join(' · ') - return detailList([{ title: target, badge: detailBadge(match[1], t), fields: [], ...(description === '' ? {} : { description }), code: { text: code } }], `${target} · ${detailBadge(match[1], t).label}`, t) + return { + ...detailList([{ title: target, badge: detailBadge(match[1], t), fields: [], ...(description === '' ? {} : { description }), code: { text: code } }], `${target} · ${detailBadge(match[1], t).label}`, t), + expandedSummary: target, + } } case 'job_kill': if (text === `requested cancellation of job ${target}`) return receipt(target, { label: t('detail.receipt.cancel'), tone: 'warning' }, t, [], arg(args, 'reason')) diff --git a/snapshots/web/tool-details/ui.expected.md b/snapshots/web/tool-details/ui.expected.md index e1cb9d408e..c65a66730e 100644 --- a/snapshots/web/tool-details/ui.expected.md +++ b/snapshots/web/tool-details/ui.expected.md @@ -408,9 +408,9 @@ - text: deepseek/deepseek-reasoner - paragraph: DeepSeek Reasoner - button "Inspect" -- button "Delegate task 检查工具卡片布局,并列出三个最值得改进的地方。 · Started" [expanded]: +- button "Delegate task 检查工具卡片布局,并列出三个最值得改进的地方。" [expanded]: - img - - text: Delegate task 检查工具卡片布局,并列出三个最值得改进的地方。 · Started + - text: Delegate task 检查工具卡片布局,并列出三个最值得改进的地方。 - text: Recorded result - list: - listitem: @@ -442,18 +442,18 @@ - term: Depth - definition: "2" - button "Inspect" -- button "Send message agent-demo-review · Message delivered" [expanded]: +- button "Send message agent-demo-review" [expanded]: - img - - text: Send message agent-demo-review · Message delivered + - text: Send message agent-demo-review - text: Recorded result - list: - listitem: - text: agent-demo-review Message delivered - paragraph: 请优先检查窄屏下的任务差异列表。 - button "Inspect" -- button "Interrupt agent agent-demo-review · Interrupt requested" [expanded]: +- button "Interrupt agent agent-demo-review" [expanded]: - img - - text: Interrupt agent agent-demo-review · Interrupt requested + - text: Interrupt agent agent-demo-review - text: Recorded result - list: - listitem: agent-demo-review Interrupt requested @@ -476,9 +476,9 @@ - term: Type - definition: terminal - button "Inspect" -- button "Read job output job-demo-build · Running" [expanded]: +- button "Read job output job-demo-build" [expanded]: - img - - text: Read job output job-demo-build · Running + - text: Read job output job-demo-build - text: Recorded result - list: - listitem: @@ -486,9 +486,9 @@ - button "Copy" - code: Building client assets… ✓ 184 modules transformed. dist/index.js 92.4 kB - button "Inspect" -- button "Cancel background job job-demo-preview · Cancellation requested" [expanded]: +- button "Cancel background job job-demo-preview" [expanded]: - img - - text: Cancel background job job-demo-preview · Cancellation requested + - text: Cancel background job job-demo-preview - text: Recorded result - list: - listitem: @@ -543,9 +543,9 @@ - term: Exit code - definition: "0" - button "Inspect" -- button "Signal terminal terminal-demo-1 · Signal delivered" [expanded]: +- button "Signal terminal terminal-demo-1" [expanded]: - img - - text: Signal terminal terminal-demo-1 · Signal delivered + - text: Signal terminal terminal-demo-1 - text: Recorded result - list: - listitem: @@ -555,9 +555,9 @@ - term: Process group - definition: "43120" - button "Inspect" -- button "Close terminal terminal-demo-1 · Closed" [expanded]: +- button "Close terminal terminal-demo-1" [expanded]: - img - - text: Close terminal terminal-demo-1 · Closed + - text: Close terminal terminal-demo-1 - text: Recorded result - list: - listitem: terminal-demo-1 Closed @@ -661,9 +661,9 @@ - term: Context - definition: fresh - button "Inspect" -- button "Send message ui-review · Message delivered" [expanded]: +- button "Send message ui-review" [expanded]: - img - - text: Send message ui-review · Message delivered + - text: Send message ui-review - text: Recorded result - list: - listitem: @@ -773,16 +773,16 @@ - listitem: - paragraph: task-demo-1 - button "Inspect" -- button "Wait for teammates Teammate activity · Change detected" [expanded]: +- button "Wait for teammates Teammate activity" [expanded]: - img - - text: Wait for teammates Teammate activity · Change detected + - text: Wait for teammates Teammate activity - text: Recorded result - list: - listitem: Teammate activity Change detected - button "Inspect" -- button "Interrupt agent ui-review · Interrupt requested" [expanded]: +- button "Interrupt agent ui-review" [expanded]: - img - - text: Interrupt agent ui-review · Interrupt requested + - text: Interrupt agent ui-review - text: Recorded result - list: - listitem: From 212f5f9ccc7e18d70de56698dfc217c7ec3a27e5 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 11:33:29 +0800 Subject: [PATCH 029/225] test(web): cover trajectory attachment presentation Refs #4356 --- apps/web/tests/file-upload-round.e2e.ts | 112 ++++++++++++++++-- .../file-upload-round/trajectory.expected.md | 40 ++++++- 2 files changed, 139 insertions(+), 13 deletions(-) diff --git a/apps/web/tests/file-upload-round.e2e.ts b/apps/web/tests/file-upload-round.e2e.ts index 5df27af9d9..bedab5ea9d 100644 --- a/apps/web/tests/file-upload-round.e2e.ts +++ b/apps/web/tests/file-upload-round.e2e.ts @@ -15,7 +15,7 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { - assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -108,6 +108,93 @@ describe('web e2e: generic file upload through the real assembly', () => { let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] + let liveTrajectory: string | undefined + + /** Inspect one durable mixed message through the real image slot and all message tabs. */ + async function inspectTrajectoryAttachments(): Promise { + const userMessage = sessionEvents.find(event => event.type === 'user/message' && event.data.source.kind === 'user') + if (userMessage?.type !== 'user/message') throw new Error('the replayed turn recorded no user message') + const content = userMessage.data.content + const imageName = IMAGE_NAMES[0]! + await page.getByRole('tab', { name: 'Chat', exact: true }).click() + const chatImage = page.getByRole('img', { name: imageName, exact: true }) + await expect.poll(() => chatImage.getAttribute('src'), { timeout: 10_000 }).toMatch(/^blob:/) + const chatImageUrl = await chatImage.getAttribute('src') + + await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() + await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) + const row = page.getByRole('row', { name: `USER, Images ×1 · Files ×1 · ${PROMPT}`, exact: true }) + await row.waitFor({ timeout: 10_000 }) + const ledger = await captureStableAria(page, '[data-trajectory-row-key][aria-label*="Files ×1"]', scaffold.workspaceCwd) + await row.click() + + const panel = page.getByRole('tabpanel') + const list = panel.getByRole('list', { name: 'Attachments', exact: true }) + let summaryAttachments: string | undefined + let preview: string | undefined + for (const tab of ['Summary', 'Preview']) { + await page.getByRole('tab', { name: tab, exact: true }).click() + await list.waitFor() + expect(await panel.getByText(PROMPT, { exact: true }).count()).toBe(1) + const rows = list.getByRole('listitem') + expect(await rows.count()).toBe(2) + expect(await rows.nth(0).getByTitle(FILE_NAME, { exact: true }).count()).toBe(1) + expect(await rows.nth(1).getByTitle(imageName, { exact: true }).count()).toBe(1) + expect(await rows.nth(0).textContent()).toContain('TXT · 16B') + expect(await rows.nth(1).textContent()).toContain('image/png · 69B · 1 × 1') + const thumbnail = list.getByRole('img', { name: imageName, exact: true }) + await expect.poll(() => thumbnail.getAttribute('src'), { timeout: 10_000 }).toBe(chatImageUrl) + expect(await thumbnail.evaluate(element => getComputedStyle(element).objectFit)).toBe('contain') + + const attachments = await captureStableAria(page, '[role="tabpanel"] [aria-label="Attachments"]', scaffold.workspaceCwd) + if (tab === 'Summary') summaryAttachments = attachments + else { + expect(attachments).toBe(summaryAttachments) + preview = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + } + const opener = list.getByRole('button', { name: `${imageName}, click to view original`, exact: true }) + await opener.focus() + await opener.press('Enter') + const dialog = page.getByRole('dialog', { name: 'Original image preview', exact: true }) + await dialog.waitFor() + expect(await dialog.getByRole('img', { name: imageName }).getAttribute('src')).toBe(chatImageUrl) + await page.keyboard.press('Escape') + await dialog.waitFor({ state: 'hidden' }) + expect(await opener.evaluate(element => element === document.activeElement)).toBe(true) + } + + await page.getByRole('tab', { name: 'Raw', exact: true }).click() + const disclosures = panel.locator('details') + expect(await disclosures.count()).toBe(2) + expect(await panel.getByRole('img').count()).toBe(0) + for (const disclosure of await disclosures.all()) { + expect(await disclosure.getAttribute('open')).toBeNull() + expect(await disclosure.locator('pre').isVisible()).toBe(false) + } + const rawCollapsed = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + const blocks = panel.locator('details, section') + expect(await blocks.count()).toBe(content.length) + for (const [index, block] of content.entries()) { + const renderedBlock = blocks.nth(index) + if (block.type === 'text') { + expect(await renderedBlock.locator('pre').textContent()).toBe(block.text) + } else { + expect(['image', 'file']).toContain(block.type) + await renderedBlock.locator('summary').click() + expect(await renderedBlock.locator('pre').isVisible()).toBe(true) + const raw: unknown = JSON.parse((await renderedBlock.locator('pre').textContent())!) + expect(raw).toEqual(block) + } + } + const rawExpanded = await captureStableAria(page, '[role="tabpanel"]', scaffold.workspaceCwd) + return [ + '# Ledger', ledger, + '# Summary attachments', summaryAttachments, + '# Preview', preview, + '# Raw (collapsed)', rawCollapsed, + '# Raw (expanded)', rawExpanded, + ].join('\n\n') + } beforeAll(async () => { scaffold = await launchWebScaffold({ @@ -283,16 +370,19 @@ describe('web e2e: generic file upload through the real assembly', () => { await compareOrRefreshGolden(HISTORY_EXPECTED, renderHistoryAttachmentGeometry(geometry), MODE) }) - it.skipIf(MODE === 'record')('marks the durable file in Trajectory without copying the Chat card', async () => { - await page.getByRole('tab', { name: 'Trajectory', exact: true }).click() - await page.getByLabel('Trajectory timeline').waitFor({ timeout: 30_000 }) - await page.getByRole('row', { name: /Files ×1/ }).waitFor({ timeout: 10_000 }) - const snapshot = await captureStableAria( - page, - '[data-trajectory-row-key][aria-label*="Files ×1"]', - scaffold.workspaceCwd, - ) - await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE) + it.skipIf(MODE === 'record')('shows both attachment kinds throughout the Trajectory inspector', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory')) + liveTrajectory = await inspectTrajectoryAttachments() + await compareOrRefreshGolden(TRAJECTORY_EXPECTED, liveTrajectory, MODE) + }) + + it.skipIf(MODE === 'record')('restores the same Trajectory attachments from persisted history after reload', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-file-upload-trajectory-reload')) + expect(liveTrajectory).toBeDefined() + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(await inspectTrajectoryAttachments()).toBe(liveTrajectory) }) it.skipIf(MODE === 'record')('stayed clean and kept the exact fixture inventory', async () => { diff --git a/snapshots/web/file-upload-round/trajectory.expected.md b/snapshots/web/file-upload-round/trajectory.expected.md index 54cde508ec..855ff5429c 100644 --- a/snapshots/web/file-upload-round/trajectory.expected.md +++ b/snapshots/web/file-upload-round/trajectory.expected.md @@ -1,3 +1,39 @@ -- row "USER, Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.": +# Ledger + +- row "USER, Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop.": - cell "Turn 1 USER": USER - - cell "Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop." + - cell "Images ×1 · Files ×1 · Read the attached file with the read tool, reply with exactly the single word it contains, and stop." + +# Summary attachments + +- list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "reference-1.png, click to view original": + - img "reference-1.png" + - text: reference-1.png image/png · 69B · 1 × 1 + +# Preview + +- tabpanel "Preview": + - paragraph: Read the attached file with the read tool, reply with exactly the single word it contains, and stop. + - list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "reference-1.png, click to view original": + - img "reference-1.png" + - text: reference-1.png image/png · 69B · 1 × 1 + +# Raw (collapsed) + +- tabpanel "Raw": + - group: "Block #1 file poem.txt" + - group: "Block #2 image reference-1.png" + - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop." + +# Raw (expanded) + +- tabpanel "Raw": + - group: "Block #1 file poem.txt { \"type\": \"file\", \"attachment\": { \"attachmentId\": \"sha256:29a0837077f6f3cbddd98c0605897cc6f2b4e5833fb8e1ca4a8045d89b2c8c60\", \"name\": \"poem.txt\", \"bytes\": 16 } }" + - group: "Block #2 image reference-1.png { \"type\": \"image\", \"attachment\": { \"attachmentId\": \"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640\", \"mediaType\": \"image/png\", \"width\": 1, \"height\": 1, \"bytes\": 69, \"name\": \"reference-1.png\" } }" + - text: "Block #3 text Read the attached file with the read tool, reply with exactly the single word it contains, and stop." From f32c0cc3d9a3827ddb22f11f4f1494139fd8d2a9 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 17 Sep 2026 12:03:41 +0800 Subject: [PATCH 030/225] refactor(client): simplify recorded tool detail models --- .../2026-09-10-compact-tool-details.i18n.yaml | 4 ++-- .../2026-09-10-compact-tool-details.md | 4 +++- .../2026-09-10-compact-tool-details.zh.md | 4 +++- .../tool/models/control-details-model.ts | 13 ++++++----- .../client/tool/models/details-card-model.ts | 7 +++--- .../tool/models/inspection-details-model.ts | 5 +++-- .../src/client/tool/models/todo-diff-model.ts | 17 +++++--------- .../src/client/tool/models/todo-history.ts | 22 +++++++------------ .../src/client/tool/toolviews/details-row.tsx | 4 +--- .../tests/details-models.client.spec.ts | 2 -- .../ui-tool/tests/details-row.client.spec.tsx | 17 +++++++------- 11 files changed, 44 insertions(+), 55 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml index acc370a074..484c0c2a3b 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md -2026-09-10-compact-tool-details.md: 7460af5bb3b480416e777fa864d80e95e25d302c -2026-09-10-compact-tool-details.zh.md: e104ac1293f8b02d67bdba0d5987e693af58d97a +2026-09-10-compact-tool-details.md: 6c1f29c5554c74b01fc2a86c2dee3750a529a6b5 +2026-09-10-compact-tool-details.zh.md: d0822a48f100f9f21ef4706204087fcd1e627da4 diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md index 7460af5bb3..6c1f29c555 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md @@ -14,6 +14,8 @@ The `ui-tool` package registers goal and schedule toolviews through the existing Tool rows keep their disclosure and Inspect interaction. Checklist marks are static and have accessible status labels. Schedule times use absolute dates with the viewer's time zone; states come from the recorded result. Goal details distinguish an active goal awaiting continuation from an armed goal. These display values do not subscribe to the current goal, todo, or schedule projection. +Todo changes compare each root or nested call with its preceding durable `todo/write`, so later writes cannot alter an earlier card. The conversation assembler repairs predecessors when older history loads. Until the predecessor is known, the card shows the submitted list without inferred additions or removals. + This scoped visual change is an exception to the visual-equivalence requirement in [Client-derived tool presentation](2026-08-23-client-derived-tool-presentation.md). Its raw-event ownership, generic fallback, and single keyed registry remain authoritative. ## Alternatives considered @@ -24,4 +26,4 @@ This scoped visual change is an exception to the visual-equivalence requirement ## Consequences -The 36 registered tool names gain readable details without changing Host tools, Session events, or public Client exports. `detailsCardModel` recognizes goal and schedule JSON, `controlDetails` recognizes agent, teammate, job, terminal, and LSP receipts, and `inspectionDetails` recognizes Cordis, workflow, Ralph, and Session query reports; any other result remains inspectable as raw text. The authored tool-details Session replay covers their assembled rendering. +Registered tools gain readable details without changing Host tools, Session events, or public Client exports. Tool-specific adapters interpret recorded JSON and text; unrecognized formats retain generic output. The authored tool-details Session replay covers their assembled rendering. diff --git a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md index e104ac1293..d0822a48f1 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md @@ -14,6 +14,8 @@ Status: implemented 工具行保留展开和 Inspect 交互。任务清单标记是静态的,并带有可访问的状态标签。定时任务使用查看者时区的绝对日期,状态取自记录的结果。目标详情区分等待继续的活动目标和已启动的目标。这些显示值不订阅当前目标、任务清单或定时任务投影。 +任务清单的变化将每个根调用或嵌套调用与之前的持久化 `todo/write` 比较,因此后续写入不会改变较早的卡片。对话组装器在加载更早的历史时修复前序记录。前序记录尚不明确时,卡片展示提交的列表,不推断新增或删除。 + 这项有限的视觉调整是[客户端派生工具呈现](2026-08-23-client-derived-tool-presentation.zh.md)中视觉等价要求的例外。该决策中的原始事件所有权、通用回退和唯一按键注册表仍然有效。 ## 考虑过的替代方案 @@ -24,4 +26,4 @@ Status: implemented ## 影响 -已注册的 36 个工具名称获得可读详情,无需修改 Host 工具、Session 事件或公共 Client 导出。`detailsCardModel` 识别目标和定时任务 JSON,`controlDetails` 识别 Agent、teammate、作业、终端和 LSP 回执,`inspectionDetails` 识别 Cordis、workflow、Ralph 和 Session 查询报告;其他结果仍可作为原始文本查看。手工编写的 tool-details Session 回放覆盖它们在完整页面中的呈现。 +已注册的工具获得可读详情,无需改变 Host 工具、Session 事件或公开的 Client 导出。工具专用适配器解读记录中的 JSON 和文本;未识别的格式保留通用输出。手写的 tool-details Session 回放覆盖这些工具组装后的渲染。 diff --git a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts index 830d16be36..19e90042bd 100644 --- a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts @@ -1,7 +1,7 @@ /** Entity lists and receipts for agent, job, terminal, and language-server tools. */ import type { ToolDetailsModel } from '../components/ToolDetails.tsx' import { - detailBadge, detailJson, detailList, detailRecord, inspectionItems, + detailBadge, detailList, detailRecord, inspectionItems, type DetailItem, type DetailTranslate, } from './detail-model-shared.ts' @@ -19,8 +19,7 @@ function receipt(title: string, badge: NonNullable, t: Deta } } -function agentList(text: string, t: DetailTranslate): ToolDetailsModel | null { - const json = detailJson(text) +function agentList(text: string, json: unknown, t: DetailTranslate): ToolDetailsModel | null { if (Array.isArray(json)) return detailList(inspectionItems(json, t), t('detail.agents.count', { count: json.length }), t) if (text === '(no subagents)') return detailList([], t('detail.agents.count', { count: 0 }), t) const items: DetailItem[] = [] @@ -102,14 +101,16 @@ function lspDetails(args: Record, text: string, t: DetailTransl * @param name - Wire tool name, including Team-scoped aliases. * @param args - Parsed recorded arguments. * @param text - Successful recorded result text. + * @param json - Parsed whole-result JSON, or undefined for non-JSON text. * @param t - Conversation translator. * @returns Compact details, or null when the output format is not recognized. */ -export function controlDetails(name: string, args: Record, text: string, t: DetailTranslate): ToolDetailsModel | null { - const json = detailJson(text) +export function controlDetails( + name: string, args: Record, text: string, json: unknown, t: DetailTranslate, +): ToolDetailsModel | null { const target = arg(args, 'target') || arg(args, 'agent_id') || arg(args, 'sessionId') || arg(args, 'job_id') switch (name) { - case 'list_agents': return agentList(text, t) + case 'list_agents': return agentList(text, json, t) case 'job_list': return jobList(text, t) case 'terminal_list': return terminalList(text, t) case 'lsp': return lspDetails(args, text, t) diff --git a/packages/client/ui-tool/src/client/tool/models/details-card-model.ts b/packages/client/ui-tool/src/client/tool/models/details-card-model.ts index eb5176c9fe..cb38149518 100644 --- a/packages/client/ui-tool/src/client/tool/models/details-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/details-card-model.ts @@ -114,12 +114,11 @@ export function detailsCardModel(block: ToolCallBlock, t: Translate, locale: str if (!('kind' in block) || block.isError) return null const call = parsedToolCall(block) if (call === null) return null - if (call.name === 'todo_write') return todosDetail(call.args, t) const text = singleResultText(block) if (text === undefined) return null - const details = controlDetails(call.name, call.args, text, t) ?? inspectionDetails(call.name, call.args, text, t, locale) - if (details !== null) return details const value = detailJson(text) + const details = controlDetails(call.name, call.args, text, value, t) ?? inspectionDetails(call.name, call.args, text, value, t, locale) + if (details !== null) return details if (value === undefined) return null switch (call.name) { case 'create_goal': @@ -137,7 +136,7 @@ export function detailsCardModel(block: ToolCallBlock, t: Translate, locale: str if (item === null) return null items.push(item) } - return { items, empty: t('detail.schedule.empty') } + return { items, summary: t('detail.schedule.count', { count: items.length }), empty: t('detail.schedule.empty') } } case 'schedule_delete': if (!detailRecord(value) || !nonempty(value.id) || value.deleted !== true) return null diff --git a/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts b/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts index 8e5e751671..b5ecff3521 100644 --- a/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts @@ -154,18 +154,19 @@ function traceDetails(name: string, text: string, t: DetailTranslate, locale: st * @param name - Wire tool name. * @param args - Recorded argument object. * @param text - Recorded result text. + * @param json - Parsed whole-result JSON, or undefined for non-JSON text. * @param t - Conversation translator. * @param locale - Date display locale. * @returns Structured details, or null when an output format is unknown. */ export function inspectionDetails( - name: string, args: Record, text: string, t: DetailTranslate, locale: string, + name: string, args: Record, text: string, json: unknown, t: DetailTranslate, locale: string, ): ToolDetailsModel | null { if (hasSpillNotice(text)) return null switch (name) { case 'cordis_inspect_list': case 'cordis_inspect_query': - case 'cordis_inspect_self': return cordisDetails(name, args, detailJson(text), t) + case 'cordis_inspect_self': return cordisDetails(name, args, json, t) case 'workflow': case 'ralph': return workflowDetails(name, args, text, t) case 'session_search': diff --git a/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts b/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts index 1b620597f1..ac4f9213fb 100644 --- a/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/todo-diff-model.ts @@ -1,10 +1,10 @@ -/** Todo changes relative to the preceding successful recorded write. */ +/** Todo changes relative to the preceding recorded write. */ import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolDetailsModel } from '../components/ToolDetails.tsx' import type { TodoBaseline } from './todo-history.ts' -import { detailsCardModel, todosDetail } from './details-card-model.ts' -import { detailRecord } from './detail-model-shared.ts' +import { todosDetail } from './details-card-model.ts' +import { parsedToolCall } from './raw-tool-call.ts' /** * Compare this write with its predecessor in the loaded call history. @@ -21,15 +21,8 @@ export function todoDiffModel( t: TranslateNS<'conversation'>, ): { details: ToolDetailsModel; summary: string | null } | null { if (!('kind' in block) || block.isError) return null - let current: ToolDetailsModel | null - if ('kind' in block && block.call?.name === 'todo_write') { - let args: unknown - try { args = JSON.parse(block.call.argsRaw) } - catch { return null } - current = detailRecord(args) ? todosDetail(args, t) : null - } else { - current = detailsCardModel(block, t, 'en') - } + const call = parsedToolCall(block) + const current = call?.name === 'todo_write' ? todosDetail(call.args, t) : null if (current === null) return null const previous: ToolDetailsModel | null = baseline?.todos === undefined ? null : { items: baseline.todos.map(todo => ({ title: todo.content, status: { value: todo.status, label: t(`detail.todo.${todo.status}`) }, fields: [], diff --git a/packages/client/ui-tool/src/client/tool/models/todo-history.ts b/packages/client/ui-tool/src/client/tool/models/todo-history.ts index 564755e4b8..591bc05ca5 100644 --- a/packages/client/ui-tool/src/client/tool/models/todo-history.ts +++ b/packages/client/ui-tool/src/client/tool/models/todo-history.ts @@ -59,26 +59,20 @@ interface TodoHistoryNode extends ConversationViewNode { readonly data: TodoBaseline } -/** Incremental target whose publication invalidates keyed reads without copying older calls. */ +/** Incremental lookup snapshots preserve earlier call baselines across later writes. */ export const todoHistoryView: ConversationViewDefinition = { target: 'tool-todo-history', create: () => { let calls = new Map() - const snapshot = (source: ReadonlyMap): TodoHistory => ({ get: callId => source.get(callId) }) - let current: TodoHistory = snapshot(calls) - const publish = (next: Map): TodoHistory => { - calls = next - current = snapshot(next) - return current - } return { - empty: current, - replace: ({ nodes }) => publish(new Map(nodes.map(node => [node.id, node.data]))), + empty: calls, + replace: ({ nodes }) => calls = new Map(nodes.map(node => [node.id, node.data])), apply: ({ upserts }) => { - if (upserts.length === 0) return current - const next = new Map(calls) - for (const node of upserts) next.set(node.id, node.data) - return publish(next) + if (upserts.length > 0) { + calls = new Map(calls) + for (const node of upserts) calls.set(node.id, node.data) + } + return calls }, } }, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx index 746ddbaf2c..2a98ab68ed 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/details-row.tsx @@ -90,9 +90,7 @@ export function DetailsRow({ toolName, block, cwd, home, openFile, inspect, t }: toolName={toolName} icon={detailIcon(toolName)} title={t(titleKey)} - summary={details?.summary ?? (toolName === 'schedule_list' && details !== null - ? t('detail.schedule.count', { count: details.items.length }) - : details?.items[0]?.title ?? details?.empty ?? model.summary)} + summary={details?.summary ?? details?.items[0]?.title ?? details?.empty ?? model.summary} details={details} bodyRaw={model.bodyRaw} output={model.output} diff --git a/packages/client/ui-tool/tests/details-models.client.spec.ts b/packages/client/ui-tool/tests/details-models.client.spec.ts index bb259fc56d..d11547bf51 100644 --- a/packages/client/ui-tool/tests/details-models.client.spec.ts +++ b/packages/client/ui-tool/tests/details-models.client.spec.ts @@ -178,8 +178,6 @@ describe('inspection detail adapters', () => { describe('detailsCardModel fallback contract', () => { it('keeps malformed successful domain results generic', () => { expect(detailsCardModel(output('create_goal', 'not-json'), t, 'en')).toBeNull() - expect(detailsCardModel(output('todo_write', 'x', { todos: [{ content: 'x', status: 'pending' }] }), t, 'en')).not.toBeNull() - expect(detailsCardModel(output('todo_write', 'x', { todos: [{ content: 'x', status: 'unknown' }] }), t, 'en')).toBeNull() expect(detailsCardModel(output('schedule_create', '{"id":"s","prompt":"x","kind":"at","scheduledAt":"invalid","state":"scheduled","deliveryMode":"session-local"}'), t, 'en')).toBeNull() }) diff --git a/packages/client/ui-tool/tests/details-row.client.spec.tsx b/packages/client/ui-tool/tests/details-row.client.spec.tsx index ab98aeab2c..1d3ed9100b 100644 --- a/packages/client/ui-tool/tests/details-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/details-row.client.spec.tsx @@ -6,7 +6,7 @@ import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' -import { detailsCardModel } from '../src/client/tool/models/details-card-model.ts' +import { detailsCardModel, todosDetail } from '../src/client/tool/models/details-card-model.ts' import { DetailsRow, detailsToolview } from '../src/client/tool/toolviews/details-row.tsx' const t = makeTranslate(en, commonEn) @@ -30,10 +30,11 @@ describe('detailsCardModel', () => { it('keeps all reminders and their recorded state, including one-shot and empty lists', () => { const model = detailsCardModel(result('schedule_list', [schedule, { ...schedule, id: 'schedule-2', kind: 'at', state: 'overdue' }, { ...schedule, id: 'schedule-3', kind: 'after', afterSeconds: 30 }]), t, 'en') expect(model?.items).toHaveLength(3) + expect(model?.summary).toBe('3 reminders') expect(model?.items[0]?.fields).toContainEqual({ label: 'Repeat', value: 'Every 1 h' }) expect(model?.items[1]?.fields).toContainEqual({ label: 'Status', value: 'Overdue, awaiting session resume' }) expect(model?.items[2]?.fields).toContainEqual({ label: 'Repeat', value: 'Once' }) - expect(detailsCardModel(result('schedule_list', []), t, 'en')?.empty).toBe('No reminders') + expect(detailsCardModel(result('schedule_list', []), t, 'en')).toMatchObject({ summary: '0 reminders', empty: 'No reminders' }) expect(detailsCardModel(result('schedule_delete', { id: 'schedule-1', deleted: true }), t, 'en')?.items[0]?.fields).toContainEqual({ label: 'Status', value: 'Deleted' }) }) @@ -63,15 +64,15 @@ describe('detailsCardModel', () => { expect(detailsCardModel({ callId: 'c1', name: 'create_goal', argsRaw: '{}', turn: 1, step: 1, time: 1000, subCalls: [] }, t, 'en')).toBeNull() expect(detailsCardModel({ ...block, parentCallId: 'parent' }, t, 'en')?.items[0]?.title).toBe(goal.objective) }) +}) - it('shows only successful valid to-do lists and uses the tool’s trimmed text', () => { - const block = result('todo_write', 'Updated todo list', JSON.stringify({ todos: [{ content: ' Build the page ', status: 'in_progress' }] })) - expect(detailsCardModel(block, t, 'en')?.items[0]).toEqual({ title: 'Build the page', status: { value: 'in_progress', label: 'In progress' }, fields: [] }) - expect(detailsCardModel({ ...block, isError: true }, t, 'en')).toBeNull() +describe('todosDetail', () => { + it('accepts valid to-do lists and uses the tool’s trimmed text', () => { + expect(todosDetail({ todos: [{ content: ' Build the page ', status: 'in_progress' }] }, t)?.items[0]).toEqual({ title: 'Build the page', status: { value: 'in_progress', label: 'In progress' }, fields: [] }) for (const todos of [[{ content: 'x', status: 'unknown' }], [{ content: '', status: 'pending' }], [null], [{ content: 'x', status: 'pending' }, { content: ' x ', status: 'completed' }]]) { - expect(detailsCardModel(result('todo_write', '', JSON.stringify({ todos })), t, 'en')).toBeNull() + expect(todosDetail({ todos }, t)).toBeNull() } - expect(detailsCardModel(result('todo_write', '', '{"todos":[]}'), t, 'en')?.empty).toBe('The to-do list is empty') + expect(todosDetail({ todos: [] }, t)?.empty).toBe('The to-do list is empty') }) }) From 83b2ff7aa171e486d1ca742bf7ab658674f23b71 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 17 Sep 2026 12:04:19 +0800 Subject: [PATCH 031/225] fix(client): preserve tool detail navigation and layout --- apps/web/tests/tool-details.e2e.ts | 18 +++++++++ packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../tool/components/ToolDetails.module.css | 2 +- .../tool/models/control-details-model.ts | 3 +- .../tool/models/inspection-details-model.ts | 2 +- .../tests/details-models.client.spec.ts | 28 ++++++++++---- snapshots/web/tool-details/session.v3.jsonl | 6 +-- snapshots/web/tool-details/ui.expected.md | 37 ++++++++++++++++--- 10 files changed, 81 insertions(+), 23 deletions(-) diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts index 336ed7901e..a1a84780e9 100644 --- a/apps/web/tests/tool-details.e2e.ts +++ b/apps/web/tests/tool-details.e2e.ts @@ -72,6 +72,24 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { await page.setViewportSize({ width: 360, height: 800 }) const card = page.locator('[data-tool="schedule_create"]') expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + const reminders = page.locator('[data-tool="schedule_list"]') + await reminders.scrollIntoViewIfNeeded() + const list = reminders.getByRole('list').first() + await list.evaluate((element) => { + const second = element.children[1]! + element.scrollTop += second.getBoundingClientRect().top - element.getBoundingClientRect().top + }) + expect(await list.evaluate(element => element.scrollTop)).toBeGreaterThan(0) + const geometry = await reminders.evaluate((element) => { + const title = element.querySelector('ul')!.children[1]!.querySelector('[class*="heading"] > span')!.getBoundingClientRect() + const inspect = element.querySelector('[class*="inspectButton"]')!.getBoundingClientRect() + return { + verticalOverlap: Math.min(title.bottom, inspect.bottom) - Math.max(title.top, inspect.top), + horizontalGap: inspect.left - title.right, + } + }) + expect(geometry.verticalOverlap).toBeGreaterThan(0) + expect(geometry.horizontalGap).toBeGreaterThanOrEqual(0) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 231310074b..94a2b9f6c4 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -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-tool/README.md -README.md: 0e41c1f1e0d8e0fb06f83cf7f7d2798b3cf8c1a0 -README.zh.md: 6a71cbd11a003b794e5ce79bc2fc63294b6c3f42 +README.md: cb01789c11e430a36081fd6e3f8629250038053a +README.zh.md: d648a93e294d6ef7830ecdf895493d44d292c2c6 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 0e41c1f1e0..cb01789c11 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -68,7 +68,7 @@ Chat diff cards keep nine rows before folding, enough for a file header, one rem An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason. -The Details view covers the 36 registered tools: goal and schedule tools, Cordis inspection, workflow and Ralph reports, Session event/search/trace queries, agent and teammate controls, background jobs, persistent terminals, and LSP navigation. These expanded bodies read successful logged results, preserve generic input/output for failures or unsupported data, and keep Inspect available. Dates include the viewer's time zone, and statuses reflect the call result rather than current session state. The browser adapter consumes recorded producer text and JSON; Host service objects and presenter callbacks do not cross into the Client. [Compact tool details](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md) records the presentation trade-offs. +Recorded tool details cover goal and schedule tools, Cordis inspection, workflow and Ralph reports, Session event/search/trace queries, agent and teammate controls, background jobs, persistent terminals, and LSP navigation. These expanded bodies read successful logged results, preserve generic input/output for failures or unsupported data, and keep Inspect available. Dates include the viewer's time zone, and statuses reflect the call result rather than current session state. Session traces preserve descendant indentation. LSP results open filesystem paths through the Host callback and display other URIs as text. The browser adapter consumes recorded producer text and JSON; Host service objects and presenter callbacks do not cross into the Client. [Compact tool details](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.md) records the presentation trade-offs. Expanded status dots and labels use static semantic colors. Receipt and job-output headers keep neutral text and omit the status while expanded. An interruption receipt confirms only that interruption was requested. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 6a71cbd11a..d648a93e29 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -68,7 +68,7 @@ Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删 Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。 -Details 视图覆盖已注册的 36 个工具:目标和定时任务工具、Cordis 检查、workflow 与 Ralph 报告、Session 事件/搜索/轨迹查询、Agent 与 teammate 控制、后台作业、持久终端以及 LSP 导航。展开内容读取成功的记录结果,为失败或不支持的数据保留通用输入/输出,并保留 Inspect。日期包含查看者的时区,状态反映调用结果而非当前会话状态。浏览器适配器消费已记录的 producer 文本与 JSON;Host service 对象和 presenter 回调不会进入 Client。[紧凑工具详情](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md)记录了呈现取舍。 +记录结果的工具详情覆盖目标和定时任务工具、Cordis 检查、workflow 与 Ralph 报告、Session 事件/搜索/轨迹查询、Agent 与 teammate 控制、后台作业、持久终端以及 LSP 导航。展开内容读取成功的记录结果,为失败或不支持的数据保留通用输入/输出,并保留 Inspect。日期包含查看者的时区,状态反映调用结果而非当前会话状态。Session 轨迹保留后代的缩进。LSP 结果通过 Host 回调打开文件系统路径,其他 URI 则显示为文本。浏览器适配器消费已记录的 producer 文本与 JSON;Host service 对象和 presenter 回调不会进入 Client。[紧凑工具详情](../../../.agents/notes/implemented/architecture/2026-09-10-compact-tool-details.zh.md)记录了呈现取舍。 展开后的状态圆点和文字使用静态语义色。操作回执和任务输出的标题保持中性色,展开时省略标题中的状态。中断回执仅确认已发出中断请求。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css index 2348cf2f11..824bd18fde 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolDetails.module.css @@ -26,7 +26,7 @@ border-top: 0.5px solid var(--dsw-alias-border-l2); } -.root[data-inspect]:not([data-caption]) > .list > .item:first-child, +.root[data-inspect]:not([data-caption]) > .list > .item, .root[data-inspect]:not([data-caption]) .empty, .root[data-inspect] .caption { padding-inline-end: 88px; diff --git a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts index 19e90042bd..47d6edceb8 100644 --- a/packages/client/ui-tool/src/client/tool/models/control-details-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/control-details-model.ts @@ -87,9 +87,10 @@ function lspDetails(args: Record, text: string, t: DetailTransl if (match === null) return null const [, path, row, column] = match if (path === undefined || row === undefined || column === undefined) return null + const isUri = /^[a-z][a-z\d+.-]*:/iu.test(path) && !/^[a-z]:[\\/]/iu.test(path) items.push({ title: path, subtitle: t('detail.location', { line: row, column }), fields: [], - .../^[a-z][a-z\d+.-]*:\/\//iu.test(path) ? {} : { location: { path, line: Number(row) } }, + ...isUri ? {} : { location: { path, line: Number(row) } }, }) } const count = items.filter(item => item.title !== undefined).length diff --git a/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts b/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts index b5ecff3521..652ee09333 100644 --- a/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/inspection-details-model.ts @@ -144,7 +144,7 @@ function traceDetails(name: string, text: string, t: DetailTranslate, locale: st const label = section.startsWith('Ancestors (nearest first):') ? t('detail.trace.ancestors') : section.startsWith('Descendants:') ? t('detail.trace.descendants') : undefined if (label === undefined || firstBreak < 0) return null const body = section.slice(firstBreak + 1) - items.push({ title: label, fields: [], ...(body === '- none' || body === '- none (target is a root session)' ? { description: t('detail.none') } : { lines: body.split('\n').map(line => line.replace(/^\s*- /u, '')) }) }) + items.push({ title: label, fields: [], ...(body === '- none' || body === '- none (target is a root session)' ? { description: t('detail.none') } : { lines: body.split('\n').map(line => line.replace(/^(\s*)- /u, '$1')) }) }) } return detailList(items, match[2], t) } diff --git a/packages/client/ui-tool/tests/details-models.client.spec.ts b/packages/client/ui-tool/tests/details-models.client.spec.ts index d11547bf51..34c355b211 100644 --- a/packages/client/ui-tool/tests/details-models.client.spec.ts +++ b/packages/client/ui-tool/tests/details-models.client.spec.ts @@ -63,7 +63,10 @@ describe('control detail adapters', () => { { sessionId: 'pty-2' as never, name: 'done', type: 'shell', pid: 4, status: { kind: 'exited' as const, exitCode: 2, signal: null } }, ] expect(details('terminal_list', renderList(terminals, 1000))?.items).toHaveLength(2) - expect(details('lsp', 'src/a.ts:2:3\nhttps://example.test/a.ts:4:5', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'findReferences' })?.items).toHaveLength(2) + const locations = details('lsp', 'src/a.ts:2:3\nhttps://example.test/a.ts:4:5\nuntitled:Untitled-1:6:7\nC:\\src\\a.ts:8:9\nD:/lib/a.ts:10:11', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'findReferences' })?.items + expect(locations?.map(item => item.location)).toEqual([ + { path: 'src/a.ts', line: 2 }, undefined, undefined, { path: 'C:\\src\\a.ts', line: 8 }, { path: 'D:/lib/a.ts', line: 10 }, + ]) expect(details('lsp', 'No results.', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'findReferences' })?.empty).toBe('No results') expect(details('lsp', 'hover text', { file_path: 'src/main.ts', line: 2, character: 3, operation: 'hover' })?.items[0]?.markdown).toBe('hover text') }) @@ -122,21 +125,30 @@ describe('inspection detail adapters', () => { it('uses the session-query producer presentation for searches, reads, and traces', () => { const session = sessionRecord('s1', true, true) const hit = { ...session, bestMatch: { sessionId: 's1' as never, seq: 2, type: 'user/message', time: 2_000, surface: 'current', snippet: 'needle' } } - const titles = new Map([['s1' as never, { title: 'Session one' } as never]]) as never + const title = { text: 'Session one' } + const titles = new Map([ + ['s1' as never, title], ['s2' as never, { text: 'Child' }], ['s3' as never, { text: 'Grandchild' }], + ]) as Parameters[1] const sessionSearch = presentation.formatSessionSearch({ items: [hit as never], capped: true }, titles, new Set()) - expect(details('session_search', sessionSearch)?.items[0]?.description).toBe('needle') + expect(details('session_search', sessionSearch)?.items[0]).toMatchObject({ title: 'Session one', subtitle: 's1', description: 'needle' }) const eventSearch = presentation.formatEventSearch( - 's1' as never, { title: 'Session one' } as never, + 's1' as never, title, { items: [{ sessionId: 's1' as never, seq: 2 as never, type: 'tool/call', time: 2_000, surface: 'current', snippet: 'needle' }], capped: false }, ) expect(details('session_event_search', eventSearch)?.items[0]?.title).toBe('needle') const trace = presentation.formatSessionTrace( { target: session as never, ancestors: [], descendants: [], complete: true, root: session as never } as never, - [], false, [], titles, + [], false, [{ record: sessionRecord('s2', true, true) as never, descendants: [{ record: sessionRecord('s3', true, true) as never, descendants: [] }] }], titles, ) - expect(details('session_trace', trace)?.items).toHaveLength(3) + const traceItems = details('session_trace', trace)?.items + expect(traceItems).toHaveLength(3) + expect(traceItems?.[0]).toMatchObject({ title: 'Session one', subtitle: 's1' }) + expect(traceItems?.[2]?.lines).toEqual([ + 's2 — Child | 1970-01-01T00:00:01.000Z | live, persisted', + ' s3 — Grandchild | 1970-01-01T00:00:01.000Z | live, persisted', + ]) const eventTrace = presentation.formatEventTrace( - 's1' as never, { title: 'Session one' } as never, + 's1' as never, title, { session: { id: 's1' as never, createdAt: 1_000 } as never, target: { sessionId: 's1' as never, seq: 2 as never, type: 'tool/call', time: 2_000, surface: 'current' }, @@ -146,7 +158,7 @@ describe('inspection detail adapters', () => { expect(details('session_event_trace', eventTrace)?.items[0]?.fields).toContainEqual({ label: 'Target event', value: 'seq 2 | tool/call | current | 1970-01-01T00:00:02.000Z' }) const event = { seq: 2, time: 2_000, type: 'tool/call', data: { name: 'x' } } const eventRead = presentation.formatEventRead( - 's1' as never, { title: 'Session one' } as never, + 's1' as never, title, { session: {} as never, inheritedEventCount: 0 as never, target: event as never, events: [event as never], startSeq: 2 as never, endSeq: 2 as never, diff --git a/snapshots/web/tool-details/session.v3.jsonl b/snapshots/web/tool-details/session.v3.jsonl index 3c28a1083b..58825a781d 100644 --- a/snapshots/web/tool-details/session.v3.jsonl +++ b/snapshots/web/tool-details/session.v3.jsonl @@ -16,7 +16,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-5","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"action\":\"complete\"}"}} {"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-5","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":2,\"objective\":\"Ship compact tool cards\",\"phase\":\"complete\",\"roundsStarted\":0,\"maxGoalRounds\":8},\"activation\":\"disarmed\"}"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-5"},"id":"{{message:7}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-6","name":"schedule_list","arguments":"{}"}} -{"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-6","content":[{"type":"text","text":"[{\"id\":\"details-reminder\",\"prompt\":\"Review the build and open the demo\",\"kind\":\"every\",\"everySeconds\":3600,\"scheduledAt\":\"2099-09-10T09:00:00.000Z\",\"state\":\"scheduled\",\"deliveryMode\":\"session-local\"}]"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-6"},"id":"{{message:8}}"}}} +{"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-6","content":[{"type":"text","text":"[{\"id\":\"details-reminder\",\"prompt\":\"Review the build and open the demo\",\"kind\":\"every\",\"everySeconds\":3600,\"scheduledAt\":\"2099-09-10T09:00:00.000Z\",\"state\":\"scheduled\",\"deliveryMode\":\"session-local\"},{\"id\":\"details-reminder-2\",\"prompt\":\"Review the narrow reminder layout before sharing the updated demo with the team\",\"kind\":\"every\",\"everySeconds\":3600,\"scheduledAt\":\"2099-09-10T09:00:00.000Z\",\"state\":\"scheduled\",\"deliveryMode\":\"session-local\"},{\"id\":\"details-reminder-3\",\"prompt\":\"Check the recorded tool results and finish the remaining review comments\",\"kind\":\"every\",\"everySeconds\":3600,\"scheduledAt\":\"2099-09-10T09:00:00.000Z\",\"state\":\"scheduled\",\"deliveryMode\":\"session-local\"},{\"id\":\"details-reminder-4\",\"prompt\":\"Publish the reviewed demo and verify the deployment status\",\"kind\":\"every\",\"everySeconds\":3600,\"scheduledAt\":\"2099-09-10T09:00:00.000Z\",\"state\":\"scheduled\",\"deliveryMode\":\"session-local\"}]"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-6"},"id":"{{message:8}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-7","name":"schedule_delete","arguments":"{\"id\":\"details-reminder\"}"}} {"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-7","content":[{"type":"text","text":"{\"id\":\"details-reminder\",\"deleted\":true}"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-7"},"id":"{{message:9}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-8","name":"todo_write","arguments":"{\"todos\":[{\"content\":\"Keep the existing row and expansion controls\",\"status\":\"completed\"},{\"content\":\"Render compact fields and lists\",\"status\":\"completed\"},{\"content\":\"Publish the reviewed cards\",\"status\":\"pending\"}]}"}} @@ -41,7 +41,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-17","name":"session_search","arguments":"{\"query\":\"工具卡片\",\"limit\":5}"}} {"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-17","content":[{"type":"text","text":"Session search results (2):\n\n1. Session todo-diff-demo — 任务清单变化示例\n Created: 2026-09-10T08:00:00.000Z\n Parent: root\n Availability: persisted\n Best match: seq 1 | user/message | current | 2026-09-10T08:00:00.000Z\n Snippet: 重新设计任务清单工具卡片。\n\n2. Session tool-details-demo — Tool card details demo\n Created: 2026-09-10T07:00:00.000Z\n Parent: root\n Availability: persisted\n Best match: seq 1 | user/message | current | 2026-09-10T07:00:00.000Z\n Snippet: Show a goal, a to-do list, and reminders."}],"isError":false}],"source":{"kind":"tool","callId":"details-call-17"},"id":"{{message:19}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-18","name":"session_trace","arguments":"{\"session_id\":\"todo-diff-demo\"}"}} -{"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-18","content":[{"type":"text","text":"Session todo-diff-demo — 任务清单变化示例\nCreated: 2026-09-10T08:00:00.000Z\nAvailability: persisted\n\nAncestors (nearest first):\n- none (target is a root session)\n\nDescendants:\n- none"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-18"},"id":"{{message:20}}"}}} +{"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-18","content":[{"type":"text","text":"Session todo-diff-demo — 任务清单变化示例\nCreated: 2026-09-10T08:00:00.000Z\nAvailability: persisted\n\nAncestors (nearest first):\n- none (target is a root session)\n\nDescendants:\n- review-child — Layout review | 2026-09-10T08:05:00.000Z | persisted\n - review-grandchild — Narrow layout | 2026-09-10T08:10:00.000Z | persisted"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-18"},"id":"{{message:20}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-19","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek\"}"}} {"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-19","content":[{"type":"text","text":"deepseek/deepseek-chat — DeepSeek Chat\ndeepseek/deepseek-reasoner — DeepSeek Reasoner"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-19"},"id":"{{message:21}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-20","name":"subagent","arguments":"{\"prompt\":\"检查工具卡片布局,并列出三个最值得改进的地方。\"}"}} @@ -71,7 +71,7 @@ {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-32","name":"lsp","arguments":"{\"operation\":\"goToDefinition\",\"file_path\":\"src/app.ts\",\"line\":18,\"character\":5}"}} {"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-32","content":[{"type":"text","text":"src/tool-details.ts:24:1"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-32"},"id":"{{message:34}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-33","name":"lsp","arguments":"{\"operation\":\"findReferences\",\"file_path\":\"src/tool-details.ts\",\"line\":24,\"character\":1}"}} -{"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-33","content":[{"type":"text","text":"src/app.ts:18:5\nsrc/tool-row.tsx:42:9\nsrc/todo-row.tsx:67:12"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-33"},"id":"{{message:35}}"}}} +{"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-33","content":[{"type":"text","text":"src/app.ts:18:5\nsrc/tool-row.tsx:42:9\nsrc/todo-row.tsx:67:12\nuntitled:Untitled-1:6:7"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-33"},"id":"{{message:35}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-34","name":"lsp","arguments":"{\"operation\":\"goToImplementation\",\"file_path\":\"src/types.ts\",\"line\":12,\"character\":18}"}} {"type":"tool/result","surfaceOp":"append","data":{"turn":1,"step":1,"message":{"role":"user","content":[{"type":"tool-result","toolCallId":"details-call-34","content":[{"type":"text","text":"src/tool-details.ts:24:1\nsrc/fallback-details.ts:8:1"}],"isError":false}],"source":{"kind":"tool","callId":"details-call-34"},"id":"{{message:36}}"}}} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"details-call-35","name":"lsp","arguments":"{\"operation\":\"hover\",\"file_path\":\"src/tool-details.ts\",\"line\":24,\"character\":1}"}} diff --git a/snapshots/web/tool-details/ui.expected.md b/snapshots/web/tool-details/ui.expected.md index c65a66730e..e415ad9913 100644 --- a/snapshots/web/tool-details/ui.expected.md +++ b/snapshots/web/tool-details/ui.expected.md @@ -59,9 +59,9 @@ - term: Rounds - definition: 0 / 8 - button "Inspect" -- button "List reminders 1 reminders" [expanded]: +- button "List reminders 4 reminders" [expanded]: - img - - text: List reminders 1 reminders + - text: List reminders 4 reminders - list: - listitem: - text: Review the build and open the demo @@ -71,6 +71,30 @@ - definition: Every 1 h - term: Status - definition: Scheduled + - listitem: + - text: Review the narrow reminder layout before sharing the updated demo with the team + - term: Scheduled for + - definition: Sep 10, 2099, {{clock}} GMT+8 + - term: Repeat + - definition: Every 1 h + - term: Status + - definition: Scheduled + - listitem: + - text: Check the recorded tool results and finish the remaining review comments + - term: Scheduled for + - definition: Sep 10, 2099, {{clock}} GMT+8 + - term: Repeat + - definition: Every 1 h + - term: Status + - definition: Scheduled + - listitem: + - text: Publish the reviewed demo and verify the deployment status + - term: Scheduled for + - definition: Sep 10, 2099, {{clock}} GMT+8 + - term: Repeat + - definition: Every 1 h + - term: Status + - definition: Scheduled - button "Inspect" - button "Delete reminder details-reminder" [expanded]: - img @@ -394,7 +418,9 @@ - paragraph: None - listitem: - text: Descendant sessions - - paragraph: None + - list: + - listitem: review-child — Layout review | {{timestamp}} | persisted + - listitem: review-grandchild — Narrow layout | {{timestamp}} | persisted - button "Inspect" - button "List models deepseek" [expanded]: - img @@ -571,9 +597,9 @@ - button "src/tool-details.ts" - text: Line 24, column 1 - button "Inspect" -- button "Find references src/tool-details.ts · 3 locations" [expanded]: +- button "Find references src/tool-details.ts · 4 locations" [expanded]: - img - - text: Find references src/tool-details.ts · 3 locations + - text: Find references src/tool-details.ts · 4 locations - text: Recorded result - list: - listitem: @@ -585,6 +611,7 @@ - listitem: - button "src/todo-row.tsx" - text: Line 67, column 12 + - listitem: untitled:Untitled-1 Line 6, column 7 - button "Inspect" - button "Find implementation src/types.ts · 2 locations" [expanded]: - img From 9119f145224d8239e9db564e28d55ff632fbaebe Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 12:34:49 +0800 Subject: [PATCH 032/225] fix(web): reset raw attachment disclosures on record selection Refs #4356 --- .../src/client/TrajectoryTable.tsx | 1 + .../ui-trajectory/tests/table.client.spec.tsx | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 53fac3a647..991c5bbb82 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1531,6 +1531,7 @@ function MarkdownRecordContent({ if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { return ( { }, ) + it('resets raw attachment disclosures on record selection but keeps them through history updates', () => { + const content = [ + { + type: 'image' as const, + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + width: 40, + height: 80, + bytes: 68, + name: 'shared.png', + }, + }, + { + type: 'file' as const, + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + name: 'shared.txt', + bytes: 1, + }, + }, + ] + const nodes = ['First message', 'Second message'].map((text, index) => ({ + kind: 'user' as const, + seq: index + 2, + time: (index + 2) * 1000, + source: { kind: 'user' as const }, + content: [{ type: 'text' as const, text }, ...content], + })) + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }, t) + const view = render() + fireEvent.click(screen.getByRole('row', { name: /First message/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) + const disclosures = () => [...screen.getByRole('tabpanel').querySelectorAll('details')] + expect(disclosures()).toHaveLength(2) + expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) + for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) + expect(disclosures().every(disclosure => disclosure.open)).toBe(true) + + const older = { ...nodes[0]!, seq: 1, time: 1000, content: [{ type: 'text' as const, text: 'Older message' }] } + const expandedTurns = deriveTrajectoryLayout({ nodes: [older, ...nodes], partial: null, runningCalls: [] }, t) + view.rerender() + expect(within(screen.getByRole('tabpanel')).getByText('First message', { exact: true })).toBeTruthy() + expect(disclosures().every(disclosure => disclosure.open)).toBe(true) + + fireEvent.click(screen.getByRole('row', { name: /Second message/ })) + expect(screen.getByRole('tab', { name: 'Raw' }).getAttribute('aria-selected')).toBe('true') + expect(within(screen.getByRole('tabpanel')).getByText('Second message', { exact: true })).toBeTruthy() + expect(disclosures()).toHaveLength(2) + expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) + for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) + fireEvent.click(screen.getByRole('row', { name: /First message/ })) + expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) + }) + it('renders a tool-result image through the shared gallery in the Result tab', () => { const attachment = { attachmentId: `sha256:${'b'.repeat(64)}`, From f176327afc2f97a56cd3b1d9647b4502c243d3f7 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 12:35:14 +0800 Subject: [PATCH 033/225] docs(web): record implemented trajectory attachment presentation Refs #4356 --- ...ajectory-attachment-presentation.i18n.yaml | 6 ++ ...9-15-trajectory-attachment-presentation.md | 55 ++++++++++++ ...5-trajectory-attachment-presentation.zh.md | 55 ++++++++++++ .../2026-08-26-generic-file-upload.i18n.yaml | 4 +- .../feature/2026-08-26-generic-file-upload.md | 2 +- .../2026-08-26-generic-file-upload.zh.md | 2 +- ...ajectory-attachment-presentation.i18n.yaml | 6 -- ...9-15-trajectory-attachment-presentation.md | 89 ------------------- ...5-trajectory-attachment-presentation.zh.md | 89 ------------------- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 + packages/client/ui-trajectory/README.zh.md | 2 + 12 files changed, 126 insertions(+), 190 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml delete mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md delete mode 100644 .agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml new file mode 100644 index 0000000000..0254ff4b08 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md +2026-09-15-trajectory-attachment-presentation.md: 4a1528a230c0ded38d26d0859a181ebaa208180d +2026-09-15-trajectory-attachment-presentation.zh.md: e6accddb292613d9688b66847b6d6834ad6636c6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md new file mode 100644 index 0000000000..4a1528a230 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -0,0 +1,55 @@ +# Agent Note: Consistent Trajectory attachment presentation + +Status: implemented + +English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) + +## Problem + +A user message can contain text, images, and ordinary files. Trajectory omitted image counts beside text, left ordinary files out of Preview, and mixed file JSON with rendered images in Raw. Readers could not identify the complete attachment set consistently across the ledger and inspector (Issue #4356). + +## Decision + +Trajectory presents every attachment occurrence while keeping visual inspection separate from inspection of recorded fields. + +| Location | Behavior | +|---|---| +| Ledger | Separate nonzero image and ordinary-file counts accompany the text excerpt, including messages without text. Each record stays on one compact line. | +| Summary and Preview | Message text precedes one ordered attachment list. Both tabs use the same renderer; Summary retains its compact spacing and scroll container. | +| Attachment list | Equal-width compact rows share spacing and corners. A file-type icon or contained image thumbnail accompanies the recorded name and available metadata. Repeated references retain their occurrence order. | +| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access, loading feedback, and retry after a failed read. | +| Raw | Image and file blocks use initially collapsed metadata disclosures with complete recorded fields. Original content-block order and unrendered text remain intact; generated display labels do not enter raw fields. | + +Recorded filenames take precedence over localized numbered image labels. Metadata comes from recorded fields or the filename extension; zero-byte sizes remain visible. Visually truncated names retain their complete accessible text and tooltip. Messages without text still expose all attachments inside the inspector's scroll area. + +### Ownership + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns counts, ordered attachment projection, and inspector composition. Its view data carries typed image and file references separately from the serialized content used by Raw, so display code does not recover file metadata by parsing JSON. + +[ui-attachment](../../../../packages/client/ui-attachment/README.md) owns image loading and the viewer through the existing image slot. [ui-conversation](../../../../packages/client/ui-conversation/README.md) owns the per-session image cache: Chat and Trajectory share one authorized read and browser URL per attachment. Thumbnail sizing and optional display labels affect presentation only; loading still receives the original reference. Chat and tool-result gallery sizing retain their existing behavior. + +This decision uses existing durable attachment references without changing upload, storage, model requests, or the Session format. Ordinary files expose metadata without a content viewer or download action. Product labels use typed locale dictionaries, and styles use the existing semantic tokens. + +The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) owns upload, storage, admission, and ordered message attachments; this record owns Trajectory presentation. The [unreadable attachment quarantine proposal](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) concerns model-request recovery independently of thumbnail loading feedback. + +## Alternatives considered + +**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments. + +**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list accommodates both panel widths. + +**Keep file JSON and image previews together in Raw.** This assigns different meanings to the same tab for two attachment kinds. Metadata disclosures let readers inspect recorded fields while Preview owns visual image presentation. + +## Consequences + +The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry. Selecting another record resets Raw attachment disclosures; updates to the selected record, including loading older history, preserve their state. + +Compact thumbnails require opening the viewer to read detailed diagrams. Missing metadata stays absent, and image-read failures retain a retry control without hiding ordinary files. Changes to the shared image slot also affect Chat and tool results, so their gallery behavior and cache reuse remain regression requirements. + +## Verification + +[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, complete Raw fields, and disclosure state across record selection and history updates. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer. + +The recorded [mixed-upload browser scenario](../../../../apps/web/tests/file-upload-round.e2e.ts) compares Summary and Preview, verifies keyboard activation and focus restoration, checks collapsed and expanded Raw content, and repeats inspection after reload. Its [Trajectory expectation](../../../../snapshots/web/file-upload-round/trajectory.expected.md) accompanies the unchanged Session fixture. The [assembled image-cache test](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts) also checks reuse between Chat and Trajectory. + +Layout acceptance covers narrow and wide panels, both themes and locales, complete thumbnail containment, and reachable final attachments. The [GIF workflow](../../../skills/record-browser-gif/SKILL.md) requires review evidence from a clean committed tree and one real-server, real-model session showing mixed submission, ledger counts, Summary, Preview image activation, and expanded Raw metadata. Issue #4356 and its linked PR track executed checks and demonstration evidence. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md new file mode 100644 index 0000000000..e6accddb29 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -0,0 +1,55 @@ +# Agent Note: 统一 Trajectory 附件展示 + +Status: implemented + +[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 + +## 问题 + +用户消息可以包含文本、图片和普通文件。Trajectory 曾在文本旁省略图片数量,在预览中遗漏普通文件,并在原始内容中混排文件 JSON 与渲染后的图片。读者无法在列表和详情面板中一致地确认完整附件集合(Issue #4356)。 + +## 决定 + +Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已记录字段检查。 + +| 位置 | 行为 | +|---|---| +| 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 | +| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 | +| 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 | +| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问、加载反馈和读取失败后的重试。 | +| 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 | + +已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。 + +### 职责 + +[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。其视图数据将带类型的图片与文件引用和原始内容使用的序列化文本分别保存,展示代码因此无需解析 JSON 来恢复文件元数据。 + +[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)通过现有图片 slot 负责图片加载与查看器。[ui-conversation](../../../../packages/client/ui-conversation/README.zh.md)拥有会话级图片缓存:Chat 与 Trajectory 对每个附件共享一次授权读取和一个浏览器 URL。缩略图尺寸和可选显示标签只影响展示,加载仍接收原始引用。Chat 和工具结果图库保留现有的尺寸行为。 + +本决定使用现有持久附件引用,不改变上传、存储、模型请求或会话格式。普通文件展示元数据,不提供内容查看器或下载操作。产品标签使用带类型的本地化字典,样式使用现有语义 token。 + +[通用文件上传决定](../feature/2026-08-26-generic-file-upload.zh.md)负责上传、存储、准入和有序消息附件;本记录负责 Trajectory 展示。[不可读附件隔离提案](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 + +## 考虑过的替代方案 + +**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 + +**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。单列等宽列表适用于两种面板宽度。 + +**在原始内容中混排文件 JSON 和图片预览。** 这让同一个标签页对两种附件具有不同含义。元数据折叠区用于检查已记录字段,预览负责图片的视觉展示。 + +## 影响 + +共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。选择另一条记录时,原始内容中的附件折叠区恢复收起状态;当前记录更新时,包括加载更早历史时,折叠状态保持不变。 + +紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 slot 的修改也会影响 Chat 和工具结果,因此其图库行为和缓存复用仍需回归验证。 + +## 验证 + +[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表、完整的原始字段,以及记录切换和历史更新时的折叠状态。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。 + +已录制的[混合上传浏览器场景](../../../../apps/web/tests/file-upload-round.e2e.ts)比较概述与预览,验证键盘激活和焦点恢复,检查原始内容收起与展开状态,并在刷新后重复检查。其 [Trajectory 预期输出](../../../../snapshots/web/file-upload-round/trajectory.expected.md)配合未改动的会话 fixture(测试前置数据)使用。[组装应用图片缓存测试](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)也检查 Chat 与 Trajectory 之间的复用。 + +布局验收覆盖窄版与宽版面板、两种主题和语言、缩略图完整缩放及末尾附件可达性。[GIF 工作流](../../../skills/record-browser-gif/SKILL.md)要求评审证据基于干净的已提交代码树,在同一个真实服务、真实模型会话中展示混合提交、列表计数、概述、预览中的图片激活和展开的原始元数据。Issue #4356 及其关联 PR(Pull Request)记录已执行的检查和演示证据。 diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml index 93cf5d2d20..d5f37e0569 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-generic-file-upload.md -2026-08-26-generic-file-upload.md: bda96caa36f27a2270971127b5e3635a7fec9fcd -2026-08-26-generic-file-upload.zh.md: 2ddfd48f1ea04ba6cfbff10224444de8d37d7a8a +2026-08-26-generic-file-upload.md: ddaf92a396ade59d65a81300531be9bce2aeeaba +2026-08-26-generic-file-upload.zh.md: 27a73536ec3632f40ee49a59d932406e77b5591d diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md index bda96caa36..ddaf92a396 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.md @@ -51,4 +51,4 @@ Unit and integration coverage pins verbatim and streamed storage, name sanitizat ## Related -The [Trajectory attachment presentation proposal](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md) covers ledger counts and inspector attachment layouts. +The [Trajectory attachment presentation decision](../bug-fix/2026-09-15-trajectory-attachment-presentation.md) owns ledger counts and inspector attachment layouts. diff --git a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md index 2ddfd48f1e..27a73536ec 100644 --- a/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md +++ b/.agents/notes/implemented/feature/2026-08-26-generic-file-upload.zh.md @@ -51,4 +51,4 @@ Status: implemented ## 相关记录 -[Trajectory 附件展示提案](../../proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)涵盖列表计数与详情面板附件布局。 +[Trajectory 附件展示决定](../bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md)负责列表计数与详情面板附件布局。 diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml deleted file mode 100644 index f7fafc2b16..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md -2026-09-15-trajectory-attachment-presentation.md: 23ce4145f0a90e0e03a90ca4c335bb2cb3598785 -2026-09-15-trajectory-attachment-presentation.zh.md: cbd507235e28a523360bd47a105acbb59963418f diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md deleted file mode 100644 index 23ce4145f0..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.md +++ /dev/null @@ -1,89 +0,0 @@ -# Agent Note: Consistent Trajectory attachment presentation - -Status: proposed - -English | [中文](2026-09-15-trajectory-attachment-presentation.zh.md) - -## Problem - -A user message can contain text, images, and ordinary files, but Trajectory exposes different subsets of that message across its ledger and inspector. The [ledger projection](../../../../packages/client/ui-trajectory/src/client/layout.ts) omits the image count when text is present. The [inspector renderer](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx) displays images without ordinary file cards in Preview and mixes file JSON with rendered images in Raw. Readers cannot reliably identify the complete attachment set without switching views. - -The Summary tab embeds `MarkdownRecordContent` with compact presentation, while Preview uses the same component without that option. Attachment changes must preserve that shared renderer so both entry points expose the same content and actions. - -## Proposal - -Deliver one Trajectory presentation change covering the ledger, Summary, Preview, and Raw. The proposed behavior is not implemented by this document; Issue #4356 and its linked PR track execution status and completed evidence. - -### Display behavior - -| Location | Proposed behavior | -|---|---| -| Ledger | Show separate nonzero image and ordinary-file counts beside the text excerpt, including messages that contain text. Keep each record on one compact line. | -| Summary and Preview | Render the message text followed by one ordered attachment list. Use the same attachment renderer in both tabs; Summary may retain its compact spacing and scroll container. | -| Attachment list | Use equal-width compact rows with consistent height, spacing, and corners. Place a file-type icon or contained image thumbnail on the left and the recorded name and available metadata on the right. Preserve attachment occurrence order, including repeated references. | -| Image interaction | Fit the whole image inside its thumbnail without cropping. Open the existing large-image viewer on activation; preserve keyboard access, loading feedback, and retry after a failed read. | -| Raw | Preserve original content-block order and boundaries. Give image and file blocks consistent, initially collapsed metadata disclosures, with complete recorded fields available on expansion. Keep text blocks as unrendered text; generated display labels do not become raw fields. | - -Use the recorded filename when available and a localized numbered image label otherwise. Show type and size only when supported by the recorded data; preserve zero-byte sizes. Long names may truncate visually, but the full name must remain available through accessible text and a tooltip. A message without text still exposes every attachment. Multiple attachments remain reachable inside the inspector's existing scroll area. - -### Implementation ownership - -[ui-trajectory](../../../../packages/client/ui-trajectory/README.md) owns the counts, ordered attachment projection, and inspector composition. Carry typed image and file references through its view data rather than recovering file metadata by parsing JSON prepared for display. Keep complete recorded attachment fields available to Raw. - -[ui-attachment](../../../../packages/client/ui-attachment/README.md) retains image loading and viewer behavior, using the Conversation-owned per-session cache. Compose cross-package UI through slots and reuse suitable static controls from [ui-primitives](../../../../packages/client/ui-primitives/README.md), following the [client rules](../../../../packages/client/AGENTS.md). Update all consumers of any changed shared props. Keep the containment treatment specific to the requested attachment presentation so existing Chat and tool-image sizing remains covered by regression checks. - -This scope uses existing durable attachment references. It introduces no upload, storage, model-request, or Session-format change, and no ordinary-file content viewer or download action. Styles follow [Web styling](../../../../docs/web-styling.md); display text, accessibility labels, and tooltips use typed locale dictionaries. - -### Existing decisions - -The [generic file upload decision](../../implemented/feature/2026-08-26-generic-file-upload.md) remains authoritative for upload, storage, admission, and ordered message attachments. This proposal revises only its Trajectory presentation and does not fully supersede that record. The [unreadable attachment quarantine proposal](2026-08-20-attachment-read-quarantine.md) concerns model-request recovery and remains independent of thumbnail loading feedback. - -## Implementation plan - -The steps below define the delivery sequence and completion evidence. They are not a record of commands already run. Use the current [development guide](../../../../docs/development.md), [testing policy](../../../../docs/testing.md), and linked workflows when executing them. - -| Step | Work | Completion evidence | -|---|---|---| -| 1. Establish the baseline | Verify the internal repository, live base, clean worktree, dependencies, and applicable rules; use an isolated feature branch. | The repository, branch, and base are identified and unrelated changes are excluded. | -| 2. Associate an Issue | Find the existing task or create its Issue with reproduction, scope, native Type, applicable Project fields, and the acceptance criteria below. | A real Issue owns progress and provides the PR reference. | -| 3. Map consumers | Trace Trajectory attachment data, Summary/Preview composition, Raw serialization, image slots, and any shared props. | Every affected consumer and test owner is identified before interfaces change. | -| 4. Implement presentation | Preserve typed attachment data, fix counts, introduce the shared attachment list, and align Raw disclosures in that order. | All four locations expose the specified information without changing durable message data. | -| 5. Add behavior evidence | Extend projection and component tests, then the recorded mixed-upload browser scenario; cover both inspector entry points and image interactions. | Assertions reject the missing-file, missing-count, ordering, and layout regressions. | -| 6. Update documentation | Update affected README pairs, JSDoc, and the owning decision records with the implementation; record bilingual consistency. | Documentation describes the implemented behavior, and this proposal is rewritten for its final lifecycle when implementation is complete. | -| 7. Validate the outgoing diff | Apply the [pre-push check workflow](../../../skills/dsh-pre-push-checks/SKILL.md) and the client check ladder; inspect the complete diff and changed expected outputs. | Relevant local checks pass, with commands and limitations recorded accurately. | -| 8. Commit and demonstrate | Review hook changes, commit normally, build the clean committed tree, and record one isolated real-server, real-model flow under the [GIF workflow](../../../skills/record-browser-gif/SKILL.md). Push normally and verify the remote head. | The reviewed GIF names the demonstrated commit and serving conditions, and the remote branch contains that commit. | -| 9. Publish and review | Fill the [PR template](../../../../.github/pull_request_template.md), link the Issue, select live kind/area labels, and attach evidence. Check the PR preview and CI; address review on the same branch. | The current head has reviewable evidence; fixes update affected checks and the GIF when its demonstrated commit changes. | -| 10. Merge and follow up | After review, satisfy live approval and merge rules, then follow mainline checks and Issue state. | The authorized merge is verified and any remaining work has an owner; release publication follows its separate workflow. | - -### Verification coverage - -| Layer | Planned coverage | -|---|---| -| Projection | Text with one image and one file; images only; files only; attachment-only mixed messages; empty text; multiple and repeated attachment occurrences. Counts and raw fields match the input. | -| Components | Summary and Preview show the same attachment names, order, metadata, and activation behavior. Raw preserves block order and complete fields. Cover absent image names, zero-byte files, long filenames, loading, failure, and retry. | -| Assembled application | Extend [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) and its [recorded scenario](../../../../snapshots/web/file-upload-round/snapshot.yml) to inspect Summary, Preview, and Raw after the mixed upload and after reopening history. Keep the [shared image-cache check](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts). | -| Browser layout and accessibility | Verify narrow and wide inspector widths, light and dark themes, both locales, complete thumbnail containment, reachable final attachments, and keyboard activation and viewer dismissal. Keep geometry expectations beside their test owner under the [snapshot placement rules](../../../../snapshots/AGENTS.md). | -| Local checks | Run focused tests during implementation, then the required GUI and browser replay checks from the client rules, locale verification, documentation checks, and lint. Normal pre-push runs typecheck; CI owns exhaustive coverage and platform checks. | -| Demonstration | Record mixed submission, the ledger counts, Summary, Preview with image activation, and expanded Raw metadata from one real model session. Keep media outside the feature branch and attach it according to the GIF workflow. | - -## Alternatives considered - -**Keep large inline images as the default inspector presentation.** This makes one image immediately readable, but a tall image displaces attachment names and message text in the narrow inspector. Compact rows with explicit image activation provide a consistent overview of mixed attachments. - -**Use two-column attachment cards.** A grid can make thumbnails more prominent on wide panels, but it leaves less room for long filenames and requires a different narrow-panel arrangement. A single equal-width list is the proposed default. - -**Keep file JSON and image previews together in Raw.** This preserves the existing rendering, but assigns different meanings to the same tab for two attachment kinds. Consistent metadata disclosures let readers inspect recorded fields while Preview owns the visual image presentation. - -## Acceptance criteria - -- A message containing text, one image, and one ordinary file shows both counts in the ledger and both attachments in Summary and Preview. -- Summary and Preview share one attachment renderer; each provides the same ordered attachments and image actions despite their different spacing and containers. -- Attachment-only messages, repeated references, unnamed images, zero-byte files, and long filenames remain identifiable without invented metadata. -- Images fit completely inside compact thumbnails. Keyboard and pointer users can open and dismiss the existing image viewer; a failed image load remains visible and retryable. -- Raw preserves every original content block and its order. Image and file disclosures expose all recorded attachment fields without replacing them with generated labels or downloaded file contents. -- Narrow panels, both themes, and both locales remain usable. Existing Chat/tool-image behavior and per-session image cache reuse retain regression coverage. -- The implementation includes corresponding tests, reviewed keyless browser expectations, paired documentation, and a real-model GIF tied to the demonstrated PR head. - -## Risks - -Adding file references to view data can accidentally route them through image-only consumers or lose raw fields. Trace every affected discriminant and verify mixed and repeated occurrences. Compact thumbnails reduce immediate readability of diagrams, so full-image activation must remain discoverable. Shared renderer changes can affect Chat and tool results, while Summary's scroll container can hide final rows; both need assembled-browser checks. Missing recorded metadata must remain visibly absent, and image-read failures must not hide unrelated files. Unavailable model credentials block the real-model demonstration and must be reported rather than replaced with fixture footage. diff --git a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md deleted file mode 100644 index cbd507235e..0000000000 --- a/.agents/notes/proposed/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md +++ /dev/null @@ -1,89 +0,0 @@ -# Agent Note: 统一 Trajectory 附件展示 - -Status: proposed - -[English](2026-09-15-trajectory-attachment-presentation.md) | 中文 - -## 问题 - -用户消息可以包含文本、图片和普通文件,但 Trajectory 列表与详情面板展示的消息内容并不完整一致。[列表投影](../../../../packages/client/ui-trajectory/src/client/layout.ts)在消息含有文本时省略图片数量。[详情渲染器](../../../../packages/client/ui-trajectory/src/client/TrajectoryTable.tsx)在预览中展示图片而不展示普通文件卡片,在原始内容中又把文件 JSON 与渲染后的图片混排。读者需要切换视图才能确认完整的附件集合。 - -概述标签页通过紧凑展示选项嵌入 `MarkdownRecordContent`,预览标签页使用同一组件,但不启用该选项。附件调整必须保留这一共享渲染器,使两个入口展示相同的内容和操作。 - -## 提案 - -交付一项覆盖列表、概述、预览和原始内容的 Trajectory 展示调整。本文不实现所提议的行为;执行状态和已完成的验证证据由 Issue #4356 及其关联 PR(Pull Request)记录。 - -### 展示行为 - -| 位置 | 提议的行为 | -|---|---| -| 列表 | 在文本摘要旁分别显示非零的图片数量与普通文件数量,包含有文本的消息。每条记录保持为紧凑单行。 | -| 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的附件渲染器;概述可以保留紧凑间距和滚动容器。 | -| 附件列表 | 使用等宽的紧凑行,统一行高、间距和圆角。左侧放文件类型图标或完整缩放的图片缩略图,右侧放已记录的名称与可用元数据。保留每次附件出现的顺序,包括重复引用。 | -| 图片交互 | 图片完整缩放到缩略图内,不裁剪。激活时打开现有大图查看器;保留键盘访问、加载反馈和读取失败后的重试。 | -| 原始内容 | 保留原始内容块顺序与边界。图片块和文件块使用一致、默认收起的元数据折叠区,展开后显示完整记录字段。文本块保持未渲染文本;生成的显示标签不进入原始字段。 | - -有已记录文件名时使用文件名,否则为图片使用本地化的编号标签。类型与大小只在已记录数据支持时显示;保留零字节大小。长名称可以在视觉上截断,但完整名称必须能通过无障碍文本和工具提示获取。没有正文的消息仍须展示每个附件。多个附件在详情面板现有滚动区域内保持可达。 - -### 实现职责 - -[ui-trajectory](../../../../packages/client/ui-trajectory/README.zh.md)负责计数、有序附件投影和详情面板组合。通过其视图数据传递带类型的图片与文件引用,不从为展示准备的 JSON 中反向解析文件元数据。为原始内容保留完整的已记录附件字段。 - -[ui-attachment](../../../../packages/client/ui-attachment/README.zh.md)继续负责图片加载和查看器行为,使用 Conversation 拥有的会话级缓存。通过 slot 组合跨包 UI,并复用 [ui-primitives](../../../../packages/client/ui-primitives/README.zh.md) 中适用的静态控件,遵循[客户端规则](../../../../packages/client/AGENTS.md)。共享属性发生变化时,更新全部消费方。完整缩放处理限定于本次附件展示,并通过回归检查覆盖现有 Chat 和工具图片尺寸行为。 - -本次范围使用现有持久附件引用,不改变上传、存储、模型请求或会话格式,也不增加普通文件内容查看器或下载操作。样式遵循 [Web 样式规范](../../../../docs/web-styling.zh.md);显示文字、无障碍标签和工具提示使用带类型的本地化字典。 - -### 现有决定 - -[通用文件上传决定](../../implemented/feature/2026-08-26-generic-file-upload.zh.md)继续负责上传、存储、准入和有序消息附件。本提案只调整其中的 Trajectory 展示,并不完整取代该记录。[不可读附件隔离提案](2026-08-20-attachment-read-quarantine.zh.md)处理模型请求恢复,与缩略图加载反馈相互独立。 - -## 实施计划 - -以下步骤定义交付顺序与完成证据,不代表命令已经执行。执行时使用当前[开发指南](../../../../docs/development.zh.md)、[测试策略](../../../../docs/testing.zh.md)和所链接的工作流。 - -| 步骤 | 工作 | 完成证据 | -|---|---|---| -| 1. 确定基线 | 核对内部仓库、实时基线、干净的 worktree、依赖和适用规则;使用隔离的功能分支。 | 仓库、分支和基线明确,未混入无关修改。 | -| 2. 关联 Issue | 查找现有任务或创建 Issue,写明复现、范围、原生 Type、适用的 Project 字段和下列验收标准。 | 真实 Issue 负责进度,并作为 PR 的引用。 | -| 3. 梳理消费方 | 跟踪 Trajectory 附件数据、概述与预览组合、原始内容序列化、图片 slot 和共享属性。 | 接口修改前,已明确全部受影响消费方与测试归属。 | -| 4. 实现展示 | 依次保留带类型的附件数据、修正计数、引入共享附件列表、统一原始内容折叠区。 | 四处位置均展示规定信息,持久消息数据不变。 | -| 5. 补行为证据 | 扩展投影与组件测试,再扩展已录制的混合上传浏览器场景;覆盖两个详情入口和图片交互。 | 断言能够检出文件遗漏、计数遗漏、顺序及布局回归。 | -| 6. 更新文档 | 随实现更新受影响的 README 双语文件、JSDoc 和所属决定记录,并记录双语一致性。 | 文档描述已实现行为,完整实现后按最终生命周期改写本提案。 | -| 7. 验证待提交差异 | 应用[推送前检查工作流](../../../skills/dsh-pre-push-checks/SKILL.md)和客户端检查层级;检查完整差异与变化的预期输出。 | 相关本地检查通过,准确记录命令与限制。 | -| 8. 提交与演示 | 检查 hook 修改,正常提交,构建干净的已提交代码树,并按 [GIF 工作流](../../../skills/record-browser-gif/SKILL.md)录制一次隔离的真实服务、真实模型流程。正常推送并核对远端提交。 | 已检查的 GIF 标明演示提交和服务条件,远端分支包含该提交。 | -| 9. 发布与评审 | 填写 [PR 模板](../../../../.github/pull_request_template.md),关联 Issue,选择当前 kind/area 标签并附证据。检查 PR 预览和 CI;在同一分支处理评审。 | 当前提交具有可评审证据;修复后更新受影响检查,演示提交变化时更新 GIF。 | -| 10. 合并与跟进 | 评审后满足实时审批与合并规则,再跟进主线检查和 Issue 状态。 | 已核实获授权的合并,剩余工作有明确归属;发行走独立流程。 | - -### 验证覆盖 - -| 层级 | 计划覆盖 | -|---|---| -| 投影 | 文本加一张图片与一个文件、只有图片、只有文件、无正文的混合附件、空文本、多个附件与重复附件。计数和原始字段与输入一致。 | -| 组件 | 概述与预览显示相同的附件名称、顺序、元数据与激活行为。原始内容保留块顺序和完整字段。覆盖图片无名称、零字节文件、长文件名、加载、失败与重试。 | -| 组装应用 | 扩展 [file-upload-round](../../../../apps/web/tests/file-upload-round.e2e.ts) 及其[录制场景](../../../../snapshots/web/file-upload-round/snapshot.yml),在混合上传后和重新打开历史后检查概述、预览与原始内容。保留[共享图片缓存检查](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)。 | -| 浏览器布局与无障碍 | 验证窄版与宽版详情面板、浅色与深色主题、两种语言、缩略图完整缩放、末尾附件可达,以及键盘激活和关闭查看器。按[快照存放规则](../../../../snapshots/AGENTS.md),把几何预期留在所属测试旁。 | -| 本地检查 | 实现期间运行针对性测试,然后执行客户端规则要求的 GUI 与浏览器回放检查、本地化验证、文档检查和 lint。正常 pre-push 执行类型检查;CI 负责全量覆盖率与平台检查。 | -| 演示 | 在同一个真实模型会话中录制混合提交、列表计数、概述、预览中的图片激活,以及展开的原始元数据。媒体保存在功能分支之外,按 GIF 工作流附到 PR。 | - -## 考虑过的替代方案 - -**默认在详情面板中内联展示大图。** 这样可以立即阅读单张图片,但高图片会在窄面板中挤走附件名称和消息正文。紧凑行加显式图片激活入口能一致地概览混合附件。 - -**使用双列附件卡片。** 网格能在宽面板上突出缩略图,但会压缩长文件名空间,并要求窄面板采用不同排布。提案默认使用单列等宽列表。 - -**在原始内容中继续混排文件 JSON 和图片预览。** 这保留了现有渲染,却让同一个标签页对两种附件具有不同含义。一致的元数据折叠区用于检查已记录字段,图片视觉展示由预览负责。 - -## 验收标准 - -- 同时包含文本、一张图片和一个普通文件的消息,在列表显示两种数量,在概述和预览显示两个附件。 -- 概述与预览共用一个附件渲染器;即使间距和容器不同,也提供相同的有序附件和图片操作。 -- 纯附件消息、重复引用、无名称图片、零字节文件和长文件名保持可识别,不编造元数据。 -- 图片完整缩放到紧凑缩略图内。键盘和指针用户均可打开并关闭现有图片查看器;图片加载失败时仍有可见反馈并可重试。 -- 原始内容保留每个原始内容块及其顺序。图片与文件折叠区展示全部已记录附件字段,不以生成标签或下载的文件内容替代。 -- 窄面板、两种主题和两种语言下均可使用。现有 Chat/工具图片行为和会话级图片缓存复用保留回归覆盖。 -- 实现包含相应测试、经审阅的无密钥浏览器预期输出、双语文档,以及与演示 PR 当前提交对应的真实模型 GIF。 - -## 风险 - -在视图数据中加入文件引用,可能误将文件传给只接受图片的消费方,或丢失原始字段。需要跟踪每个受影响的类型标记,并验证混合与重复附件。紧凑缩略图降低了图表的直接可读性,因此大图激活入口必须容易发现。共享渲染器改动可能影响 Chat 和工具结果,概述滚动容器也可能遮挡末尾行;两者都需要组装浏览器检查。缺失的记录元数据必须保持明确缺失,图片读取失败不能隐藏其他文件。模型凭证不可用时,真实模型演示会受阻,必须如实说明,不能用 fixture(测试前置数据)录屏替代。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 1834635f5a..a41ed11a2c 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -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-trajectory/README.md -README.md: f11bfa3188ce616e392158f9f2cf33920edea8ba -README.zh.md: 53672cc8e4c8891ff6ea9b985b4efafc0368e1bf +README.md: 1ddfe6198d37b8e61b44507ab02cbe5a08cafc40 +README.zh.md: 68ea4d7fa8711296407b3881300c7343514a62cc diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index f11bfa3188..1ddfe6198d 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -35,6 +35,8 @@ Selection, timeline navigation, folding, and search cover the React-visible wind Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields. +Selecting another record resets its Raw attachment disclosures to collapsed. Updates to the selected record, including loading older history, preserve its disclosure state. + ### The timing overview Historical replies retain TTFT, generation duration, and throughput when their recorded streams contain token timestamps. TTFT measures from the Step start to its first token, including output from an earlier retry attempt; an unloaded Step start or a stream without tokens leaves the corresponding metric unavailable. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 53672cc8e4..68ea4d7fa8 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -35,6 +35,8 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 +选择另一条记录时,其原始内容中的附件折叠区恢复收起状态。当前记录更新时,包括加载更早历史时,折叠状态保持不变。 + ### 时间概览 当记录的流包含 token 时间戳时,历史回复会保留 TTFT、生成耗时与吞吐量。TTFT 从步骤开始计量到首个 token,包括更早重试尝试中的输出;步骤起点尚未加载或流中没有 token 时,对应指标保持不可用。 From 7c55730a97e55711d5a5ecc5eb64faa0c2e7b801 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 17 Sep 2026 13:36:02 +0800 Subject: [PATCH 034/225] chore: move file-reference evaluation to personal repository --- ...9-15-markdown-file-preview-links.i18n.yaml | 4 +- .../2026-09-15-markdown-file-preview-links.md | 20 +- ...26-09-15-markdown-file-preview-links.zh.md | 20 +- .github/workflows/ci.yml | 3 - .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 1 - packages/client/ui-deliverables/README.zh.md | 1 - .../evals/file-references/README.i18n.yaml | 6 - .../evals/file-references/README.md | 58 --- .../evals/file-references/README.zh.md | 58 --- .../evals/file-references/cases/diagnose.txt | 1 - .../evals/file-references/cases/explain.txt | 1 - .../evals/file-references/cases/plan.txt | 1 - .../evals/file-references/cases/review.txt | 1 - .../evals/file-references/prompts/a-colon.txt | 2 - .../file-references/prompts/baseline.txt | 2 - .../evals/file-references/prompts/display.txt | 2 - .../file-references/prompts/filename.txt | 2 - .../results/2026-09-16/metrics.md | 108 ----- .../results/2026-09-16/notes.md | 14 - .../results/2026-09-16/observations.json | 415 ------------------ .../evals/file-references/rubric.md | 75 ---- .../evals/file-references/run.py | 177 -------- .../evals/file-references/summarize.py | 81 ---- .../evals/file-references/test_eval.py | 154 ------- .../evals/file-references/treatment.mjs | 14 - .../ui-deliverables/tests/prompt.host.spec.ts | 2 - 27 files changed, 6 insertions(+), 1221 deletions(-) delete mode 100644 packages/client/ui-deliverables/evals/file-references/README.i18n.yaml delete mode 100644 packages/client/ui-deliverables/evals/file-references/README.md delete mode 100644 packages/client/ui-deliverables/evals/file-references/README.zh.md delete mode 100644 packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/cases/explain.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/cases/plan.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/cases/review.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/display.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/prompts/filename.txt delete mode 100644 packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md delete mode 100644 packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md delete mode 100644 packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json delete mode 100644 packages/client/ui-deliverables/evals/file-references/rubric.md delete mode 100644 packages/client/ui-deliverables/evals/file-references/run.py delete mode 100644 packages/client/ui-deliverables/evals/file-references/summarize.py delete mode 100644 packages/client/ui-deliverables/evals/file-references/test_eval.py delete mode 100644 packages/client/ui-deliverables/evals/file-references/treatment.mjs diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml index df0d00e82b..45af01e0ff 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md -2026-09-15-markdown-file-preview-links.md: d35f1d11cb303eb0b1e47b3d37f8e8b0aedc1937 -2026-09-15-markdown-file-preview-links.zh.md: 8eae197032abbe833a6e6655799043412c1c10b9 +2026-09-15-markdown-file-preview-links.md: 0114e8cf3a7024273ff5973f0fb09a28fc3244cb +2026-09-15-markdown-file-preview-links.zh.md: f872abb18c26716f4a169958bb7215ef4ac16685 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md index d35f1d11cb..0114e8cf3a 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -28,22 +28,4 @@ The Web file-reference prompt asks for a link on every existing-file mention out ## Consequences -Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar and keep the final colon treatment aligned with the shipped paragraph. Historical input hashes and the evaluation runner’s keyless checks run in Linux PR CI. The evaluation runner waits for live process-group members to terminate, excluding zombies that can retain a group id after exit; a PID-existence probe cannot establish running work. - -## Prompt evaluation - -The development comparison ran four read-only tasks three times per variant. E counts eligible existing-file mentions, L valid links, M missing links, and I invalid links; E = L + M + I. A timed out once without a final answer; that attempt remains a failure and is excluded only from content denominators. These descriptive measurements are neither a weighted quality score nor a holdout evaluation. - -| Metric | A | B | C | -| --- | ---: | ---: | ---: | -| Completed first answers / attempts | 11/12 | 12/12 | 12/12 | -| Valid-link coverage L/E | 453/476 (95.2%) | 539/585 (92.1%) | 467/592 (78.9%) | -| Missing / invalid links | 23 / 0 | 45 / 1 | 124 / 1 | -| Answers with M = I = 0 | 2/11 | 5/12 | 4/12 | -| Unnecessary directory labels / named labels | 149/373 | 200/471 | 166/403 | -| Full-path labels / named labels | 44/373 | 89/471 | 118/403 | -| Balanced 11-answer coverage | 95.2% | 93.0% | 79.0% | - -The colon revision is a separate development run. Its first three answers contained 28 mixed `:start-Lend` suffixes, retained as failures. Explicitly forbidding both `#` and `L` in the suffix yielded 51 structurally valid links in one repeated explanation, but no colon labels, three missing links, and 27 unnecessary directory qualifiers. A subsequent user-accepted demo produced 18 structurally valid links, including three colon labels and no mixed suffix. Neither run establishes reliable compliance or exhaustive factual accuracy. Authentication failures, the timeout, and all first answers remain in the local experiment archive; the [committed metrics](../../../../packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md) carry the extended 69-row metrics and task-rubric tables. - -Reusable [evaluation inputs and rubrics](../../../../packages/client/ui-deliverables/evals/file-references/README.md) live beside the prompt owner, with compact historical observations and failure records. Their manual runner uses the supported headless profile and keeps generated outputs outside version control. Package ownership keeps prompt changes and their regression criteria together; the repository performance benchmark tree has different input and timing requirements. +Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md index 8eae197032..f872abb18c 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -28,22 +28,4 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外 ## Consequences -源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导,并保持最终冒号评测文本与正式段落一致。历史输入哈希和评测运行器的无密钥检查在 Linux PR CI 中运行。评测运行器等待仍在运行的进程组成员终止,排除退出后仍可能保留组 id 的僵尸进程;PID 存在性检查无法确认进程仍在运行。 - -## Prompt evaluation - -开发对照对每版执行四个只读任务,每个任务三次。E 统计应链接的现有文件提及,L 为有效链接,M 为漏链,I 为无效链接;E = L + M + I。A 有一次超时且没有最终回答;该次仍保留为失败,仅从内容分母排除。这些描述性测量既不是加权质量总分,也不是 holdout 评估。 - -| 指标 | A | B | C | -| --- | ---: | ---: | ---: | -| 完整首答 / 尝试 | 11/12 | 12/12 | 12/12 | -| 有效链接覆盖 L/E | 453/476 (95.2%) | 539/585 (92.1%) | 467/592 (78.9%) | -| 漏链 / 无效链接 | 23 / 0 | 45 / 1 | 124 / 1 | -| M = I = 0 的回答 | 2/11 | 5/12 | 4/12 | -| 不必要目录标签 / 名称标签 | 149/373 | 200/471 | 166/403 | -| 完整路径标签 / 名称标签 | 44/373 | 89/471 | 118/403 | -| 平衡的 11 份回答覆盖率 | 95.2% | 93.0% | 79.0% | - -冒号修订是独立的开发验证。最初三份回答包含 28 个混合 `:start-Lend` 后缀,均保留为失败。明确禁止后缀包含 `#` 和 `L` 后,一次重复讲解生成 51 个结构有效的链接,但没有冒号标签,仍有三次漏链和 27 个不必要的目录限定。随后用户接受的 demo 生成 18 个结构有效的链接,其中三个使用冒号标签,没有混合后缀。这些运行都不能证明稳定遵循或完整事实正确性。认证失败、超时和所有首答仍保留在本地实验归档中;[已提交的指标](../../../../packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md)包含扩展的 69 行指标及任务 rubric 表。 - -可复用的[评测输入和 rubric](../../../../packages/client/ui-deliverables/evals/file-references/README.zh.md) 与提示词所有者放在一起,并保留精简历史观察和失败记录。人工触发的运行器使用受支持的 headless profile,生成输出不进入版本控制。包内归属让提示词变化与回归标准一起维护;仓库性能 benchmark 目录有不同的输入和计时要求。 +源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d56db14598..2139231c06 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -475,9 +475,6 @@ jobs: - name: Test production blame scoring run: uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py' - - name: Test file-reference evaluation tools - run: python -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v - - name: Run complete keyless Python suite run: uv run --python 3.10 --group test --project python/sdk pytest diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index bb1dd24ac8..1d015a3a01 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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: fc5efaca407b8d33a45f5d25dd581c2e0b67eeee -README.zh.md: 14871aad463360bc55dfa147c5b800e66671c26e +README.md: 4d3cdb2c82327a485a318af9bbb2e398bd704d71 +README.zh.md: 387fd678ba16bff42d944344a8ede940b44781d3 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index fc5efaca40..4d3cdb2c82 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -67,7 +67,6 @@ 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. -- [File-reference evaluation](evals/file-references/README.md) — frozen prompts, tasks, manual rubrics, and reproducible development-result summaries. - [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. - [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. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 14871aad46..387fd678ba 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -67,7 +67,6 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl 当产出物面不够用时阅读以下页面。它们从卡片进入 Host 记录器、turn-tail 洞与词表背后的决策。 -- [文件引用评测](evals/file-references/README.zh.md) — 冻结提示词、任务、人工 rubric 和可复算的开发评测汇总。 - [workspace-changes](../../deliverables/workspace-changes/README.zh.md)——记录并提供卡片所渲染摘要的 Host 插件。 - [ui-conversation](../ui-conversation/README.zh.md)——声明 `conversation.chat.turnTail` 洞并渲染收尾正文。 - [本轮改动文件卡片](../../../.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md)——用 git 记录的摘要取代修改调用行背后的决策。 diff --git a/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml b/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml deleted file mode 100644 index e4252b2dcb..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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 packages/client/ui-deliverables/evals/file-references/README.md -README.md: d4ad2a81209ad2786d491ddf7aab3858543c5328 -README.zh.md: 7a542e2fa3d4789b323037bf3a10d362ecb83b02 diff --git a/packages/client/ui-deliverables/evals/file-references/README.md b/packages/client/ui-deliverables/evals/file-references/README.md deleted file mode 100644 index d4ad2a8120..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/README.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -description: "File-reference prompt evaluation inputs, manual rubrics, and preserved development results for prompt maintainers." -kind: "package-reference" ---- - -# File-reference prompt evaluation - -English | [中文](README.zh.md) - -## Summary - -This owner-local evaluation compares file-reference guidance using four unchanged development tasks. It preserves A/B/C, the final colon-label revision, eligibility rules, task rubrics, and historical failures. Real-model runs are manual; the existing package tests and Web Session snapshots own keyless product regressions. The runner and aggregator use Python's standard library; the treatment enters the logged system prompt through a regular plugin. - -## Table of Contents - -- [Run a cohort](#run-a-cohort) -- [Review and summarize](#review-and-summarize) -- [Preserved results](#preserved-results) -- [Dev Note](#dev-note) - - -## Run a cohort - -Requires Python 3.9+, a POSIX host with `ps`, Node and repository dependencies, and a built checkout (`pnpm run build`). Run from the repository root. Supply the normal DSH environment file with `--env-file`, or inherit its environment; the runner does not read credential values. `--model` is explicit, with the DeepSeek provider and high reasoning effort. Each attempt gets fresh DSH home, agent home, Session, and an archived HEAD workspace; the executable is this checkout's built `dsh --profile headless` launcher. - -For one real smoke, set `DSH_EVAL_ENV` to your normal environment-file path and run: - -```sh -python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV" -``` - -Omit `--variant`, `--case`, and `--repetitions` for the original three variants, four tasks, and three repetitions. `--prepare-only` records inputs without calling the API. The default 480-second attempt bound can be shortened with `--timeout`. Runs execute serially and stop at the first failed attempt; no retry or resume replaces an output. The printed directory lives under ignored `.artifacts/file-reference-evals/`, retaining input hashes, source archive, outputs, workspaces, Sessions, and failures. A supplied `--output` must be new and inside `.artifacts/`; missing parent directories are created. The runner reaps the launcher and waits for every process-group member to terminate; zombies awaiting their parent’s reap do not count as running work. - -The portable runner uses the normal read-only sandbox and never-approval policy. Evaluator files are removed from the task workspace, but host/runtime/network resources are shared: this is not hermetic isolation. Inspect recorded tool activity for out-of-workspace access. Its controls differ from historical R3's macOS-specific guards and parallel scheduling; compare only within the new cohort, not by pooling it with R3. The archive pins source HEAD while the launcher hash identifies the build; rebuild after runtime changes. Treatment files retain their embedded newline and omit the shipped section’s leading output reminder, so the evaluated text is not byte-identical to the shipped section and cohort numbers do not measure that full section. - - -## Review and summarize - -Apply [rubric.md](rubric.md) to the full first answer and preserved workspace. Keep evaluators separate from execution; rubric contents are not passed to the model. Occurrence eligibility, unnecessary directories, ambiguity, and factual support require human review. Preserve the occurrence and task ledgers with each run, then write hash-bound `annotation.json` as documented in the rubric. Unreviewed answers remain unreviewed; an absent answer never counts as a perfect response. - -The summarizer accepts the printed run directory, or the committed historical observations: - -```sh -python3 packages/client/ui-deliverables/evals/file-references/summarize.py packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json -python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v -``` - -It validates nonnegative counts, E = L + M + I, duplicate attempts, and fresh-answer hashes. It reports planned/attempted/answered counts, missing audits, valid-link coverage, perfect audited answers, and timeouts. It does not automatically infer missing-file mentions or judge semantic correctness. These keyless Python checks validate the evaluation tools, historical input hashes, and process cleanup in the Linux PR CI job. The package’s normal Vitest suite checks the final colon treatment against the shipped guidance and that guidance in every Web prompt sidecar. No real-API CI job is added. - - -## Preserved results - -[The R3 table](results/2026-09-16/metrics.md) retains all 69 original metrics and task rubrics. [Observations](results/2026-09-16/observations.json) reproduce the core counts and include the timeout. [Limitations and later runs](results/2026-09-16/notes.md) keep the colon revisions and guided recording separate. The raw first answers, full Sessions, authentication logs, and original audit ledgers remain in the private experiment archive, not in this package or its npm tarball. Historical aggregate counts support recomputation, not independent re-auditing without those raw records. - - -## Dev Note - -The [file-preview decision](../../../../../.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md) owns prompt selection. This directory owns reusable evaluation inputs and methods; repository `benchmarks/` owns cross-package performance gates. diff --git a/packages/client/ui-deliverables/evals/file-references/README.zh.md b/packages/client/ui-deliverables/evals/file-references/README.zh.md deleted file mode 100644 index 7a542e2fa3..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/README.zh.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -description: "供提示词维护者使用的文件引用提示词评测输入、人工 rubric 和保留的开发评测结果。" -kind: "package-reference" ---- - -# 文件引用提示词评测 - -[English](README.md) | 中文 - -## 摘要 - -这份包内评测使用四个未修改的开发任务比较文件引用指导。它保留 A/B/C、最终冒号标签版、计数规则、任务 rubric 和历史失败。真实模型运行由人工触发;现有包测试和 Web Session 快照负责无密钥产品回归。运行器和汇总器使用 Python 标准库;评测提示词通过普通插件进入已记录的系统提示词。 - -## 目录 - -- [运行一组评测](#run-a-cohort) -- [评审与汇总](#review-and-summarize) -- [保留的结果](#preserved-results) -- [开发者笔记](#dev-note) - - -## 运行一组评测 - -需要 Python 3.9+、提供 `ps` 的 POSIX 主机、Node 和仓库依赖,以及已构建的检出目录(`pnpm run build`)。从仓库根目录执行。通过 `--env-file` 指定正常使用的 DSH 环境文件,或继承其环境;运行器不读取凭据值。`--model` 必须显式提供,使用 DeepSeek provider 和 high 推理强度。每次尝试使用新的 DSH home、agent home、Session 和 HEAD 归档工作区;可执行程序是本检出目录构建的 `dsh --profile headless` 启动器。 - -执行一次真实冒烟验证时,将 `DSH_EVAL_ENV` 设为正常环境文件路径,然后运行: - -```sh -python3 packages/client/ui-deliverables/evals/file-references/run.py --variant a-colon --case plan --repetitions 1 --model deepseek-flash --env-file "$DSH_EVAL_ENV" -``` - -省略 `--variant`、`--case` 和 `--repetitions` 即使用原三版、四个任务和三次重复。`--prepare-only` 只记录输入,不调用 API。默认每次尝试上限为 480 秒,可用 `--timeout` 缩短。运行串行执行,遇到首次失败即停止;重试或恢复不会替换输出。打印的目录位于被忽略的 `.artifacts/file-reference-evals/` 下,保留输入哈希、源码归档、输出、工作区、Session 和失败。指定的 `--output` 必须是 `.artifacts/` 内的新目录;缺失的父目录会自动创建。运行器回收启动器,并等待进程组的所有成员终止;等待父进程回收的僵尸进程不计为仍在运行的工作。 - -可移植运行器使用正常的只读 sandbox 和 never approval 策略。评测文件会从任务工作区移除,但宿主、运行时和网络资源仍共享,因此不是完全隔离环境。应检查记录的工具活动中是否存在工作区外访问。它的控制措施与历史 R3 的 macOS 专用防护及并行调度不同;只在新组内比较,不与 R3 合并统计。归档固定 HEAD 源码,启动器哈希标识构建;运行时代码变化后应重新构建。评测文本保留内嵌换行,并省略正式提示词段开头的产出提醒,因此评测文本与正式段落并非逐字节相同,组内指标不衡量该完整段落。 - - -## 评审与汇总 - -依据 [rubric.md](rubric.md) 检查完整首答和保留的工作区。评审与执行分开;rubric 内容不传给模型。引用是否应计数、目录是否多余、是否有歧义及事实依据都需要人工评审。保留每次运行的引用和任务台账,再按 rubric 写入绑定答案哈希的 `annotation.json`。未经评审的回答保持未评审;缺失回答不计为完美回答。 - -汇总器接受打印的运行目录,或已提交的历史观察数据: - -```sh -python3 packages/client/ui-deliverables/evals/file-references/summarize.py packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json -python3 -m unittest discover -s packages/client/ui-deliverables/evals/file-references -p 'test_*.py' -v -``` - -它验证非负计数、E = L + M + I、重复尝试和新答案哈希,并报告计划数、尝试数、回答数、缺失审计、有效链接覆盖、已审计完美回答和超时。它不自动推断遗漏的文件提及,也不判断语义正确性。这些无密钥 Python 检查在 Linux PR CI 作业中验证评测工具、历史输入哈希和进程清理。包的常规 Vitest 测试校验最终冒号评测文本与正式指导的一致性,以及每份 Web 提示词 sidecar 中的指导。不增加真实 API CI 作业。 - - -## 保留的结果 - -[R3 表格](results/2026-09-16/metrics.md) 保留全部 69 项原始指标和任务 rubric。[观察数据](results/2026-09-16/observations.json) 可复算核心计数,并包含超时。[限制及后续运行](results/2026-09-16/notes.md) 将冒号修订和指定格式的录屏单列。原始首答、完整 Session、认证日志和原始审计台账保留在私有实验归档中,不进入此包或其 npm tarball。历史聚合计数支持复算;没有原始记录时,不支持独立重新审计。 - - -## 开发者笔记 - -[文件预览决策](../../../../../.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md) 负责提示词选择。本目录负责可复用评测输入和方法;仓库 `benchmarks/` 负责跨包性能门禁。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt b/packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt deleted file mode 100644 index a922eab05a..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/cases/diagnose.txt +++ /dev/null @@ -1 +0,0 @@ -使用 str_replace_editor 时,我先 view 了一个文件,外部程序随后改了它,再执行 str_replace 报 FS_STALE_VERSION。请基于 packages/fs 里的实现、测试和文档给出排查步骤,说明怎样正确重试、恢复旧会话时有什么不同,以及从哪些文件继续定位;可以给必要的调用示例。只读分析,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/explain.txt b/packages/client/ui-deliverables/evals/file-references/cases/explain.txt deleted file mode 100644 index bd103b673e..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/cases/explain.txt +++ /dev/null @@ -1 +0,0 @@ -讲讲 packages/bundle/headless 是怎么把命令行任务跑成一次 Agent 会话的:任务从哪里来,如何选模型和工作目录,什么时候打印结果,失败和恢复会话如何处理。只读分析,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/plan.txt b/packages/client/ui-deliverables/evals/file-references/cases/plan.txt deleted file mode 100644 index 5ef6863e66..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/cases/plan.txt +++ /dev/null @@ -1 +0,0 @@ -想让 packages/util/atomic-write 的 writeFileAtomic 支持 Buffer,同时保留现有字符串调用。请给一个最小修改方案,列出涉及的实现、测试、文档与调用方,说明兼容性和验证方式。只分析方案,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/cases/review.txt b/packages/client/ui-deliverables/evals/file-references/cases/review.txt deleted file mode 100644 index 41cc4bad89..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/cases/review.txt +++ /dev/null @@ -1 +0,0 @@ -请 review packages/util/lazy-require 的实现、测试和 README,指出值得修复的问题;没有确认的问题也请直说,并说明你检查了哪些行为。只读分析,不修改文件,不调用子代理。 diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt b/packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt deleted file mode 100644 index b31f8a440f..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/prompts/a-colon.txt +++ /dev/null @@ -1,2 +0,0 @@ -Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. -Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt b/packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt deleted file mode 100644 index a5ead75311..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/prompts/baseline.txt +++ /dev/null @@ -1,2 +0,0 @@ -Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. -Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/display.txt b/packages/client/ui-deliverables/evals/file-references/prompts/display.txt deleted file mode 100644 index 3e3890dfbc..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/prompts/display.txt +++ /dev/null @@ -1,2 +0,0 @@ -Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. -Keep full paths in link targets, with only a filename or short, clear alias visible; use the surrounding text or a brief qualifier to distinguish same-named files. diff --git a/packages/client/ui-deliverables/evals/file-references/prompts/filename.txt b/packages/client/ui-deliverables/evals/file-references/prompts/filename.txt deleted file mode 100644 index a511017804..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/prompts/filename.txt +++ /dev/null @@ -1,2 +0,0 @@ -Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. -Use the filename alone as the label by default; if ambiguous in context, use a short, clear alias or brief qualifier. Keep directory paths in the target. diff --git a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md deleted file mode 100644 index 1d4471a2a4..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/metrics.md +++ /dev/null @@ -1,108 +0,0 @@ -# R3 补充 metrics 与 rubric 表格 - -A = baseline,B = filename,C = display。除标明“首次尝试”或“平衡子集”的行外,使用全部非空首答:A 11 份、B/C 各 12 份。A 的 review 第 2 轮 480.17 秒超时保留,空答不进入内容分母;没有重跑。这里补充汇总既有台账,未修改冻结 rubric、首答或失败记录。 - -数字是描述量,不能相加为总分。任务要求被覆盖,不等于其中断言正确;事实问题和限制另列。耗时含工具工作、网络与主机调度,仅描述本轮,不能归因为 SP 性能差异。排除项与待定项统计台账条目,集体 README 条目可能代表多个对象,因此不能直接加回 E。 - -## 执行与稳定性 - -| 指标 / rubric | A | B | C | -| --- | ---: | ---: | ---: | -| 首次尝试 | 12 | 12 | 12 | -| 完整首答 / 首次尝试 | 11/12 (91.7%) | 12/12 (100.0%) | 12/12 (100.0%) | -| 无最终回答的超时 | 1 | 0 | 0 | -| 首答有效引用全部链接(M=I=0) | 2/11 (18.2%) | 5/12 (41.7%) | 4/12 (33.3%) | -| 首答完全没有文件链接 | 0/11 (0.0%) | 0/12 (0.0%) | 3/12 (25.0%) | -| 完成首答耗时中位数(秒) | 77.19 | 94.28 | 86.09 | -| 完成首答耗时范围(秒) | 58.97–244.59 | 64.84–306.79 | 66.34–368.05 | -| 所有首次尝试总墙钟耗时之和(秒;并发任务相加) | 1639.39 | 1493.39 | 1615.84 | -| 工作区未变更 / 首次尝试 | 12/12 (100.0%) | 12/12 (100.0%) | 12/12 (100.0%) | - -## 引用、标签与歧义 - -| 指标 / rubric | A | B | C | -| --- | ---: | ---: | ---: | -| 有效链接覆盖 L/E | 453/476 (95.2%) | 539/585 (92.1%) | 467/592 (78.9%) | -| 已生成链接中的结构有效率 L/(L+I) | 453/453 (100.0%) | 539/540 (99.8%) | 467/468 (99.8%) | -| 漏链 / 无效链接 | 23 / 0 | 45 / 1 | 124 / 1 | -| 越界行号引用 | 0 | 1 | 1 | -| 不同文件数(跨首答按仓库相对路径去重) | 89 | 94 | 104 | -| 每份首答不同文件数之和(跨答不去重) | 186 | 207 | 230 | -| 待定引用台账条目(不进 E) | 12 | 16 | 17 | -| 排除项台账条目(不是全体排除引用次数) | 67 | 64 | 104 | -| 同名文件上下文歧义:引用次数 / 首答数 | 0 / 0 | 4 / 1 | 2 / 1 | -| 不必要目录标签 / 文件名或别名标签 | 149/373 (39.9%) | 200/471 (42.5%) | 166/403 (41.2%) | -| 完整路径标签 / 文件名或别名标签 | 44/373 (11.8%) | 89/471 (18.9%) | 118/403 (29.3%) | -| 未链接引用中仍显示完整路径 | 1 | 2 | 59 | -| 文件名或别名长度中位数 / P90(字符) | 19 / 39 | 16 / 43 | 20 / 42 | -| 文件名或别名长度均值(字符) | 21.58 | 21.86 | 23.37 | -| 实际数字标签 / 明确行号标签(不进名称长度) | 79 / 1 | 68 / 1 | 65 / 0 | - -## 平衡子集:每版相同的 11 个任务轮次 - -| 指标 / rubric | A | B | C | -| --- | ---: | ---: | ---: | -| 有效链接覆盖 | 453/476 (95.2%) | 506/544 (93.0%) | 432/547 (79.0%) | -| 全引用有效链接的首答 | 2/11 (18.2%) | 5/11 (45.5%) | 4/11 (36.4%) | -| 不必要目录标签 / 文件名或别名标签 | 149/373 (39.9%) | 185/438 (42.2%) | 152/368 (41.3%) | -| 同名歧义引用次数 | 0 | 4 | 2 | -| 完成耗时中位数(秒) | 77.19 | 93.92 | 84.02 | - -## 原任务 rubric 分项 - -| 指标 / rubric | A | B | C | -| --- | ---: | ---: | ---: | -| P1 Buffer 最小实现与 string 支持 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | -| P2 实现、测试、文档、调用方四个面 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | -| P3 兼容性 | 3/3 已回应 | 3/3 已回应 | 2/3 已回应;1 份有矛盾 | -| P4 验证方案 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | -| P5 只读/无子代理 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | -| D1 陈旧版本时序与排查步骤 | 3/3 已回应 | 1/3 已回应;2 份有矛盾 | 2/3 已回应;1 份有矛盾 | -| D2 正确重试流程 | 2/3 已回应;1 份有矛盾 | 3/3 已回应 | 3/3 已回应 | -| D3 恢复旧会话的区别 | 3/3 已回应 | 2/3 已回应;1 份有矛盾 | 2/3 已回应;1 份有矛盾 | -| D4 实现、测试、文档依据 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | -| D5 给出的调用示例符合实现 | 2/3 已回应;1 份有矛盾 | 2/3 已回应;1 份有矛盾 | 3/3 已回应 | -| D6 只读/无子代理 | 3/3 已回应 | 3/3 已回应 | 3/3 已回应 | -| R1 实现检查 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | -| R2 测试检查 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | -| R3 README 检查 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | -| R4 明确的 findings 结论 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | -| R5 说明检查过的行为 | 2/2 已覆盖;另 1 次超时 | 3/3 已覆盖 | 3/3 已覆盖 | -| R6 核查 findings 的事实依据 | 2/2 有审计;均保留证据限制;另 1 次超时 | 3/3 有审计;均保留证据限制 | 3/3 有审计;均保留证据限制 | -| R7 只读/无子代理 | 2/2 记录支持;另 1 次超时 | 3/3 记录支持 | 3/3 记录支持 | -| X1 任务来源 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | -| X2 模型选择 | 3/3 已覆盖;1 份有矛盾 | 3/3 已覆盖;1 份有矛盾 | 3/3 已覆盖 | -| X3 工作目录 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | -| X4 输出时机 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | -| X5 失败处理 | 3/3 已覆盖;1 份有矛盾 | 3/3 已覆盖 | 3/3 已覆盖 | -| X6 恢复会话 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | -| X7 贯通会话流程 | 3/3 已覆盖 | 3/3 已覆盖 | 3/3 已覆盖 | -| X8 事实及引用依据 | 3/3 记录问题或证据限制 | 3/3 记录问题或证据限制 | 3/3 记录问题或证据限制 | -| X9 只读/无子代理 | 3/3 记录支持 | 3/3 记录支持 | 3/3 记录支持 | - -## 问题记录:各任务保留各自分类,不相加成总分 - -| 指标 / rubric | A | B | C | -| --- | ---: | ---: | ---: | -| plan:源码支持的矛盾条目 | 1 | 2 | 2 | -| diagnose:源码支持的矛盾条目 | 5 | 6 | 6 | -| plan:仓库指令不符条目 | 2 | 1 | 2 | -| plan:无依据断言 / 文档遗漏条目 | 1 / 1 | 1 / 0 | 0 / 1 | -| explain:X8 问题或证据限制条目(含轻微措辞、引用问题) | 7 | 7 | 7 | -| review:R6 仍未证明的事实/运行结论 | 2/2 保留限制 | 3/3 保留限制 | 3/3 保留限制 | - -## 实验与 GUI rubric 状态 - -| 指标 / rubric | A | B | C | -| --- | ---: | ---: | ---: | -| SP 两条物理行,通用指导 | 2 | 2 | 2 | -| 原任务逐字保留;三次首次尝试 | 已核对 | 已核对 | 已核对 | -| GUI 原始首答导入(方案第 1、2 轮) | 2 份;字节一致 | 2 份;字节一致 | 2 份;字节一致 | -| GUI 显示、换行、截断与表格布局 | 未验证:浏览器阻止访问 | 未验证:浏览器阻止访问 | 未验证:浏览器阻止访问 | -| 实际点击并打开目标/定位行号 | 未验证 | 未验证 | 未验证 | -| 主观美观/偏好 | 未评;无数值分数 | 未评;无数值分数 | 未评;无数值分数 | -| 未见过的 holdout | 无;仅开发/回归任务 | 无;仅开发/回归任务 | 无;仅开发/回归任务 | - -同名歧义的人工记录集中于 B diagnose 第 2 轮的 4 次和 C diagnose 第 1 轮的 2 次;若把相邻角色的列举顺序视为足够对应,两者敏感性下界均为 0。0 表示没有观察到歧义,不代表全局唯一性得到证明。长度延续主汇总的顺序统计口径(偶数样本中位数取较低中位项,P90 取上整秩),只描述文件名/别名,排除数字与明确行号标签。 - -结构有效率只检查 Markdown、解析、文件存在性和行号范围。语义引用支持未逐条穷尽;例如 A explain 第 2 轮、B explain 第 1 轮仍有范围存在但不能完全支持断言的引用。越界行号的 2 次分属 B/C explain 第 3 轮;不应误报为文件不存在。不同评审的事实问题分类与粒度不同,尤其 review 的 R6 是逐例文字审计,故没有将它强行变成与其他任务可加总的错误次数。 diff --git a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md deleted file mode 100644 index 01ad6408f0..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/notes.md +++ /dev/null @@ -1,14 +0,0 @@ -# R3 and subsequent colon-label observations - -R3 contains 36 first attempts on four known development tasks, three repetitions per A/B/C treatment. One A review attempt timed out at 480.17 seconds without a final answer; it remains in execution counts and outside content denominators. Earlier authentication failures and earlier unsuccessful cohorts remain separately archived. No retry replaces an original first answer. - -The [original 69-row table](metrics.md) preserves its audit-time GUI status. The [compact observations](observations.json) preserve E/L/M/I for every attempted slot and exact SHA-256 hashes of the original prompts and tasks. These aggregates do not contain complete answer text or occurrence-level evidence; independent semantic re-audit requires the private archive. The original controls included macOS-specific host-read restrictions, maximum concurrency four, and a 480-second wall limit. Two runs had recorded host-access limitations; another read unfrozen external history. No cohort is claimed hermetic. - -| Later evidence | Valid links | Missing links | Label observations | Scope | -| --- | ---: | ---: | --- | --- | -| First A colon revision: plan, review, explain | 102 | 13 | 28 mixed `:start-Lend` suffixes | Retained failures; separate revision. | -| Corrected A: explanation | 51 | 3 | No colon labels; 27 unnecessary directory qualifiers | Colon branch inactive. | -| Corrected A: accepted GUI demo | 18 | Not fully audited | 3 colon labels; no mixed suffix | One task; user accepted the display. | -| PR recording | 5 | Not fully audited | Explicit `filename:line` request; five colon labels | Guided UI demonstration; not autonomous adherence evidence. | - -A leads original occurrence coverage; B has more wholly linked answers and completes all attempts. C has three answers without Markdown file links. All variants have factual problems or unresolved claims. No weighted score, statistically established winner, or unseen holdout is available. The final colon revision has not received the full four-task, three-repeat comparison. diff --git a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json b/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json deleted file mode 100644 index 815481fe47..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/results/2026-09-16/observations.json +++ /dev/null @@ -1,415 +0,0 @@ -{ - "cohort": "historical-r3", - "runs": [ - { - "name": "plan-baseline-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 36, - "L": 34, - "M": 2, - "I": 0 - } - }, - { - "name": "plan-filename-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 36, - "L": 35, - "M": 1, - "I": 0 - } - }, - { - "name": "plan-display-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 30, - "L": 0, - "M": 30, - "I": 0 - } - }, - { - "name": "diagnose-baseline-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 76, - "L": 74, - "M": 2, - "I": 0 - } - }, - { - "name": "diagnose-filename-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 65, - "L": 65, - "M": 0, - "I": 0 - } - }, - { - "name": "diagnose-display-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 85, - "L": 85, - "M": 0, - "I": 0 - } - }, - { - "name": "review-baseline-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 37, - "L": 33, - "M": 4, - "I": 0 - } - }, - { - "name": "review-filename-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 36, - "L": 25, - "M": 11, - "I": 0 - } - }, - { - "name": "review-display-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 31, - "L": 19, - "M": 12, - "I": 0 - } - }, - { - "name": "explain-baseline-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 28, - "L": 27, - "M": 1, - "I": 0 - } - }, - { - "name": "explain-filename-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 53, - "L": 53, - "M": 0, - "I": 0 - } - }, - { - "name": "explain-display-1", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 53, - "L": 52, - "M": 1, - "I": 0 - } - }, - { - "name": "plan-filename-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 34, - "L": 30, - "M": 4, - "I": 0 - } - }, - { - "name": "plan-display-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 27, - "L": 20, - "M": 7, - "I": 0 - } - }, - { - "name": "plan-baseline-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 30, - "L": 28, - "M": 2, - "I": 0 - } - }, - { - "name": "diagnose-filename-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 66, - "L": 66, - "M": 0, - "I": 0 - } - }, - { - "name": "diagnose-display-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 57, - "L": 57, - "M": 0, - "I": 0 - } - }, - { - "name": "diagnose-baseline-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 65, - "L": 64, - "M": 1, - "I": 0 - } - }, - { - "name": "review-filename-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 41, - "L": 33, - "M": 8, - "I": 0 - } - }, - { - "name": "review-display-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 45, - "L": 35, - "M": 10, - "I": 0 - } - }, - { - "name": "review-baseline-2", - "nonemptyFinal": false, - "timedOut": true, - "counts": null - }, - { - "name": "explain-filename-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 41, - "L": 39, - "M": 2, - "I": 0 - } - }, - { - "name": "explain-display-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 61, - "L": 61, - "M": 0, - "I": 0 - } - }, - { - "name": "explain-baseline-2", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 38, - "L": 38, - "M": 0, - "I": 0 - } - }, - { - "name": "plan-display-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 31, - "L": 0, - "M": 31, - "I": 0 - } - }, - { - "name": "plan-baseline-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 26, - "L": 25, - "M": 1, - "I": 0 - } - }, - { - "name": "plan-filename-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 46, - "L": 46, - "M": 0, - "I": 0 - } - }, - { - "name": "diagnose-display-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 57, - "L": 57, - "M": 0, - "I": 0 - } - }, - { - "name": "diagnose-baseline-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 58, - "L": 57, - "M": 1, - "I": 0 - } - }, - { - "name": "diagnose-filename-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 81, - "L": 81, - "M": 0, - "I": 0 - } - }, - { - "name": "review-display-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 32, - "L": 0, - "M": 32, - "I": 0 - } - }, - { - "name": "review-baseline-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 29, - "L": 20, - "M": 9, - "I": 0 - } - }, - { - "name": "review-filename-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 33, - "L": 20, - "M": 13, - "I": 0 - } - }, - { - "name": "explain-display-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 83, - "L": 81, - "M": 1, - "I": 1 - } - }, - { - "name": "explain-baseline-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 53, - "L": 53, - "M": 0, - "I": 0 - } - }, - { - "name": "explain-filename-3", - "nonemptyFinal": true, - "timedOut": false, - "counts": { - "E": 53, - "L": 46, - "M": 6, - "I": 1 - } - } - ], - "inputHashes": { - "prompts": { - "baseline": "a26cb88160f3078cda6ad3bb87714eee5faa7e2a898511d971412c40e2695f69", - "filename": "fcec33296928dcb369f9b13b96dc1433cc339665abf25fcce956957df41cb906", - "display": "48f2e4816785357713fed14717847d5964583862f9ea9de8fa02bd5fee4bd354" - }, - "tasks": { - "plan": "4c010a99a3d0a3713e15fdc220bd35ecb5f7f35bd1089805159ca12a08a153ec", - "diagnose": "afe4c6646c8265e5716244566522cf0f9559585e0d3f1f1d5897f95d61075cee", - "review": "eb3d6274371135e4cfb95a987fdccc983b4fbc627e7d9d03642a6277d08f0b72", - "explain": "236e36fadefb1475ed0335532eacbd318efc7368eec7cb6b82d563f2f44a8bd4" - } - }, - "limitations": [ - "Development tasks; no holdout or weighted score.", - "A review repetition 2 timed out; no retry replaced it.", - "Two runs had host-access limitations; another read unfrozen external history.", - "Portable runner has different controls; do not pool cohorts." - ] -} diff --git a/packages/client/ui-deliverables/evals/file-references/rubric.md b/packages/client/ui-deliverables/evals/file-references/rubric.md deleted file mode 100644 index e09d8bb89b..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/rubric.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -description: "Frozen file-reference eligibility, label judgments, and original-task checks for prompt maintainers." -kind: "package-reference" ---- - -# File-reference evaluation rubric - -## Summary - -These checks preserve the user-calibrated R3 rules and four original tasks. Task coverage, supported factual contradictions, unresolved claims, linking, and presentation are separate observations. No weighted score, character cutoff, significance claim, or universal-winner threshold is defined. - -## Table of Contents - -- [References and labels](#references-and-labels) -- [Original tasks](#original-tasks) -- [Review records](#review-records) -- [GUI and experiment limits](#gui-and-experiment-limits) - -## References and labels - -Count every reader-facing mention of an identifiable existing file, including repeats, headings, lists, tables, aliases, and bare filenames in inline code. Commands, configuration expressions, code blocks, directories, globs, hypothetical files, and unverified candidates do not enter the verified denominator. Record exclusions and uncertainty with their reasons; false existence claims remain factual issues. - -E is eligible mentions, L valid links, M missing links, and I invalid links: E = L + M + I. Zero E is inactive, never 100%. A timed-out attempt remains a failure; an absent answer has no content denominator. Keep all first attempts and report missing reviews explicitly. - -One filename followed by plain line numbers is one occurrence. Repeating only a plain line number creates no additional linking obligation. An actual numeric-only Markdown file link counts once. Full workspace-relative and absolute targets are valid; known-line anchors use `#L24` or `#L24-L30`. A displayed colon suffix is not a DSH destination anchor. Check parser acceptance, intended file identity, existence, and range separately from whether the cited text supports the claim. - -Labels may use a filename, short suffix, clear alias, or contextual numeric reference. Prefer an identifiable basename over its full repository path. Judge unnecessary directories and same-basename ambiguity in visible nearby context; hidden URLs and tooltips do not establish visible clarity. A basename need not be globally unique. Keep composite README references, generic groups, and unresolved aliases outside decisive counts, with sensitivity bounds where useful. - -Report full-path frequency, unnecessary qualifiers, label lengths, distinct files, numeric labels, malformed targets, wrong-file targets, and broken ranges separately. Exclude numeric-only and explicit line-reference labels from filename/alias lengths. Count unlinked full-path mentions too, so missing links cannot masquerade as shorter labels. Use the lower middle observation for even-sample medians and the ceiling rank for P90, matching the historical report. - -## Original tasks - -Each criterion receives addressed, omitted, contradicted, or unverified, with output spans and source evidence. Covering a topic does not establish correctness. Suggested edits do not violate read-only scope. No requirement to execute tests or produce findings is added to a task that asks for inspection. - -| ID | Requirement | -| --- | --- | -| P1 | Minimal Buffer support grounded in implementation, retaining string support. | -| P2 | Address implementation, tests, documentation, and callers; evidence may show a surface needs no edit. | -| P3 | Explain compatibility without requiring an unsupported overload or API design. | -| P4 | Give verification appropriate to the proposed change. | -| P5 | Read-only analysis, no subagents, assessed from execution evidence. | -| D1 | Explain the stale-version sequence and usable investigation steps. | -| D2 | Give the supported retry procedure and current basis for retry. | -| D3 | Explain restored-session differences from source evidence. | -| D4 | Identify relevant implementation, tests, documentation, and useful investigation files. | -| D5 | Invocation examples are optional; supplied examples must agree with implementation. | -| D6 | Read-only analysis, no subagents, assessed from execution evidence. | -| R1 | Inspect or discuss the implementation. | -| R2 | Inspect or discuss the tests. | -| R3 | Inspect README and documented expectations. | -| R4 | Give supported findings or explicitly state no issue was confirmed. | -| R5 | Explain the behaviors checked beyond a bare assertion of correctness. | -| R6 | Verify material findings against preserved source; distinguish confirmed issues, limitations, and speculation. | -| R7 | Read-only analysis, no subagents, assessed from execution evidence. | -| X1 | Explain CLI task origin and entry into Session execution. | -| X2 | Explain source-supported model selection inputs and defaults. | -| X3 | Explain working-directory selection. | -| X4 | Explain output timing relative to execution and completion. | -| X5 | Explain failure handling without unsupported recovery promises. | -| X6 | Explain Session resume and relevant task/model/directory interactions. | -| X7 | Connect the topics into a coherent Session lifecycle account. | -| X8 | Verify material factual claims and citations against preserved source. | -| X9 | Read-only analysis, no subagents, assessed from execution evidence. | - -## Review records - -For each first answer, review the complete text and retain an occurrence ledger containing line, column, text, identified file, L/M/I classification, exclusions, and ambiguity. Record task-criterion observations and source-supported contradictions separately. Reviewers must not infer E by counting only Markdown links. A self-review is supplemental evidence, not authoritative scoring. - -Create `annotation.json` beside a reviewed run's `final.md`, with `finalSha256`, `counts` containing E/L/M/I, and the occurrence/task ledgers. The summarizer verifies the answer hash and arithmetic; it does not validate human identity judgments or semantic claims. Missing annotations remain unreviewed. Historical observations contain aggregate counts imported from the original audit; their full raw ledgers remain in the private experiment archive. - -## GUI and experiment limits - -Review scanability, informative labels, same-basename clarity, visible/clickable links, correct destinations, wrapping, clipping, tables, line-number placement, and retained task explanation. Match viewport, theme, and zoom across prompts; inspect raw rendered answers without rewriting them. Screenshots alone do not verify clicking. Overall attractiveness and acceptable density require human preference, with no numeric beauty score. - -Freeze exact tasks, treatments, permitted resources, and bounds before execution. Keep evaluator material outside the task workspace. Run three fresh repetitions to describe variance; preserve failures and never replace them with silent retries. Known development tasks are not holdouts. An inactive formatting branch remains unverified. Later colon-label revisions and guided GUI demos are separate evidence, not new scores for the original A/B/C cohort. diff --git a/packages/client/ui-deliverables/evals/file-references/run.py b/packages/client/ui-deliverables/evals/file-references/run.py deleted file mode 100644 index e68e2ad7a9..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/run.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Run preserved file-reference tasks through built dsh profiles on POSIX hosts.""" -import argparse -import hashlib -import json -import os -from pathlib import Path -import shutil -import signal -import subprocess -import tempfile -import time - -OWNER = Path(__file__).resolve().parent -REPO = OWNER.parents[4] - - -def digest(path): - """Hash exact input or output bytes without parsing their contents.""" - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def process_group_running(pgid): - """Report live group members; zombies have exited and await their parent's reap.""" - rows = subprocess.check_output(['ps', '-A', '-o', 'pgid=', '-o', 'stat='], text=True) - return any(int(group) == pgid and not state.startswith('Z') - for group, state in (row.split() for row in rows.splitlines())) - - -def execute(command, workspace, task, slot, timeout): - """Preserve first output, reap the launcher, and await termination of its process group.""" - started = time.monotonic() - timed_out = False - interrupted = False - with task.open('rb') as stdin, (slot / 'stdout.jsonl').open('xb') as stdout, (slot / 'stderr.txt').open('xb') as stderr: - child = subprocess.Popen(command, cwd=workspace, stdin=stdin, stdout=stdout, - stderr=stderr, start_new_session=True) - try: - child.wait(timeout=timeout) - except subprocess.TimeoutExpired: - timed_out = True - except KeyboardInterrupt: - interrupted = True - finally: - # Terminate descendants that inherit the launcher process group. - try: - os.killpg(child.pid, signal.SIGTERM) - except ProcessLookupError: - pass - try: - child.wait(timeout=5) - except subprocess.TimeoutExpired: - os.killpg(child.pid, signal.SIGKILL) - child.wait() - # A descendant may ignore TERM after the leader exits; never leave it running. - try: - os.killpg(child.pid, signal.SIGKILL) - except ProcessLookupError: - pass - deadline = time.monotonic() + 5 - teardown_error = None - while process_group_running(child.pid): - if time.monotonic() >= deadline: - teardown_error = 'Owned process group did not finish teardown' - break - time.sleep(.01) - finals = [] - output_error = None - for line in (slot / 'stdout.jsonl').read_text().splitlines(): - try: - event = json.loads(line) - except json.JSONDecodeError: - output_error = 'stdout contains a non-JSON line; inspect preserved output' - break - if event.get('type') == 'final': - finals.append(event['text']) - final = finals[-1] if finals else '' - if final.strip(): - (slot / 'final.md').write_text(final + '\n') - result = {'exitCode': child.returncode, 'timedOut': timed_out, - 'interrupted': interrupted, 'outputError': output_error, 'teardownError': teardown_error, 'durationSeconds': time.monotonic() - started, - 'nonemptyFinal': bool(final.strip()), - 'finalSha256': digest(slot / 'final.md') if final.strip() else None} - (slot / 'execution.json').write_text(json.dumps(result, indent=2) + '\n') - return result - - -def main(): - """Freeze inputs before serial execution; stop at the first failed attempt.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--variant', nargs='+', choices=['baseline', 'filename', 'display', 'a-colon'], default=['baseline', 'filename', 'display']) - parser.add_argument('--case', nargs='+', choices=['plan', 'diagnose', 'review', 'explain'], default=['plan', 'diagnose', 'review', 'explain']) - parser.add_argument('--repetitions', type=int, default=3) - parser.add_argument('--timeout', type=float, default=480) - parser.add_argument('--model', required=True, help='Explicit DeepSeek model id, recorded with every run') - parser.add_argument('--env-file', type=Path, help='Normal DSH .env source; values are never read by this runner') - parser.add_argument('--output', type=Path, help='New directory under repository .artifacts/') - parser.add_argument('--prepare-only', action='store_true', help='Freeze the cohort without calling the API') - args = parser.parse_args() - if os.name != 'posix': - parser.error('Process-group cleanup requires a POSIX host') - if args.repetitions < 1 or not 0 < args.timeout <= 480: - parser.error('Use positive repetitions and a timeout no greater than 480 seconds') - if len(set(args.variant)) != len(args.variant) or len(set(args.case)) != len(args.case): - parser.error('Cases and variants must be unique') - launcher = REPO / 'apps/cli/lib/bin.js' - if not launcher.is_file(): - parser.error('Build this checkout with pnpm run build first') - root = REPO / '.artifacts/file-reference-evals' - root.mkdir(parents=True, exist_ok=True) - output = args.output.resolve() if args.output else Path(tempfile.mkdtemp(prefix='run-', dir=root)) - if args.output: - if not output.is_relative_to(REPO / '.artifacts'): - parser.error('--output must be inside repository .artifacts/') - output.mkdir(mode=0o700, parents=True) - output.chmod(0o700) - env_args = ['--env-file=' + str(args.env_file.resolve())] if args.env_file else [] - revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=REPO, text=True).strip() - archive = output / 'source.tar' - with archive.open('xb') as target: - subprocess.run(['git', 'archive', 'HEAD'], cwd=REPO, stdout=target, check=True) - frozen = output / 'inputs' - frozen.mkdir() - for folder in ['cases', 'prompts']: - shutil.copytree(OWNER / folder, frozen / folder) - shutil.copyfile(OWNER / 'treatment.mjs', frozen / 'treatment.mjs') - shutil.copyfile(OWNER / 'rubric.md', frozen / 'rubric.md') - names = [f'{case}-{variant}-{repeat}' for repeat in range(1, args.repetitions + 1) - for case in args.case for variant in args.variant] - manifest = {'sourceRevision': revision, 'launcherSha256': digest(launcher), - 'sourceArchiveSha256': digest(archive), 'runs': names, - 'model': args.model, 'reasoningEffort': 'high', 'timeoutSeconds': args.timeout, - 'preparedOnly': args.prepare_only, 'cohort': 'portable-owner-eval', - 'inputs': {str(p.relative_to(frozen)): digest(p) for p in frozen.rglob('*') if p.is_file()}} - (output / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') - print(output, flush=True) - if args.prepare_only: - return - for name in names: - case, variant = name.rsplit('-', 1)[0].split('-', 1) - slot = output / name - slot.mkdir() - workspace = slot / 'workspace' - workspace.mkdir() - subprocess.run(['tar', '-xf', str(archive), '-C', str(workspace)], check=True) - # Evaluator instructions and historical scores are not task inputs. - evals = workspace / OWNER.relative_to(REPO) - if evals.exists(): - shutil.rmtree(evals) - before = {str(p.relative_to(workspace)): digest(p) for p in workspace.rglob('*') if p.is_file()} - home = slot / 'home' - home.mkdir() - (home / 'settings.yaml').write_text(json.dumps({'agent-default-model': { - 'provider': 'deepseek-official', 'model': args.model, 'reasoningEffort': 'high'}})) - patch = [ - {'id': 'sandbox-policy', 'config': {'mode': 'read-only'}}, - {'id': 'approval', 'config': {'policy': 'never'}}, - {'id': 'agent-instructions', 'config': {'maxBytes': 65536, 'projectRootMarkers': ['pnpm-workspace.yaml'], 'dshHome': str(slot / 'agents')}}, - {'insert': [{'id': 'file-reference-treatment', 'name': str(frozen / 'treatment.mjs'), - 'config': {'promptFile': str(frozen / 'prompts' / (variant + '.txt'))}}]}, - ] - overlay = slot / 'overlay.yml' - overlay.write_text(json.dumps(patch)) - command = ['env', 'DSH_HOME=' + str(home), 'DSH_AGENTS_HOME=' + str(slot / 'agents'), - 'node', *env_args, str(launcher), '--profile', 'headless', '--patch', str(overlay), '--json', '-'] - task = frozen / 'cases' / (case + '.txt') - result = execute(command, workspace, task, slot, args.timeout) - after = {str(p.relative_to(workspace)): digest(p) for p in workspace.rglob('*') if p.is_file()} - changed = sorted(k for k in before.keys() | after.keys() if before.get(k) != after.get(k)) - (slot / 'workspace-audit.json').write_text(json.dumps({'unchanged': not changed, 'changedFiles': changed}, indent=2) + '\n') - print(json.dumps({'run': name, **result, 'workspaceUnchanged': not changed}), flush=True) - if result['exitCode'] != 0 or result['timedOut'] or result['interrupted'] or result['outputError'] or result['teardownError'] or not result['nonemptyFinal'] or changed: - (output / 'stop.json').write_text(json.dumps({'failed': name, 'unstarted': names[names.index(name) + 1:]}, indent=2) + '\n') - raise SystemExit(1) - - -if __name__ == '__main__': - main() diff --git a/packages/client/ui-deliverables/evals/file-references/summarize.py b/packages/client/ui-deliverables/evals/file-references/summarize.py deleted file mode 100644 index e14aba4516..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/summarize.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Aggregate reviewed occurrence counts without treating unreviewed answers as passes.""" -import argparse -import json -from pathlib import Path -from run import digest - - -def observations(source): - """Read historical records or join fresh outputs with hash-bound human annotations.""" - if source.is_file(): - return json.loads(source.read_text())['runs'] - manifest = json.loads((source / 'manifest.json').read_text()) - rows = [] - for name in manifest['runs']: - slot = source / name - if not (slot / 'execution.json').exists(): - if (slot / 'stdout.jsonl').exists(): - raise ValueError(f'{name}: incomplete execution record; inspect retained output') - rows.append({'name': name, 'unstarted': True}) - continue - row = {'name': name, **json.loads((slot / 'execution.json').read_text()), 'counts': None} - annotation = slot / 'annotation.json' - if annotation.exists(): - review = json.loads(annotation.read_text()) - if not row['nonemptyFinal'] or not review['finalSha256'] == row['finalSha256'] == digest(slot / 'final.md'): - raise ValueError(f'{name}: annotation does not match the preserved final answer') - row['counts'] = review['counts'] - rows.append(row) - return rows - - -def aggregate(rows): - """Keep execution and content denominators separate; reject invalid ledgers.""" - result = {} - seen = set() - for row in rows: - name = row['name'] - if name in seen: - raise ValueError(f'Duplicate attempt: {name}') - seen.add(name) - variant = name.rsplit('-', 1)[0].split('-', 1)[1] - group = result.setdefault(variant, dict(planned=0, attempted=0, completed=0, timedOut=0, - audited=0, unreviewed=0, perfect=0, E=0, L=0, M=0, I=0)) - group['planned'] += 1 - if row.get('unstarted'): - continue - group['attempted'] += 1 - group['completed'] += int(row['nonemptyFinal']) - group['timedOut'] += int(row['timedOut']) - counts = row.get('counts') - if counts is None: - group['unreviewed'] += int(row['nonemptyFinal']) - continue - if not row['nonemptyFinal']: - raise ValueError(f'{name}: an absent final answer cannot have content counts') - if any(type(counts.get(k)) is not int or counts[k] < 0 for k in ['E', 'L', 'M', 'I']): - raise ValueError(f'{name}: E/L/M/I must be nonnegative integers') - if counts['E'] != counts['L'] + counts['M'] + counts['I']: - raise ValueError(f'{name}: E must equal L + M + I') - group['audited'] += 1 - group['perfect'] += int(counts['E'] > 0 and counts['M'] == counts['I'] == 0) - for key in ['E', 'L', 'M', 'I']: - group[key] += counts[key] - return result - - -def main(): - """Print the same compact result table for archived and newly reviewed cohorts.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('source', type=Path, help='Run directory or historical observations.json') - args = parser.parse_args() - groups = aggregate(observations(args.source)) - print('| Prompt | Answers / attempts / planned | Audited | Unreviewed | L/E | Missing / invalid | Perfect | Timeouts |') - print('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |') - for variant, row in groups.items(): - rate = f"{row['L']}/{row['E']} ({row['L'] / row['E']:.1%})" if row['E'] else 'inactive' - print(f"| {variant} | {row['completed']}/{row['attempted']}/{row['planned']} | {row['audited']} | {row['unreviewed']} | {rate} | {row['M']} / {row['I']} | {row['perfect']} | {row['timedOut']} |") - - -if __name__ == '__main__': - main() diff --git a/packages/client/ui-deliverables/evals/file-references/test_eval.py b/packages/client/ui-deliverables/evals/file-references/test_eval.py deleted file mode 100644 index 07ed08cdd9..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/test_eval.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Keyless checks for retained failures and honest evaluation denominators.""" -import json -import os -import signal -import subprocess -import time -from pathlib import Path -import sys -import tempfile -import unittest -from run import execute, digest, process_group_running -from summarize import aggregate, observations - - -class EvaluationTests(unittest.TestCase): - def test_missing_audits_and_unstarted_slots_are_not_passes(self): - rows = [ - {'name': 'plan-baseline-1', 'nonemptyFinal': True, 'timedOut': False}, - {'name': 'plan-baseline-2', 'nonemptyFinal': False, 'timedOut': True}, - {'name': 'plan-baseline-3', 'unstarted': True}, - ] - self.assertEqual(aggregate(rows)['baseline'], dict(planned=3, attempted=2, completed=1, - timedOut=1, audited=0, unreviewed=1, perfect=0, E=0, L=0, M=0, I=0)) - - def test_interrupted_record_is_not_an_unstarted_slot(self): - with tempfile.TemporaryDirectory() as root: - source = Path(root) - (source / 'manifest.json').write_text(json.dumps({'runs': ['plan-baseline-1']})) - slot = source / 'plan-baseline-1' - slot.mkdir() - (slot / 'stdout.jsonl').write_text('{}\n') - with self.assertRaisesRegex(ValueError, 'incomplete execution record'): - observations(source) - - def test_bad_denominators_and_duplicate_attempts_fail(self): - row = {'name': 'plan-a-colon-1', 'nonemptyFinal': True, 'timedOut': False, - 'counts': {'E': 2, 'L': 1, 'M': 0, 'I': 0}} - with self.assertRaisesRegex(ValueError, 'E must equal'): - aggregate([row]) - row['counts']['M'] = 1 - self.assertEqual(aggregate([row])['a-colon']['M'], 1) - with self.assertRaisesRegex(ValueError, 'Duplicate'): - aggregate([row, row]) - row['counts']['E'] = True - with self.assertRaisesRegex(ValueError, 'nonnegative integers'): - aggregate([row]) - - def test_frozen_inputs_match_original_hashes(self): - owner = Path(__file__).parent - hashes = json.loads((owner / 'results/2026-09-16/observations.json').read_text())['inputHashes'] - for kind, expected in hashes.items(): - folder = 'cases' if kind == 'tasks' else 'prompts' - for name, value in expected.items(): - self.assertEqual(digest(owner / folder / (name + '.txt')), value) - - def test_zombie_group_has_no_running_work(self): - child = subprocess.Popen([sys.executable, '-c', - 'import time; print("ready", flush=True); time.sleep(60)'], - stdout=subprocess.PIPE, text=True, start_new_session=True) - try: - self.assertEqual(child.stdout.readline(), 'ready\n') - self.assertTrue(process_group_running(child.pid)) - child.kill() - deadline = time.monotonic() + 5 - while not subprocess.check_output(['ps', '-p', str(child.pid), '-o', 'stat='], text=True).strip().startswith('Z'): - if time.monotonic() >= deadline: - self.fail('Killed child did not reach zombie state') - time.sleep(.01) - # Deliberately leave our child unreaped while checking the same group. - if sys.platform == 'linux': - os.killpg(child.pid, 0) - self.assertFalse(process_group_running(child.pid)) - finally: - child.kill() - child.communicate() - - def test_successful_leader_does_not_leave_a_term_ignoring_descendant_running(self): - with tempfile.TemporaryDirectory() as root: - slot = Path(root) - task = slot / 'task.txt' - task.write_text('task') - descendant = 'import signal,time; signal.signal(signal.SIGTERM,signal.SIG_IGN); print("ready",flush=True); time.sleep(60)' - code = f"""import json, subprocess, sys -child = subprocess.Popen([sys.executable, '-c', {descendant!r}], stdout=subprocess.PIPE, text=True) -assert child.stdout.readline() == 'ready\\n' -print(json.dumps({{'pid': child.pid}}), flush=True) -print(json.dumps({{'type': 'final', 'text': 'done'}}), flush=True) -""" - result = execute([sys.executable, '-c', code], slot, task, slot, 10) - pid = json.loads((slot / 'stdout.jsonl').read_text().splitlines()[0])['pid'] - try: - self.assertFalse(result['timedOut']) - self.assertEqual(result['exitCode'], 0) - self.assertTrue(result['nonemptyFinal']) - self.assertIsNone(result['teardownError']) - state = subprocess.run(['ps', '-p', str(pid), '-o', 'stat='], capture_output=True, text=True, check=False) - self.assertTrue(state.returncode == 1 or state.stdout.strip().startswith('Z')) - finally: - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - pass - - def test_historical_counts_reproduce_report(self): - source = Path(__file__).parent / 'results/2026-09-16/observations.json' - groups = aggregate(observations(source)) - for variant, expected in [('baseline', (476, 453, 23, 0)), - ('filename', (585, 539, 45, 1)), ('display', (592, 467, 124, 1))]: - self.assertEqual(tuple(groups[variant][k] for k in ['E', 'L', 'M', 'I']), expected) - self.assertEqual(groups['baseline']['timedOut'], 1) - - def test_annotation_cannot_reclassify_changed_first_answer(self): - with tempfile.TemporaryDirectory() as root: - source = Path(root) - (source / 'manifest.json').write_text(json.dumps({'runs': ['plan-baseline-1']})) - slot = source / 'plan-baseline-1' - slot.mkdir() - final = slot / 'final.md' - final.write_text('first answer') - original = digest(final) - (slot / 'execution.json').write_text(json.dumps({'nonemptyFinal': True, 'timedOut': False, 'finalSha256': original})) - (slot / 'annotation.json').write_text(json.dumps({'finalSha256': original, 'counts': {'E': 1, 'L': 1, 'M': 0, 'I': 0}})) - self.assertEqual(aggregate(observations(source))['baseline']['L'], 1) - final.write_text('changed answer') - with self.assertRaisesRegex(ValueError, 'does not match'): - observations(source) - - def test_malformed_output_retains_execution_failure(self): - with tempfile.TemporaryDirectory() as root: - slot = Path(root) - task = slot / 'task.txt' - task.write_text('task') - result = execute([sys.executable, '-c', 'print("not json")'], slot, task, slot, 10) - self.assertIsNotNone(result['outputError']) - self.assertTrue((slot / 'execution.json').exists()) - - def test_timeout_preserves_output_and_reaps_process(self): - with tempfile.TemporaryDirectory() as root: - slot = Path(root) - task = slot / 'task.txt' - task.write_text('task') - result = execute([sys.executable, '-c', 'import time; print("{}", flush=True); time.sleep(60)'], - slot, task, slot, .2) - self.assertTrue(result['timedOut']) - self.assertFalse(result['nonemptyFinal']) - self.assertIsNotNone(result['exitCode']) - self.assertEqual((slot / 'stdout.jsonl').read_text(), '{}\n') - self.assertEqual(json.loads((slot / 'execution.json').read_text())['timedOut'], True) - with self.assertRaises(FileExistsError): - execute([sys.executable, '-c', 'pass'], slot, task, slot, 1) - - -if __name__ == '__main__': - unittest.main() diff --git a/packages/client/ui-deliverables/evals/file-references/treatment.mjs b/packages/client/ui-deliverables/evals/file-references/treatment.mjs deleted file mode 100644 index d8b288a04c..0000000000 --- a/packages/client/ui-deliverables/evals/file-references/treatment.mjs +++ /dev/null @@ -1,14 +0,0 @@ -/** Frozen evaluation treatment registered through the ordinary system-prompt plugin. */ -import { readFileSync } from 'node:fs' - -export const name = 'file-reference-evaluation' -export const inject = ['systemPrompt'] - -export function apply(ctx, config) { - ctx.systemPrompt.section({ - name: 'evaluation:file-references', - order: ctx.systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES'), - text: readFileSync(config.promptFile, 'utf8'), - interpolate: false, - }) -} diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 703572482f..0b7e989104 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -31,8 +31,6 @@ describe('ui-deliverables node plugin', () => { .find(entry => entry.name === 'ui:deliverable-file-references') expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') - const treatment = await readFile(new URL('../evals/file-references/prompts/a-colon.txt', import.meta.url), 'utf8') - expect(section?.text).toBe(`When you successfully create or modify files, mention the primary outputs in your final response. ${treatment.trim().split('\n').join(' ')}`) for (const scenario of ['cordis-tool-round', 'fresh-round-trip', 'ptc-round', 'schedule-catalog']) { const sidecar = await readFile(new URL(`../../../../snapshots/web/${scenario}/system-prompt.expected.md`, import.meta.url), 'utf8') expect(sidecar, scenario).toContain(section!.text) From f6428a164ef67e76ad57ba856bb615a31504b5e6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 13:39:31 +0800 Subject: [PATCH 035/225] feat(web): keep submitted plans in chat and open them in sidebar --- ...2026-09-17-persistent-plan-cards.i18n.yaml | 6 ++ .../2026-09-17-persistent-plan-cards.md | 29 ++++++ .../2026-09-17-persistent-plan-cards.zh.md | 29 ++++++ apps/web/tests/plan-review.e2e.ts | 72 +++++++++++++- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 3 +- docs/subsystems/slots.zh.md | 3 +- docs/subsystems/user-questions.i18n.yaml | 4 +- docs/subsystems/user-questions.md | 2 + docs/subsystems/user-questions.zh.md | 2 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 1 + .../ui-chat/src/client/contract/chat-nodes.ts | 2 + .../turn-process-presentation.ts | 2 +- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 8 +- packages/client/ui-plan/README.zh.md | 8 +- packages/client/ui-plan/package.json | 18 +++- .../client/ui-plan/src/client/PlanCard.tsx | 42 ++++++++ .../ui-plan/src/client/PlanPreview.module.css | 26 +++++ .../client/ui-plan/src/client/PlanPreview.tsx | 45 +++++++++ packages/client/ui-plan/src/client/index.ts | 51 +++++++--- packages/client/ui-plan/src/client/locales.ts | 10 ++ .../ui-plan/src/client/plan-definition.ts | 34 +++++++ .../ui-plan/src/client/plan-resource.ts | 79 +++++++++++++++ packages/client/ui-plan/src/client/plan.ts | 68 +++++++++++++ .../tests/browser-plugin.client.spec.ts | 23 ++++- .../tests/plan-preview.client.spec.tsx | 98 +++++++++++++++++++ .../tests/plan-resource.client.spec.ts | 58 +++++++++++ packages/client/ui-plan/tsconfig.json | 24 +++++ .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 4 +- .../client/ui-user-questions/README.zh.md | 4 +- .../client/ui-user-questions/package.json | 3 +- .../src/client/PlanReviewPanel.module.css | 30 ++---- .../src/client/PlanReviewPanel.tsx | 20 ++-- .../src/client/QuestionComposer.tsx | 2 +- .../src/client/contract/slots.ts | 14 ++- .../ui-user-questions/src/client/index.ts | 1 + .../tests/plan-review-panel.client.spec.tsx | 10 +- .../user-questions-composer.client.spec.tsx | 2 + .../client/ui-user-questions/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 63 +++++++++++- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- .../user-questions/README.i18n.yaml | 4 +- packages/interaction/user-questions/README.md | 2 + .../interaction/user-questions/README.zh.md | 2 + .../interaction/user-questions/src/types.ts | 3 + packages/plan/plan-mode/README.i18n.yaml | 4 +- packages/plan/plan-mode/README.md | 2 + packages/plan/plan-mode/README.zh.md | 2 + packages/plan/plan-mode/src/index.ts | 2 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 2 +- pnpm-lock.yaml | 37 ++++++- .../plan-review/approved-expanded.expected.md | 6 +- .../web/plan-review/approved.expected.md | 6 +- snapshots/web/plan-review/preview.expected.md | 39 ++++++++ snapshots/web/plan-review/review.expected.md | 38 +------ 63 files changed, 947 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md create mode 100644 .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md create mode 100644 packages/client/ui-plan/src/client/PlanCard.tsx create mode 100644 packages/client/ui-plan/src/client/PlanPreview.module.css create mode 100644 packages/client/ui-plan/src/client/PlanPreview.tsx create mode 100644 packages/client/ui-plan/src/client/plan-definition.ts create mode 100644 packages/client/ui-plan/src/client/plan-resource.ts create mode 100644 packages/client/ui-plan/src/client/plan.ts create mode 100644 packages/client/ui-plan/tests/plan-preview.client.spec.tsx create mode 100644 packages/client/ui-plan/tests/plan-resource.client.spec.ts create mode 100644 snapshots/web/plan-review/preview.expected.md diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml new file mode 100644 index 0000000000..261f24f581 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md +2026-09-17-persistent-plan-cards.md: e0a630c8395e4bf3d8d93bef010afffd177488d7 +2026-09-17-persistent-plan-cards.zh.md: c5722cd065260c5db6fd21a664b23545ca14c79b diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md new file mode 100644 index 0000000000..e0a630c839 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -0,0 +1,29 @@ +# 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` derives one permanent Chat card from each `exit_plan_mode` invocation, including native calls and PTC dispatches. The card declares `process: 'independent'`, so Chat keeps it outside the Turn process disclosure. Review dismissal, refusal, and approval do not delete it. + +The review intent carries the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. Both open the resource identified by Session and invocation. 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. + +The document is read-only. The pending review shows its status, sidebar opener, and decision buttons; the complete text is read in the sidebar. 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. + +**Teach Chat the plan tool's name.** A node-owned disclosure declaration lets the feature identify its independently readable artifact without adding plan-specific rendering or imports to Chat. + +## Consequences + +Plans survive review closure and browser reload without a new Session event or file. 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 native/PTC decoding and resource failures. diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md new file mode 100644 index 0000000000..c5722cd065 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 常驻计划卡片与侧边栏阅读 + +Status: implemented + +[English](2026-09-17-persistent-plan-cards.md) | 中文 + +## Problem + +计划审批只在用户回答或关闭之前占据编辑器。已完成的回合也会把工具调用折叠到运行过程里。这两种生命周期都无法在实施继续时,为已提交的计划提供容易找到的阅读入口。 + +## Decision + +`ui-plan` 从每次 `exit_plan_mode` 调用派生一张常驻 Chat 卡片,覆盖原生调用和 PTC dispatch。卡片声明 `process: 'independent'`,让 Chat 将其保留在回合运行过程折叠区之外。关闭、拒绝或批准审批都不会删除卡片。 + +审批意图携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。两者都打开由会话和调用标识确定的资源。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 + +文档只读。待处理审批显示状态、侧边栏入口和决定按钮;完整正文在侧边栏阅读。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。 + +## Alternatives considered + +**只保留待处理审批或可展开的工具行。** 两者都会让文档受其他交互或运行过程状态控制。用户需要常驻的产物入口。 + +**写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。 + +**让 Chat 识别计划工具名称。** 节点自行声明折叠行为,让功能标识需要独立阅读的产物,无需向 Chat 添加计划专用渲染或导入。 + +## Consequences + +计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖原生与 PTC 解码及资源读取失败。 diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 0bf7f5d5c9..0e99e9fdfc 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -27,6 +27,7 @@ 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 MODE = webSnapshotMode() @@ -84,6 +85,8 @@ 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(0) + 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 +99,21 @@ describe('web e2e: plan review takeover round trip', () => { await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE) } + const planCard = page.locator('[data-plan-card]') + await planCard.waitFor({ state: 'visible' }) + await card.getByRole('button', { name: 'Open plan in sidebar' }).click() + const preview = page.locator('[data-plan-preview]') + await preview.waitFor({ state: 'visible' }) + 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 planCard.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 @@ -115,6 +133,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) + // The completed Turn collapses its process; the artifact remains on the main line. + await planCard.waitFor({ state: 'visible' }) + expect(await planCard.locator('xpath=ancestor::*[@data-turn-process-member]').count()).toBe(0) + 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( @@ -129,8 +157,50 @@ describe('web e2e: plan review takeover round trip', () => { 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) + 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 review.getByRole('button', { name: 'Chat about it', exact: true }).click() + await settled + const results = events.filter(event => event.type === 'tool/result') + expect(results.some(event => JSON.stringify(event).includes('dismissed the plan review'))).toBe(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') + expect(tripwire.pageErrors).toEqual([]) + } finally { + await browser?.close() + await scaffold.close() + } + }) +}) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index bcae1728ec..614c1af2a8 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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: b494af3ece876b5b10b30c3602d6912f25e5088a -event-producer-consumer.zh.md: 36f97f867de1a897982a0dd7a463a8c20e85484d +event-producer-consumer.md: 2ba5b32d820d169d0cb0b4e97a294c5e2688d13f +event-producer-consumer.zh.md: 64fbf2e197f8ceb10eea25f1bb17f2735d6acd68 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b494af3ece..2ba5b32d82 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -73,7 +73,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` | | `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) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 36f97f867d..64fbf2e197 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -75,7 +75,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` | | `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) | diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d25c088405..3851d2aeea 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 177bbc5b8f..a9f0ee6ab2 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -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 diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c746ac8870..da6a38eab6 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -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 diff --git a/docs/subsystems/user-questions.i18n.yaml b/docs/subsystems/user-questions.i18n.yaml index 1138e03de1..d3e6082375 100644 --- a/docs/subsystems/user-questions.i18n.yaml +++ b/docs/subsystems/user-questions.i18n.yaml @@ -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 diff --git a/docs/subsystems/user-questions.md b/docs/subsystems/user-questions.md index fbbfb14355..d6515464e5 100644 --- a/docs/subsystems/user-questions.md +++ b/docs/subsystems/user-questions.md @@ -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 } ``` diff --git a/docs/subsystems/user-questions.zh.md b/docs/subsystems/user-questions.zh.md index 054ca06cd3..87536c22ad 100644 --- a/docs/subsystems/user-questions.zh.md +++ b/docs/subsystems/user-questions.zh.md @@ -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 } ``` diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 966f603db7..00cb0adb37 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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-chat/README.md -README.md: c7c9fb7cb2a9a149fae9b27db78e019d821dfb95 -README.zh.md: 386d7aae91b157a7b09fcd0515bfd8d075a39638 +README.md: faf5e8b955b46a84483ebfe0ec16c2a397522992 +README.zh.md: 3330ce4e37ad9b30dae3355b92430a55cdd736a6 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index c7c9fb7cb2..faf5e8b955 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -91,6 +91,8 @@ None; Chat presentation does not assemble or mutate provider requests. None. +A feature’s Chat Node may declare `process: 'independent'` to keep a user-facing artifact visible when the surrounding Turn process collapses; submitted plans use this declaration. + **Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 386d7aae91..3330ce4e37 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -93,6 +93,8 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有 无。 +功能的 Chat Node 可以声明 `process: 'independent'`,让面向用户的产物在所属回合运行过程折叠时仍保持可见;已提交的计划使用这一声明。 + **运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。 diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 40e08dbaf7..b852f8989c 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -68,6 +68,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ && !historyIncomplete const processMember = routedNode !== undefined && processWindowReady + && routedNode.process !== 'independent' && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) && routedNode.anchorSeq >= processSpec.processStartSeq && routedNode.anchorSeq < processSpec.answerAnchorSeq diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index d04bdfd90e..3b9152ecf8 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -10,6 +10,8 @@ export interface ChatConversationViewNode extends ConversationViewNode { readonly anchorSeq: number readonly location: ConversationLocation readonly visibility: 'visible' | 'hidden' + /** Keep a user-facing artifact outside the Turn process disclosure. */ + readonly process?: 'independent' } /** Merge-extensible payload registry keyed by final Chat renderer kind. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index a49ae6031b..02d3aa0ba2 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -54,7 +54,7 @@ function derivePresentation( && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) { compactAnswer = false } - if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) + if (node.process === 'independent' || TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) || node.anchorSeq < spec.processStartSeq || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 970ececb75..121093a2e5 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -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: 09c112fe395045a5fa9302efef73ee6a44eebb0c +README.zh.md: cbb58febadeb6bda92b0d3e23109c4d9ed7a108e diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 4ff275834e..09c112fe39 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -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`, leave with the composer chip, and open a submitted plan in the right sidebar from its review card or permanent Chat card. Plans remain available 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,10 @@ 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 + +Each submitted plan has a permanent Chat card outside the Turn process disclosure. Click its title, or the review strip’s Open plan in sidebar button, to read and copy the complete Markdown. Different submissions retain separate tabs. Closing the document or review does not delete the plan; the review buttons alone decide whether implementation may begin. + ### 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 +49,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. The plan resource address identifies the Session and invocation; 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 the caller’s invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. + ----- diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 588698b273..cbb58febad 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -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,10 @@ kind: "package-reference" 当有效目标为 plan 模式时,该座位渲染 warn 色「Plan ×」状态按钮,执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。 +### 查看已提交的计划 + +每次提交的计划都有一张常驻聊天卡片,不随回合运行过程折叠。点击卡片标题,或审批条带上的“在侧边栏打开计划”按钮,即可阅读和复制完整 Markdown。不同提交保留独立标签页。关闭文档或审批卡片不会删除计划;是否开始实施仍由审批按钮决定。 + ### 失败 准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,徽章保持显示直至投影确认退出。 @@ -45,6 +49,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 参数派生。计划资源地址标识会话和调用;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供调用方的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 + ----- diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index c8ada3d792..7fdf124cf7 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -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,11 @@ "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-resources", + "@deepseek-ai/dsh-api-session-controller" ], "platform": "web" } @@ -56,7 +60,15 @@ "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:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx new file mode 100644 index 0000000000..fe0db3f0f0 --- /dev/null +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -0,0 +1,42 @@ +/** Persistent transcript card and pending-review action use the same plan resource opener. */ +import { IconFullscreenOutline16, IconPlanOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +import type { InjectFace, PropsLocale, PropsRuntime } 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 css from './PlanPreview.module.css' + +/** Session-bound navigation injected into both plan entry points. */ +export interface PlanOpenInjected { + /** Open or focus the exact submitted plan. */ + openPlan: (callId: ToolCallId) => void +} + +/** + * Render the plan's permanent transcript entry. + * @param props - Logged plan, localized copy, and Session-bound navigation. + * @returns a keyboard-accessible plan card. + */ +export function PlanCard({ node, openPlan, t }: PropsRuntime<'conversation.chat.node', 'submitted-plan'> & InjectFace & PropsLocale<'plan'>) { + return ( + + ) +} + +/** + * Add a sidebar opener to the pending review without changing its answer. + * @param props - Review identity, localized copy, and navigation. + * @returns an opener when the asker supplied a logged invocation identity. + */ +export function PlanReviewOpen({ review, openPlan, t }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'>) { + const callId = review.callId + if (callId === undefined) return null + return +} diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css new file mode 100644 index 0000000000..19543b4ccf --- /dev/null +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -0,0 +1,26 @@ +.card { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 16px 18px; + margin: 12px 0; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 14px; + background: transparent; + color: var(--dsw-alias-label-secondary); + font: inherit; + text-align: left; + cursor: pointer; +} +.card:hover { background: var(--dsw-alias-interactive-bg-hover); } +.card:focus-visible, .reviewOpen:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.card > svg { flex-shrink: 0; } +.cardTitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); } +.reviewOpen { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } +.reviewOpen: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; } +@media (max-width: 767px) { .preview { padding: 16px 18px 32px; } } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx new file mode 100644 index 0000000000..71d2e6444e --- /dev/null +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -0,0 +1,45 @@ +/** Read-only Markdown viewer for an exact plan resource. */ +import { useMemo } from 'react' +import { IconCopyOutline16, 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 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 labels = useMemo(() => ({ code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes') }), [t]) + const plan = resource.value + if (plan === undefined) return ( +
    + {resource.status === 'failed' ? t('preview.failed') : t('preview.loading')} + {resource.failure !== undefined &&

    {resource.failure.message}

    } +
    + ) + return ( +
    +
    +
    +
    + ) +} + +/** + * Display the plan heading in its tab after resource recovery. + * @param props - Framework-bound tab identity and resource reader. + * @returns the recovered plan title or the tab's 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) + return {resource.value?.title ?? tab.tab.title} +} diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 42d080da76..b371c36cee 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -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,16 @@ 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 { PlanCard, PlanReviewOpen, type PlanOpenInjected } 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 { PlanChip } from './PlanModeControl.tsx' import { en, zh, type PlanKey } from './locales.ts' @@ -42,16 +44,41 @@ export interface PlanChipInjected { exitPlanMode: () => Promise } -/** 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', '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/**'], priority: 'builtin', + canOpen: address => parsePlanAddress(address) !== undefined, + title: () => t('preview.title'), + }), 'ui-plan: sidebar type') + const open = (sessionId: SessionId): PlanOpenInjected => ({ + openPlan: (callId) => { ctx.sidebarRight.openResourceIn(sessionId, planAddress({ sessionId, callId })) }, + }) + ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ + name: 'conversation.chat.node', key: 'submitted-plan', locale: NS, inject: open, + }, PlanCard)) + ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({ + name: 'conversation.plan-review.actions', id: previewId, locale: NS, inject: open, + }, 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, diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index b3048a7d5a..eac2047dc1 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -3,6 +3,11 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'chip.label': 'Plan', + 'preview.title': '计划', + 'preview.open': '在侧边栏打开计划', + 'preview.openNamed': '打开计划:{title}', + 'preview.loading': '正在读取计划…', + 'preview.failed': '无法读取计划', 'chip.on.aria': 'plan mode 已开启,按下关闭', 'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)', 'chip.off.aria': 'plan mode 已关闭,按下开启', @@ -16,6 +21,11 @@ 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.open': 'Open plan in sidebar', + 'preview.openNamed': 'Open plan: {title}', + 'preview.loading': 'Loading plan…', + 'preview.failed': 'Could not load plan', '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', diff --git a/packages/client/ui-plan/src/client/plan-definition.ts b/packages/client/ui-plan/src/client/plan-definition.ts new file mode 100644 index 0000000000..d3f1600509 --- /dev/null +++ b/packages/client/ui-plan/src/client/plan-definition.ts @@ -0,0 +1,34 @@ +/** Durable plan cards remain readable independently of a Turn's process disclosure. */ +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 = { + 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: 'visible', process: 'independent', data, + } satisfies ChatNode<'submitted-plan'> + }, +} diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts new file mode 100644 index 0000000000..7cb99f4ead --- /dev/null +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -0,0 +1,79 @@ +/** Read immutable plan arguments through the existing cold-safe Session history API. */ +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 + /** Session history ended before an opening snapshot. */ + 'plan/unavailable': Record + /** The Session has no readable plan for this invocation. */ + 'plan/not-found': Record + /** The Session history read failed. */ + 'plan/read-failed': Record + } +} + +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. + * @param remote - Existing Session history API. + * @returns a provider whose reads stop after finding the exact invocation. + */ +export function planResourceProvider(remote: Pick): ResourceProvider<'plan'> { + return { + protocol: 'plan', + async *open(address, { signal }): AsyncIterable> { + 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 = { kind: 'session' as const, sessionId: target.sessionId } + try { + // The snapshot supplies the page API's fixed log cut. Breaking closes the follow stream. + let snapshot: Extract | 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 (!aborted()) { + 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 + } + if (!aborted()) 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), {}), + } + } + }, + } +} diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts new file mode 100644 index 0000000000..d83c5c23ce --- /dev/null +++ b/packages/client/ui-plan/src/client/plan.ts @@ -0,0 +1,68 @@ +/** 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' + +/** One submitted plan, identified by its originating tool invocation. */ +export interface SubmittedPlan { + readonly callId: ToolCallId + readonly markdown: string + readonly title: string +} + +/** A saved sidebar resource names one invocation in one Session. */ +export interface PlanAddress { + readonly sessionId: SessionId + readonly callId: ToolCallId +} + +function record(value: unknown): value is Record { + 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 { + return `dsh-resource://plan/${encodeURIComponent(target.sessionId)}/${encodeURIComponent(target.callId)}` +} + +/** + * 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 { + return { sessionId: decodeURIComponent(match[1] as string) as SessionId, callId: decodeURIComponent(match[2] as string) as ToolCallId } + } catch (_error) { + // Invalid saved percent encoding cannot identify a Session or invocation. + return undefined + } +} diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index ee5b790a79..9b05f71806 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -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' @@ -16,10 +17,23 @@ 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() + ctx.provide('resources', { register: vi.fn(() => removeResources) }) + ctx.provide('sidebarRightTabs', { register: vi.fn(() => removeType) }) + ctx.provide('sidebarRight', { openResourceIn: vi.fn() }) + ctx.provide('remote.session', {}) + return { events, removeResources, removeType } +} + 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({ @@ -32,12 +46,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', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']) }) it('node-half apply is an intentional no-op', () => { @@ -46,6 +60,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 +98,11 @@ 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) }) }) diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx new file mode 100644 index 0000000000..7028be35d2 --- /dev/null +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom +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 { 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 { PlanCard, PlanReviewOpen } from '../src/client/PlanCard.tsx' +import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx' +import { planAddress, parsePlanAddress, submittedPlan } from '../src/client/plan.ts' +import { planDefinition } from '../src/client/plan-definition.ts' + +afterEach(cleanup) +const markdown = '# Keep this plan\n\n## Goal\n\n- Review\n- Implement' +const call = { type: 'tool/call', data: { name: 'exit_plan_mode', callId: 'call:1', arguments: JSON.stringify({ plan: markdown }) } } +const plan = submittedPlan(call)! +const target = { sessionId: 'session / 中文' as SessionId, callId: plan.callId } +const t = makeTranslate(en, commonEn) + +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 address of ['file:///plan.md', 'dsh-resource://plan/s/c/extra', 'dsh-resource://plan/s/%XX', 'dsh-resource://plan/s/c?text=x']) { + 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({ process: 'independent', anchorSeq: 12, data: plan }) + expect(planDefinition.buildViewNode!({ ...context, state: undefined, start: undefined, matches: [] } as never)).toBeNull() + }) +}) + +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 = { node: { data: plan }, t: makeTranslate(dictionary, commonEn), openPlan } as unknown as Parameters[0] + const view = render() + fireEvent.click(screen.getByRole('button')) + expect(openPlan).toHaveBeenCalledWith(plan.callId) + expect(screen.getByText(plan.title)).toBeTruthy() + view.unmount() + } + }) + it('opens the review without approving or cancelling it', () => { + const openPlan = vi.fn() + const props = { review: { callId: plan.callId }, t, openPlan } as unknown as Parameters[0] + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) + expect(openPlan).toHaveBeenCalledWith(plan.callId) + view.rerender([0]} />) + expect(screen.queryByRole('button')).toBeNull() + }) + 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([0]} />) + expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy() + expect(screen.getAllByRole('listitem')).toHaveLength(2) + cleanup() + render([0]} />) + expect(screen.getByText(plan.title)).toBeTruthy() + }) + it('shows loading and failed reads without an empty sidebar', () => { + const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }) } + const view = render( ({ status: 'loading' }) } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe('Loading plan…') + view.rerender( ({ status: 'failed', failure: { message: 'gone' } }) } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toContain('Could not load plan') + expect(screen.getByText('gone')).toBeTruthy() + }) +}) diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts new file mode 100644 index 0000000000..251f91ee01 --- /dev/null +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' +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[0]) + return { provider, follow, page, closed } +} + +async function read(provider: ReturnType, 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('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' } }]) + }) +}) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 5362b499de..3d1405d7ff 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -34,6 +34,30 @@ }, { "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" } ] } diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 0a5c7e8d82..9c6e7038fd 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -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: b6604aced9c5732ca302f93610c4fd313498397d +README.zh.md: 197427d337234eba44e1949de72d383fba1c3553 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index ec20bbc1c1..b6604aced9 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -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 waiting-approval card: a `Plan review` strip with the sidebar opener and one decision row of `Chat about it` / `Refuse` / `Approve`. The complete plan is read in the sidebar through this opener or its permanent Chat card. 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. ### Failure and recovery @@ -57,6 +57,8 @@ The card claims a request only when it can send every answer that request allows 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`; the plan plugin contributes a sidebar opener when the review intent identifies its logged invocation. Opening a document does not answer or dismiss the review. + ----- diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 9d8a2bf216..197427d337 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -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` 审阅上设置——渲染紧凑的等待审批卡片:一条带侧边栏入口的 `Plan review` 条带,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。通过该入口或聊天历史中的常驻卡片,在侧边栏阅读完整计划。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。 ### 失败与恢复 @@ -57,6 +57,8 @@ kind: "package-reference" 编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。 +计划审批提供 `conversation.plan-review.actions` 插槽;当审批意图包含已记录调用的标识时,计划插件在其中提供侧边栏入口。打开文档不会回答或关闭审批。 + ----- diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 6ff89373c3..200dc8d76c 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -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", diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css index bbe543610c..7222c69107 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css @@ -12,20 +12,11 @@ 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); border-radius: 20px; background: var(--dsw-specific-input-major); box-shadow: var(--dsw-shadow-lv2); 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, @@ -54,17 +45,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; @@ -105,12 +85,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; +} diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx index 9e18e5aced..43a70e5eb6 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx @@ -1,11 +1,11 @@ -import { useMemo, useState } from 'react' -import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { useState } from 'react' +import { Button, 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 + { pending: PendingQuestion; review: PlanReview } & Pick /** * 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) @@ -52,9 +48,9 @@ export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
    {t('plan.header')} -
    -
    - +
    + {renderSlot('conversation.plan-review.actions', { review })} +
    {error}
    diff --git a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx index 2f8726f3f0..c22355fcb0 100644 --- a/packages/client/ui-user-questions/src/client/QuestionComposer.tsx +++ b/packages/client/ui-user-questions/src/client/QuestionComposer.tsx @@ -123,7 +123,7 @@ export function QuestionComposer(props: QuestionComposerProps) { actions={props.actions} /> ) - : + : } type QuestionFlowProps = diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index 2d42379205..863443bd9c 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -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 } } + } +} + /** 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. */ @@ -90,6 +100,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 +231,6 @@ export type QuestionWait = PendingQuestion export type QuestionComposerProps = PropsRuntime<'conversation.composer'> & PropsStore> + & PropsRenderSlots<'conversation.plan-review.actions'> & { matched: QuestionWait } & PropsLocale<'question'> diff --git a/packages/client/ui-user-questions/src/client/index.ts b/packages/client/ui-user-questions/src/client/index.ts index 4596347211..a356324f0b 100644 --- a/packages/client/ui-user-questions/src/client/index.ts +++ b/packages/client/ui-user-questions/src/client/index.ts @@ -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, )) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 71b385e0fb..a5ea6b1982 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -108,6 +108,8 @@ const questionDraftStore = createQuestionDraftStore().create(SID) /** Framework standard-kit stubs: the panel consumes only the locale seat. */ const kit: Omit = { + renderSlot: () => null, + SessionProvider: ({ children }) => children, sessionId: SID, session: undefined, pendingInteraction: undefined, @@ -203,15 +205,15 @@ describe('planReviewOf', () => { }) describe('PlanReviewPanel', () => { - it('renders the plan under a review strip, with none of the quiz affordances', () => { + it('keeps the review compact with document reading left to the sidebar', () => { const { carrier } = wait() render() 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.queryByRole('heading', { name: 'Ship the picker' })).toBeNull() + 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() diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index 9dff625620..b83eb16262 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -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 = { + renderSlot: () => null, + SessionProvider: ({ children }) => children, session: undefined, sessionId: SID, pendingInteraction: undefined, diff --git a/packages/client/ui-user-questions/tsconfig.json b/packages/client/ui-user-questions/tsconfig.json index e66c67d105..c07d2c5fcc 100644 --- a/packages/client/ui-user-questions/tsconfig.json +++ b/packages/client/ui-user-questions/tsconfig.json @@ -46,6 +46,9 @@ }, { "path": "../ui-session" + }, + { + "path": "../../llm/llm" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 1ec4166573..ea5e03daa5 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -268,7 +268,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', + keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, submitted-plan, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', hookContext: 'ConversationLocationDataStore | undefined', slotInject: 'ChatNodeTurnDataInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', @@ -288,6 +288,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-chat TurnTailNodeView key \'turn-tail\'', 'client-ui-chat UnknownNodeView key \'unknown\'', 'client-ui-goal GoalCommandInputView key \'command-input\'', + 'client-ui-plan PlanCard key \'submitted-plan\'', 'client-ui-tool ToolCallTree key \'tool-call\'', 'client-ui-workflow-run WorkflowRunPanel key \'workflow-run\'', ], @@ -1051,6 +1052,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:199', }, + { + 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', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + '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 +2545,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-documentpreview TextPreview', 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', @@ -2533,6 +2593,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-documentpreview TextTitle', 'client-ui-sidebar-files FilesTitle', 'client-ui-sidebar-right GuideTitle', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eaf21fa553..4017c62cde 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4037,7 +4037,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', diff --git a/packages/interaction/user-questions/README.i18n.yaml b/packages/interaction/user-questions/README.i18n.yaml index ae8e55b4e0..3e42cc7328 100644 --- a/packages/interaction/user-questions/README.i18n.yaml +++ b/packages/interaction/user-questions/README.i18n.yaml @@ -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 diff --git a/packages/interaction/user-questions/README.md b/packages/interaction/user-questions/README.md index 5558c14839..b7082f7b76 100644 --- a/packages/interaction/user-questions/README.md +++ b/packages/interaction/user-questions/README.md @@ -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. + **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. diff --git a/packages/interaction/user-questions/README.zh.md b/packages/interaction/user-questions/README.zh.md index 17cf9c973c..81f1f3e72b 100644 --- a/packages/interaction/user-questions/README.zh.md +++ b/packages/interaction/user-questions/README.zh.md @@ -74,6 +74,8 @@ kind: "package-reference" 无。 +计划审批中可选的 `callId` 标识已记录的工具调用,供文档导航使用,不改变回答及其校验。 + **运行时不变式:** 不发布伴生入口。answerer waterfall 按请求解析并把结果直接返回调用方;该 seam 不发布独立的请求/回答审计流。 diff --git a/packages/interaction/user-questions/src/types.ts b/packages/interaction/user-questions/src/types.ts index 1818a63d91..38e507ce29 100644 --- a/packages/interaction/user-questions/src/types.ts +++ b/packages/interaction/user-questions/src/types.ts @@ -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. */ diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index aa3473d5d1..87aee0ed5e 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -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 diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 693273ab4b..642caf0c6d 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -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. + ----- diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 4782eed381..4165d5b925 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -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 日志中。 + ----- diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index e5910064de..f2c53c27a4 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -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, diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 313a3c9192..bdabf94997 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -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) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23120ed0df..7e46e9f8fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3614,12 +3614,21 @@ 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-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 @@ -3632,15 +3641,30 @@ 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 '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -4927,6 +4951,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 @@ -8106,7 +8133,7 @@ importers: version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.85.1 - version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3) + version: 0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -20561,14 +20588,14 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' - '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(supports-color@9.4.0)(ws@8.21.0)(zod@4.4.3)': + '@earendil-works/pi-ai@0.85.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.123.0(zod@4.4.3) '@aws-sdk/client-bedrock-runtime': 3.1048.0 '@earendil-works/pi-telemetry': 0.85.1 '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) '@smithy/node-http-handler': 4.7.3 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 openai: 6.40.0(ws@8.21.0)(zod@4.4.3) partial-json: 0.1.7 @@ -23196,7 +23223,7 @@ snapshots: cross-spawn: 7.0.6 debug: 4.4.3(supports-color@9.4.0) fs-extra: 10.1.0 - http-proxy-agent: 7.0.2(supports-color@9.4.0) + http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 js-yaml: 4.3.1 sanitize-filename: 1.6.4 @@ -24511,7 +24538,7 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - http-proxy-agent@7.0.2(supports-color@9.4.0): + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 debug: 4.4.3(supports-color@9.4.0) diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 0e4b01bcdb..51fe08f897 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -5,8 +5,6 @@ - text: Standard mode - button "More actions": - img - - button "Open right sidebar": - - img - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -34,6 +32,10 @@ - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . +- 'button "Open plan: Add `--greeting` flag to CLI"': + - img + - text: "Add `--greeting` flag to CLI" + - img - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"': - img - img diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index b5eb059952..2f392a048b 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -5,8 +5,6 @@ - text: Standard mode - button "More actions": - img - - button "Open right sidebar": - - img - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -22,6 +20,10 @@ - button "1 tool call · 1 message": - text: 1 tool call · 1 message - img +- 'button "Open plan: Add `--greeting` flag to CLI"': + - img + - text: "Add `--greeting` flag to CLI" + - img - button "System prompt": - img - img diff --git a/snapshots/web/plan-review/preview.expected.md b/snapshots/web/plan-review/preview.expected.md new file mode 100644 index 0000000000..8b9c5daeb5 --- /dev/null +++ b/snapshots/web/plan-review/preview.expected.md @@ -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. diff --git a/snapshots/web/plan-review/review.expected.md b/snapshots/web/plan-review/review.expected.md index 81d3af911c..12959ed5c1 100644 --- a/snapshots/web/plan-review/review.expected.md +++ b/snapshots/web/plan-review/review.expected.md @@ -1,41 +1,7 @@ - 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. + - button "Open plan in sidebar": + - img - status - button "Chat about it": - img From ab834ac667884f5baa906186818c5c11d8e08d96 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 13:45:28 +0800 Subject: [PATCH 036/225] test(web): cover plan resource cancellation and sidebar bindings --- .../ui-plan/src/client/plan-resource.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 46 +++++++++++++++++-- .../tests/plan-preview.client.spec.tsx | 25 ++++++++++ .../tests/plan-resource.client.spec.ts | 38 +++++++++++++++ 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts index 7cb99f4ead..695ee27386 100644 --- a/packages/client/ui-plan/src/client/plan-resource.ts +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -52,7 +52,7 @@ export function planResourceProvider(remote: Pick[0]) => removeType) + const openResourceIn = vi.fn() ctx.provide('resources', { register: vi.fn(() => removeResources) }) - ctx.provide('sidebarRightTabs', { register: vi.fn(() => removeType) }) - ctx.provide('sidebarRight', { openResourceIn: vi.fn() }) + ctx.provide('sidebarRightTabs', { register: registerType }) + ctx.provide('sidebarRight', { openResourceIn }) ctx.provide('remote.session', {}) - return { events, removeResources, removeType } + return { events, removeResources, removeType, registerType, openResourceIn } } const SID = 's-plan' as SessionId @@ -38,7 +43,13 @@ async function bench() { 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.node': { kind: 'keyed', 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 } } })) @@ -105,4 +116,31 @@ describe('ui-plan browser apply', () => { 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 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"}' } })! + for (const [slot, component] of [['conversation.chat.node', PlanCard], ['conversation.plan-review.actions', PlanReviewOpen]] as const) { + const entry = b.slots.entries(slot)[0]! + expect(entry.component).toBe(component) + const injected = (entry.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID) + injected.openPlan(plan.callId) + expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address) + } + 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.node')).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() } + }) }) diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index 7028be35d2..164f279dcb 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -52,6 +52,14 @@ describe('submitted plan identity', () => { expect(planDefinition.buildViewNode!(context as never)).toMatchObject({ process: 'independent', 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 }) + }) }) describe('plan entry points and document', () => { @@ -87,6 +95,23 @@ describe('plan entry points and document', () => { render([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([0]} />) + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + await Promise.resolve() + expect(writeText).toHaveBeenCalledWith(markdown) + view.rerender( ({ status: 'loading' }) } as unknown as Parameters[0]} />) + expect(screen.getByText('Plan')).toBeTruthy() + } finally { + if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard') + else Object.defineProperty(navigator, 'clipboard', clipboard) + } + }) it('shows loading and failed reads without an empty sidebar', () => { const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }) } const view = render( ({ status: 'loading' }) } as unknown as Parameters[0]} />) diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index 251f91ee01..700339f74f 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -1,4 +1,5 @@ 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' @@ -55,4 +56,41 @@ describe('plan history resource', () => { 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() + const pending = Promise.withResolvers>>() + 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() + }) }) From 472ddfa7aec2709d890a500054d5645fd408a2af Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 14:15:51 +0800 Subject: [PATCH 037/225] fix(web): align plan card borders and cover review identity --- .../client/ui-plan/src/client/PlanPreview.module.css | 2 +- .../tests/plan-review-panel.client.spec.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index 19543b4ccf..1bbe714308 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -5,7 +5,7 @@ width: 100%; padding: 16px 18px; margin: 12px 0; - border: 1px solid var(--dsw-alias-border-l1); + border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 14px; background: transparent; color: var(--dsw-alias-label-secondary); diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index a5ea6b1982..e362e06b00 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -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' @@ -173,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]) From 1e1dd43fee55967cda239eeecabfce1c2e816002 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 14:31:22 +0800 Subject: [PATCH 038/225] feat(web): open submitted plans automatically with artifact cards --- ...2026-09-17-persistent-plan-cards.i18n.yaml | 4 +- .../2026-09-17-persistent-plan-cards.md | 2 +- .../2026-09-17-persistent-plan-cards.zh.md | 2 +- apps/web/tests/plan-review.e2e.ts | 9 ++++- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 4 +- packages/client/ui-plan/README.zh.md | 4 +- packages/client/ui-plan/package.json | 1 + .../client/ui-plan/src/client/PlanCard.tsx | 27 +++++++++---- .../ui-plan/src/client/PlanPreview.module.css | 26 ++++++++---- packages/client/ui-plan/src/client/index.ts | 4 +- packages/client/ui-plan/src/client/locales.ts | 4 ++ .../client/ui-plan/src/client/review-store.ts | 24 +++++++++++ .../tests/plan-preview.client.spec.tsx | 40 +++++++++++++++++-- packages/client/ui-plan/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 16 files changed, 130 insertions(+), 31 deletions(-) create mode 100644 packages/client/ui-plan/src/client/review-store.ts diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml index 261f24f581..34b58fa6b8 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md -2026-09-17-persistent-plan-cards.md: e0a630c8395e4bf3d8d93bef010afffd177488d7 -2026-09-17-persistent-plan-cards.zh.md: c5722cd065260c5db6fd21a664b23545ca14c79b +2026-09-17-persistent-plan-cards.md: 5b790123bf10800b94ed28595f4127031df8bd04 +2026-09-17-persistent-plan-cards.zh.md: 155280d94f250ee2725c92f1f601331259e68728 diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md index e0a630c839..5b790123bf 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -14,7 +14,7 @@ A plan review occupies the composer only until the user answers or dismisses it. The review intent carries the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. Both open the resource identified by Session and invocation. 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. -The document is read-only. The pending review shows its status, sidebar opener, and decision buttons; the complete text is read in the sidebar. 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. +The document is read-only. The pending review shows its status, sidebar opener, and decision buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation, 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. 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 diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md index c5722cd065..155280d94f 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -14,7 +14,7 @@ Status: implemented 审批意图携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。两者都打开由会话和调用标识确定的资源。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 -文档只读。待处理审批显示状态、侧边栏入口和决定按钮;完整正文在侧边栏阅读。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。 +文档只读。待处理审批显示状态、侧边栏入口和决定按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。 ## Alternatives considered diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 0e99e9fdfc..325072020d 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -101,9 +101,9 @@ describe('web e2e: plan review takeover round trip', () => { const planCard = page.locator('[data-plan-card]') await planCard.waitFor({ state: 'visible' }) - await card.getByRole('button', { name: 'Open plan in sidebar' }).click() 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) @@ -111,6 +111,7 @@ describe('web e2e: plan review takeover round trip', () => { 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 planCard.click() await preview.waitFor({ state: 'visible' }) @@ -182,6 +183,8 @@ describe('web e2e: dismissed plan history', () => { 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: 'Chat about it', exact: true }).click() await settled const results = events.filter(event => event.type === 'tool/result') @@ -197,6 +200,10 @@ describe('web e2e: dismissed plan history', () => { 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]').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) } finally { await browser?.close() diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 121093a2e5..a1cf0d1cf8 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -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: 09c112fe395045a5fa9302efef73ee6a44eebb0c -README.zh.md: cbb58febadeb6bda92b0d3e23109c4d9ed7a108e +README.md: 7f68df2d44e6e540a559e4769b7550fc52e5cfae +README.zh.md: ac9977aa72787b73fb84f46a0e9f0f9515e72ad5 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 09c112fe39..7f68df2d44 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Plan mode lets you review a plan before implementation. Enter with `/plan`, leave with the composer chip, and open a submitted plan in the right sidebar from its review card or permanent Chat card. Plans remain available after approval, rejection, or dismissal; reopening a plan focuses its existing tab, and browser reload restores the document from Session history. +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 their permanent Chat cards after approval, rejection, or dismissal. Reopening a plan focuses its existing tab, and browser reload restores the document from Session history. ## Table of Contents @@ -33,7 +33,7 @@ While the effective target is plan mode, the seat renders the warn-colored "Plan ### Reading submitted plans -Each submitted plan has a permanent Chat card outside the Turn process disclosure. Click its title, or the review strip’s Open plan in sidebar button, to read and copy the complete Markdown. Different submissions retain separate tabs. Closing the document or review does not delete the plan; the review buttons alone decide whether implementation may begin. +Each submitted plan has a permanent Chat card outside the Turn process disclosure, 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 Open plan in sidebar button to read and copy the complete Markdown. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin. ### Failures diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index cbb58febad..ac9977aa72 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出,并从审批卡片或聊天历史中的常驻计划卡片在右侧边栏打开全文。批准、拒绝或关闭审批后,计划仍可查看;重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。 +计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出。提交的计划自动在右侧边栏打开供审阅;批准、拒绝或关闭审批后,仍可通过聊天历史中的常驻卡片查看。重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。 ## 目录 @@ -33,7 +33,7 @@ kind: "package-reference" ### 查看已提交的计划 -每次提交的计划都有一张常驻聊天卡片,不随回合运行过程折叠。点击卡片标题,或审批条带上的“在侧边栏打开计划”按钮,即可阅读和复制完整 Markdown。不同提交保留独立标签页。关闭文档或审批卡片不会删除计划;是否开始实施仍由审批按钮决定。 +每次提交的计划都有一张常驻聊天卡片,不随回合运行过程折叠,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“在侧边栏打开计划”按钮可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 ### 失败 diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 7fdf124cf7..a2e837347a 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -48,6 +48,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:^", diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index fe0db3f0f0..b9e2c94eb8 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -1,9 +1,11 @@ /** Persistent transcript card and pending-review action use the same plan resource opener. */ -import { IconFullscreenOutline16, IconPlanOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { useEffect } from 'react' +import { FileTypeIcon, IconFullscreenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' -import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +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 injected into both plan entry points. */ @@ -22,20 +24,29 @@ export function PlanCard({ node, openPlan, t }: PropsRuntime<'conversation.chat. ) } /** - * Add a sidebar opener to the pending review without changing its answer. - * @param props - Review identity, localized copy, and navigation. + * 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 when the asker supplied a logged invocation identity. */ -export function PlanReviewOpen({ review, openPlan, t }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'>) { +export function PlanReviewOpen({ review, openPlan, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { const callId = review.callId + const opened = useStore(state => callId !== undefined && state.opened[callId] === true) + useEffect(() => { + if (callId === undefined || opened) return + actions.markOpened(callId) + openPlan(callId) + }, [callId, opened, openPlan, actions]) if (callId === undefined) return null return diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index 1bbe714308..116dc40a62 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -1,22 +1,32 @@ .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: 12px; + gap: 10px; + min-width: 0; width: 100%; - padding: 16px 18px; + height: 60px; + padding: 8px 10px; margin: 12px 0; border: 0.5px solid var(--dsw-alias-border-l1); - border-radius: 14px; - background: transparent; - color: var(--dsw-alias-label-secondary); + 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; } -.card:hover { background: var(--dsw-alias-interactive-bg-hover); } +: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, .reviewOpen:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } -.card > svg { flex-shrink: 0; } -.cardTitle { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary); } +.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; } .reviewOpen { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } .reviewOpen:hover { background: var(--dsw-alias-interactive-bg-hover); } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index b371c36cee..8dfa535e1b 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -20,6 +20,7 @@ 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 { createPlanReviewStore } from './review-store.ts' import { PlanChip } from './PlanModeControl.tsx' import { en, zh, type PlanKey } from './locales.ts' @@ -66,11 +67,12 @@ export function apply(ctx: ClientContext): void { const open = (sessionId: SessionId): PlanOpenInjected => ({ openPlan: (callId) => { ctx.sidebarRight.openResourceIn(sessionId, planAddress({ sessionId, callId })) }, }) + const reviewStore = createPlanReviewStore() ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'submitted-plan', locale: NS, inject: open, }, PlanCard)) ctx.slots.inject('conversation.plan-review.actions', () => ctx.slots.register({ - name: 'conversation.plan-review.actions', id: previewId, locale: NS, inject: open, + name: 'conversation.plan-review.actions', id: previewId, locale: NS, inject: open, store: reviewStore, }, PlanReviewOpen)) ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', key: previewId, locale: NS, diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index eac2047dc1..7581602beb 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -4,6 +4,8 @@ export const zh = { 'chip.label': 'Plan', 'preview.title': '计划', + 'preview.document': '计划 · Markdown', + 'preview.action': '打开', 'preview.open': '在侧边栏打开计划', 'preview.openNamed': '打开计划:{title}', 'preview.loading': '正在读取计划…', @@ -22,6 +24,8 @@ export type PlanKey = keyof typeof zh export const en = { 'chip.label': 'Plan', 'preview.title': 'Plan', + 'preview.document': 'Plan · Markdown', + 'preview.action': 'Open', 'preview.open': 'Open plan in sidebar', 'preview.openNamed': 'Open plan: {title}', 'preview.loading': 'Loading plan…', diff --git a/packages/client/ui-plan/src/client/review-store.ts b/packages/client/ui-plan/src/client/review-store.ts new file mode 100644 index 0000000000..d3a4676534 --- /dev/null +++ b/packages/client/ui-plan/src/client/review-store.ts @@ -0,0 +1,24 @@ +/** Session-owned memory of pending plans already opened automatically. */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' + +interface PlanReviewState { + opened: Partial> +} + +type PlanReviewActions = { + markOpened: (draft: PlanReviewState, callId: ToolCallId) => 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 { + return defineStore({ + init: (): PlanReviewState => ({ opened: {} }), + actions: { + markOpened: (draft, callId) => { draft.opened[callId] = true }, + }, + }) +} diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index 164f279dcb..83d28fb835 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -9,6 +9,7 @@ import { PlanCard, PlanReviewOpen } from '../src/client/PlanCard.tsx' import { PlanPreview, PlanTitle } from '../src/client/PlanPreview.tsx' import { planAddress, parsePlanAddress, submittedPlan } from '../src/client/plan.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' @@ -68,6 +69,9 @@ describe('plan entry points and document', () => { const openPlan = vi.fn() const props = { node: { data: plan }, t: makeTranslate(dictionary, commonEn), openPlan } as unknown as Parameters[0] const view = render() + 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() @@ -76,13 +80,43 @@ describe('plan entry points and document', () => { }) it('opens the review without approving or cancelling it', () => { const openPlan = vi.fn() - const props = { review: { callId: plan.callId }, t, openPlan } as unknown as Parameters[0] + const store = createPlanReviewStore().create() + const stateProps = { + actions: store.actions, + useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), + } + const props = { review: { callId: plan.callId }, t, openPlan, ...stateProps } as unknown as Parameters[0] const view = render() + expect(openPlan).toHaveBeenCalledExactlyOnceWith(plan.callId) + view.rerender() + expect(openPlan).toHaveBeenCalledTimes(1) fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) - expect(openPlan).toHaveBeenCalledWith(plan.callId) - view.rerender([0]} />) + expect(openPlan).toHaveBeenCalledTimes(2) + view.rerender([0]} />) expect(screen.queryByRole('button')).toBeNull() }) + it('preserves manual closure across remounts and opens a new submission', () => { + const store = createPlanReviewStore().create() + const openPlan = vi.fn() + const props = { review: { callId: plan.callId }, t, openPlan, actions: store.actions, + useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), + } as unknown as Parameters[0] + const first = render() + first.unmount() + const second = render() + expect(openPlan).toHaveBeenCalledTimes(1) + const revised = submittedPlan({ ...call, data: { ...call.data, callId: 'call:2' } })! + const nextProps = { ...props, review: { callId: revised.callId } } as unknown as Parameters[0] + second.rerender() + expect(openPlan).toHaveBeenLastCalledWith(revised.callId) + expect(openPlan).toHaveBeenCalledTimes(2) + second.unmount() + const other = createPlanReviewStore().create() + render() => unknown) => select(other.getSnapshot()), + } as unknown as Parameters[0]} />) + expect(openPlan).toHaveBeenCalledTimes(3) + }) it('shows restored Markdown and its heading as the tab title', () => { const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }), diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index 3d1405d7ff..d3381e24b2 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../store" + }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7e46e9f8fe..3411b9aea3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3623,6 +3623,9 @@ importers: '@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 From e68bc539b81550d384bffec92f25edfd40af3a20 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 14:35:19 +0800 Subject: [PATCH 039/225] test(web): snapshot artifact-style plans and automatic previews --- apps/web/tests/plan-review.e2e.ts | 2 +- snapshots/web/plan-review/approved-expanded.expected.md | 5 +---- snapshots/web/plan-review/approved.expected.md | 5 +---- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 325072020d..100e06748e 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -203,7 +203,7 @@ describe('web e2e: dismissed plan history', () => { 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]').count()).toBe(0) + expect(await page.locator('[data-plan-preview]').isVisible()).toBe(false) expect(tripwire.pageErrors).toEqual([]) } finally { await browser?.close() diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 51fe08f897..ac31e524c2 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -32,10 +32,7 @@ - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . -- 'button "Open plan: Add `--greeting` flag to CLI"': - - img - - text: "Add `--greeting` flag to CLI" - - img +- 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"': - img - img diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 2f392a048b..897b4a6d00 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -20,10 +20,7 @@ - button "1 tool call · 1 message": - text: 1 tool call · 1 message - img -- 'button "Open plan: Add `--greeting` flag to CLI"': - - img - - text: "Add `--greeting` flag to CLI" - - img +- 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "System prompt": - img - img From a096637d017a63145dd0fa5c29457f704ef55b14 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 14:40:08 +0800 Subject: [PATCH 040/225] test(web): cover automatic plan previews on narrow screens --- apps/web/tests/plan-review.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 100e06748e..ec9b195a36 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -174,6 +174,7 @@ describe('web e2e: dismissed plan history', () => { 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) From 577e4a036d89f4b77da62e141df2dfdbf28c5183 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 15:13:04 +0800 Subject: [PATCH 041/225] fix(web): collect submitted plans in the final turn artifacts --- ...2026-09-17-persistent-plan-cards.i18n.yaml | 4 +- .../2026-09-17-persistent-plan-cards.md | 6 +- .../2026-09-17-persistent-plan-cards.zh.md | 6 +- apps/web/tests/plan-review.e2e.ts | 8 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 - packages/client/ui-chat/README.zh.md | 2 - .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 1 - .../src/client/chat/TurnTailNodeView.tsx | 6 +- .../client/chat/register-node-renderers.ts | 2 +- .../ui-chat/src/client/contract/chat-nodes.ts | 2 - .../ui-chat/src/client/contract/slots.ts | 8 +- .../turn-process-presentation.ts | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 3 - .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- .../src/client/Deliverables.tsx | 12 ++- .../ui-deliverables/src/client/index.ts | 10 +-- .../tests/deliverables.client.spec.tsx | 14 ++- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 6 +- packages/client/ui-plan/README.zh.md | 6 +- .../client/ui-plan/src/client/PlanCard.tsx | 38 +++++--- .../ui-plan/src/client/PlanPreview.module.css | 3 +- packages/client/ui-plan/src/client/index.ts | 8 +- .../ui-plan/src/client/plan-definition.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 11 ++- .../tests/plan-preview.client.spec.tsx | 86 +++++++++++++++++-- .../src/client/slot-catalog.ts | 32 ++++--- 30 files changed, 207 insertions(+), 95 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml index 34b58fa6b8..d0edb29ca6 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md -2026-09-17-persistent-plan-cards.md: 5b790123bf10800b94ed28595f4127031df8bd04 -2026-09-17-persistent-plan-cards.zh.md: 155280d94f250ee2725c92f1f601331259e68728 +2026-09-17-persistent-plan-cards.md: dabf5c970f8cf77fb2f4ff5cedcc11e2a094751f +2026-09-17-persistent-plan-cards.zh.md: c84a20ca464ac69206f008b99e5039cd0791a2ca diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md index 5b790123bf..dabf5c970f 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -10,7 +10,7 @@ A plan review occupies the composer only until the user answers or dismisses it. ## Decision -`ui-plan` derives one permanent Chat card from each `exit_plan_mode` invocation, including native calls and PTC dispatches. The card declares `process: 'independent'`, so Chat keeps it outside the Turn process disclosure. Review dismissal, refusal, and approval do not delete it. +`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 carries the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. Both open the resource identified by Session and invocation. 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. @@ -22,8 +22,8 @@ The document is read-only. The pending review shows its status, sidebar opener, **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. -**Teach Chat the plan tool's name.** A node-owned disclosure declaration lets the feature identify its independently readable artifact without adding plan-specific rendering or imports to Chat. +**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 -Plans survive review closure and browser reload without a new Session event or file. 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 native/PTC decoding and resource failures. +Plans survive review closure and browser reload without a new Session event or file. 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. diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md index 155280d94f..c84a20ca46 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`ui-plan` 从每次 `exit_plan_mode` 调用派生一张常驻 Chat 卡片,覆盖原生调用和 PTC dispatch。卡片声明 `process: 'independent'`,让 Chat 将其保留在回合运行过程折叠区之外。关闭、拒绝或批准审批都不会删除卡片。 +`ui-plan` 汇总每个回合的 `exit_plan_mode` 提交,覆盖原生调用和 PTC dispatch。每次调用在已完成回合末尾的产物区域贡献一张卡片。`conversation.chat.turnTail` 列表同时容纳文件产物和计划卡片。关闭、拒绝或批准审批都不会删除已记录的计划。 审批意图携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。两者都打开由会话和调用标识确定的资源。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 @@ -22,8 +22,8 @@ Status: implemented **写入 Markdown 文件或在侧边栏布局中保存正文。** 工具参数已经拥有准确的提交文本。另一份持久副本需要同步,且可能与审阅的计划不一致。[计划状态决策](../simplification/2026-07-22-plan-specific-collaboration-state.zh.md)仍是这项所有权的权威记录。 -**让 Chat 识别计划工具名称。** 节点自行声明折叠行为,让功能标识需要独立阅读的产物,无需向 Chat 添加计划专用渲染或导入。 +**只选择一个回合末尾区域的属主。** 计划和文件产物可能出现在同一回合。可追加列表让两个插件都能贡献内容,无需彼此了解数据或渲染。 ## Consequences -计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖原生与 PTC 解码及资源读取失败。 +计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。 diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index ec9b195a36..43c0080534 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -100,7 +100,7 @@ describe('web e2e: plan review takeover round trip', () => { } const planCard = page.locator('[data-plan-card]') - await planCard.waitFor({ state: 'visible' }) + 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) @@ -112,7 +112,7 @@ describe('web e2e: plan review takeover round trip', () => { } await page.locator('[data-sidebar-right-toggle]').click() await preview.waitFor({ state: 'hidden' }) - await planCard.click() + await card.getByRole('button', { name: 'Open plan in sidebar' }).click() await preview.waitFor({ state: 'visible' }) await card.getByRole('button', { name: 'Approve' }).click() @@ -134,9 +134,9 @@ 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) - // The completed Turn collapses its process; the artifact remains on the main line. + // 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-process-member]').count()).toBe(0) + 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' }) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 781dfd899c..2b127cdf1b 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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-chat/README.md -README.md: 0a3f5cad6e0bcd2da6f8ff993dea2e32a3e7e4a6 -README.zh.md: 028467df8ec75154f585fd7d928c51436e8b82c6 +README.md: 9217ff678690f6eb659bb6b7c3a808d97a26171b +README.zh.md: 1ac5159a466c61ddada5b55e62354d6bfb1e6652 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 0a3f5cad6e..9217ff6786 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -91,8 +91,6 @@ None; Chat presentation does not assemble or mutate provider requests. None. -A feature’s Chat Node may declare `process: 'independent'` to keep a user-facing artifact visible when the surrounding Turn process collapses; submitted plans use this declaration. - **Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 028467df8e..1ac5159a46 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -93,8 +93,6 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有 无。 -功能的 Chat Node 可以声明 `process: 'independent'`,让面向用户的产物在所属回合运行过程折叠时仍保持可见;已提交的计划使用这一声明。 - **运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。 diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index b852f8989c..40e08dbaf7 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -68,7 +68,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ && !historyIncomplete const processMember = routedNode !== undefined && processWindowReady - && routedNode.process !== 'independent' && !TURN_PROCESS_INDEPENDENT_KINDS.has(routedNode.kind) && routedNode.anchorSeq >= processSpec.processStartSeq && routedNode.anchorSeq < processSpec.answerAnchorSeq diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index 36c76b7c26..20399ffe9c 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -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 :
    {tail}
    + const tail = renderSlot('conversation.chat.turnTail', owner) + if (closing === null) return tail === null ? null :
    {tail}
    const runMs = turn.start === undefined || turn.end === undefined ? undefined : Math.max(0, turn.end.time - turn.start.time) diff --git a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts index e58ec4a9ad..79b35fd85a 100644 --- a/packages/client/ui-chat/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-chat/src/client/chat/register-node-renderers.ts @@ -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)) diff --git a/packages/client/ui-chat/src/client/contract/chat-nodes.ts b/packages/client/ui-chat/src/client/contract/chat-nodes.ts index 3b9152ecf8..d04bdfd90e 100644 --- a/packages/client/ui-chat/src/client/contract/chat-nodes.ts +++ b/packages/client/ui-chat/src/client/contract/chat-nodes.ts @@ -10,8 +10,6 @@ export interface ChatConversationViewNode extends ConversationViewNode { readonly anchorSeq: number readonly location: ConversationLocation readonly visibility: 'visible' | 'hidden' - /** Keep a user-facing artifact outside the Turn process disclosure. */ - readonly process?: 'independent' } /** Merge-extensible payload registry keyed by final Chat renderer kind. */ diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index fa02cc4092..3f8e92cafc 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -204,11 +204,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 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts index 02d3aa0ba2..a49ae6031b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/turn-process-presentation.ts @@ -54,7 +54,7 @@ function derivePresentation( && (spec.answerAnchorSeq === null || node.anchorSeq < spec.answerAnchorSeq)) { compactAnswer = false } - if (node.process === 'independent' || TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) + if (TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind) || node.anchorSeq < spec.processStartSeq || (spec.answerAnchorSeq !== null && node.anchorSeq >= spec.answerAnchorSeq)) continue if (node.kind !== 'assistant-step' || spec.answerStep === null || node.data.step !== spec.answerStep) { diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 8a63203668..c35ba357e3 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -282,8 +282,6 @@ function makeHarness( }> = [] const renderCommandSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => opts?.fallback ?? null) as unknown as React.ComponentProps['renderSlot'] - const renderTurnTail = ((_key: string, _owner: object) => null) as unknown as - React.ComponentProps['renderSlotChain'] const renderTurnTailSlot = (() => null) as unknown as React.ComponentProps['renderSlot'] let nodeSlotOverride: React.ComponentProps['renderSlot'] | undefined @@ -335,7 +333,6 @@ function makeHarness( ()} renderSlot={renderTurnTailSlot} - renderSlotChain={renderTurnTail} SessionProvider={props.SessionProvider} /> ) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index bcec6615fe..2606b218ce 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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: b0137675a207a7aaee3d38060319d5948c8ba432 -README.zh.md: 8dfc6767919ae186962d49da393ef8316f62c417 +README.md: 125c84c91553b93e97f9158c9429014051804ed9 +README.zh.md: ef5b7e97f90f1101e765c776a0c1a90dfe7c1319 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index b0137675a2..125c84c915 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -54,7 +54,7 @@ The closing prose links produced or delivered paths: an inline-code token resolv
    Implementation internals — click to expand -The Node half registers the static `ui:deliverable-file-references` system-prompt section asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. 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///` 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 asking the model to mention primary files from successful creation or modification calls and to write those and any other changed-file references as Markdown inline code. 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///` 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. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 8dfc676791..ef5b7e97f9 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -54,7 +54,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交
    实现细节——点击展开 -Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 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///`,并以文件下标作为 `changes-review` 的导航参数;本包在 `builtin` 档为该模式注册 `changes-review` tab 类型,把其 body 连同一个按 tab 保存选择的独占 store 注册到按键的 `sidebar.right.pane.tab` 座位下,body 通过经过认证的路由把摘要和对比读进连接更换时清空的 store。本包还提供 chat 视图按收尾消息查询的 `chatFileMentions` 服务;把插件组合出去会移除全部表面,视图的空链以零成本留下。 +Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段,要求模型点名成功创建或修改的主要文件,并把这些文件以及正文中提到的其他本轮变更文件写成 Markdown 行内代码。浏览器半部把组合改动文件卡片与显式交付的包装组件注册进 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///`,并以文件下标作为 `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 半部注册静态 `ui:deliverable-file-references` 系统提示词段, 当产出物面不够用时阅读以下页面。它们从卡片进入 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)——收尾正文可点击提及背后的决策。 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index a7ad63dc03..112bc68070 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -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 & InjectFace) { + const matched = selectDeliverables(props) + return matched === null ? null : +} + /** * Render the changed-files card, once the Host has served the announced * summary and it lists a file, and default-application buttons for declared diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index c9167d2605..76cecc33bc 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -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, diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index cedb8a2d04..8cff6f2850 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -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() 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[0] + const view = render() + expect(view.container.innerHTML).toBe('') + const withFile = tailOwner({ produced: [], presented: [{ path: 'report.md', seq: 2, index: 0 }] }, 3) + view.rerender() + expect(view.getByText('report.md')).toBeTruthy() +}) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index a1cf0d1cf8..ddf0e0e982 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -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: 7f68df2d44e6e540a559e4769b7550fc52e5cfae -README.zh.md: ac9977aa72787b73fb84f46a0e9f0f9515e72ad5 +README.md: e0a0949ce4b09b4a4b87455b424b85094625d661 +README.zh.md: 2d1fc1654618abe75975645301b86588a7e9532c diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 7f68df2d44..e0a0949ce4 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -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 their permanent Chat cards after approval, rejection, or dismissal. Reopening a plan focuses its existing tab, and browser reload restores the document from Session history. +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 @@ -33,7 +33,7 @@ While the effective target is plan mode, the seat renders the warn-colored "Plan ### Reading submitted plans -Each submitted plan has a permanent Chat card outside the Turn process disclosure, 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 Open plan in sidebar button to read and copy the complete Markdown. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin. +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 Open plan in sidebar button to read and copy the complete Markdown. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin. ### Failures @@ -49,7 +49,7 @@ 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. The plan resource address identifies the Session and invocation; 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 the caller’s invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. +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 Session and invocation; 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 the caller’s invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate.
    diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index ac9977aa72..2d1fc16546 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出。提交的计划自动在右侧边栏打开供审阅;批准、拒绝或关闭审批后,仍可通过聊天历史中的常驻卡片查看。重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。 +计划模式让你在实施前审阅计划。通过 `/plan` 进入,通过编辑器中的状态按钮退出。提交的计划自动在右侧边栏打开供审阅;批准、拒绝或关闭审批后,仍可通过已完成回合末尾的产物卡片查看。重新打开同一计划会聚焦已有标签页,刷新浏览器后会从会话历史恢复正文。 ## 目录 @@ -33,7 +33,7 @@ kind: "package-reference" ### 查看已提交的计划 -每次提交的计划都有一张常驻聊天卡片,不随回合运行过程折叠,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“在侧边栏打开计划”按钮可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 +回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“在侧边栏打开计划”按钮可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 ### 失败 @@ -49,7 +49,7 @@ 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 参数派生。计划资源地址标识会话和调用;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供调用方的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识会话和调用;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供调用方的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。
    diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index b9e2c94eb8..f2ba382e3e 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -1,7 +1,9 @@ /** Persistent transcript card and pending-review action use the same plan resource opener. */ import { useEffect } from 'react' import { FileTypeIcon, IconFullscreenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' -import type {} from '@deepseek-ai/dsh-client-ui-chat/client' +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' @@ -15,22 +17,30 @@ export interface PlanOpenInjected { } /** - * Render the plan's permanent transcript entry. + * Render the completed Turn's submitted plans in invocation order. * @param props - Logged plan, localized copy, and Session-bound navigation. - * @returns a keyboard-accessible plan card. + * @returns keyboard-accessible plan cards, or null for a Turn without plans. */ -export function PlanCard({ node, openPlan, t }: PropsRuntime<'conversation.chat.node', 'submitted-plan'> & InjectFace & PropsLocale<'plan'>) { +export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversation.chat.turnTail'> & InjectFace & 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 ( - +
    + {plans.map(({ data: plan }) => )} +
    ) } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index 116dc40a62..f8762a6e88 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -1,3 +1,4 @@ +.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); @@ -9,7 +10,7 @@ width: 100%; height: 60px; padding: 8px 10px; - margin: 12px 0; + margin: 0; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 18px; background: var(--plan-card-fill); diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 8dfa535e1b..5b7998e794 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -15,7 +15,7 @@ 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 { PlanCard, PlanReviewOpen, type PlanOpenInjected } from './PlanCard.tsx' +import { PlanCards, PlanReviewOpen, type PlanOpenInjected } from './PlanCard.tsx' import { PlanPreview, PlanTitle } from './PlanPreview.tsx' import { planDefinition } from './plan-definition.ts' import { planResourceProvider } from './plan-resource.ts' @@ -68,9 +68,9 @@ export function apply(ctx: ClientContext): void { openPlan: (callId) => { ctx.sidebarRight.openResourceIn(sessionId, planAddress({ sessionId, callId })) }, }) const reviewStore = createPlanReviewStore() - ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ - name: 'conversation.chat.node', key: 'submitted-plan', locale: NS, inject: open, - }, PlanCard)) + 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, inject: open, store: reviewStore, }, PlanReviewOpen)) diff --git a/packages/client/ui-plan/src/client/plan-definition.ts b/packages/client/ui-plan/src/client/plan-definition.ts index d3f1600509..8c742f71fa 100644 --- a/packages/client/ui-plan/src/client/plan-definition.ts +++ b/packages/client/ui-plan/src/client/plan-definition.ts @@ -1,4 +1,4 @@ -/** Durable plan cards remain readable independently of a Turn's process disclosure. */ +/** 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' @@ -28,7 +28,7 @@ export const planDefinition: ConversationNodeDefinition = { return { key: context.key, kind: 'submitted-plan', id: context.id, target: 'chat', anchorSeq: start.event.seq, location: start.location, - visibility: 'visible', process: 'independent', data, + visibility: 'hidden', data, } satisfies ChatNode<'submitted-plan'> }, } diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index cedb9cabf5..bd83cb2e87 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -13,7 +13,7 @@ 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 { PlanCard, PlanReviewOpen, type PlanOpenInjected } from '../src/client/PlanCard.tsx' +import { PlanCards, PlanReviewOpen, type PlanOpenInjected } 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' @@ -45,7 +45,7 @@ async function bench() { name: 'root', children: { 'conversation.input.plan': { kind: 'single', scope: 'session' }, - 'conversation.chat.node': { kind: 'keyed', 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' }, @@ -121,13 +121,16 @@ describe('ui-plan browser apply', () => { 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"}' } })! - for (const [slot, component] of [['conversation.chat.node', PlanCard], ['conversation.plan-review.actions', PlanReviewOpen]] as const) { + for (const [slot, component] of [['conversation.chat.turnTail', PlanCards], ['conversation.plan-review.actions', PlanReviewOpen]] as const) { const entry = b.slots.entries(slot)[0]! expect(entry.component).toBe(component) const injected = (entry.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID) @@ -137,7 +140,7 @@ describe('ui-plan browser apply', () => { 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.node')).toEqual([]) + 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([]) diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index 83d28fb835..c907f83ef9 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -1,11 +1,15 @@ // @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 { PlanCard, PlanReviewOpen } from '../src/client/PlanCard.tsx' +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 { planDefinition } from '../src/client/plan-definition.ts' @@ -13,11 +17,18 @@ 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', data: { name: 'exit_plan_mode', callId: 'call:1', arguments: JSON.stringify({ plan: markdown }) } } +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 = { 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[0]['useChat'] { + return ((select: (snapshot: unknown) => unknown) => select({ nodes: { values: () => nodes } })) as Parameters[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 }) @@ -50,7 +61,7 @@ describe('submitted plan identity', () => { 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({ process: 'independent', anchorSeq: 12, data: plan }) + 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', () => { @@ -63,12 +74,55 @@ describe('submitted plan identity', () => { }) }) +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[0] + const view = render() + expect(view.container.querySelectorAll('[data-plan-card]')).toHaveLength(2) + view.rerender([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 = { node: { data: plan }, t: makeTranslate(dictionary, commonEn), openPlan } as unknown as Parameters[0] - const view = render() + const props = { turn: { turn: 1 }, useChat: planHook([planNode(plan)]), seq: 30, + t: makeTranslate(dictionary, commonEn), openPlan, + } as unknown as Parameters[0] + const view = render() expect(openPlan).not.toHaveBeenCalled() expect(screen.getByText(dictionary['preview.action'])).toBeTruthy() expect(screen.queryByText('Implement')).toBeNull() @@ -78,6 +132,28 @@ describe('plan entry points and document', () => { 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[0] + const view = render() + 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() + expect(view.container.innerHTML).toBe('') + }) it('opens the review without approving or cancelling it', () => { const openPlan = vi.fn() const store = createPlanReviewStore().create() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index ea5e03daa5..f1f3bbe619 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -268,7 +268,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'useProjection: UseProjection', 'useTrajectory: UseTrajectory', ], - keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, submitted-plan, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', + keyDomain: 'fixed by the owner\'s key table { [Kind in ChatNodeKind]: { node: ChatNode } }, already taken: assistant-step, command, command-input, compaction, context, manual-compaction, model-retry, steering, system-prompt, tool-call, turn-error, turn-max-tokens, turn-process, turn-tail, unknown, user, workflow-run', hookContext: 'ConversationLocationDataStore | undefined', slotInject: 'ChatNodeTurnDataInjected', declaredBy: 'an entry in \'conversation.view\' (client-ui-chat), so it exists while that entry is mounted', @@ -288,7 +288,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-chat TurnTailNodeView key \'turn-tail\'', 'client-ui-chat UnknownNodeView key \'unknown\'', 'client-ui-goal GoalCommandInputView key \'command-input\'', - 'client-ui-plan PlanCard key \'submitted-plan\'', 'client-ui-tool ToolCallTree key \'tool-call\'', 'client-ui-workflow-run WorkflowRunPanel key \'workflow-run\'', ], @@ -298,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: [ @@ -338,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:211', }, { From 11637ac48ec03dc44db84db9285425000e10ab78 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 15:24:11 +0800 Subject: [PATCH 042/225] test(web): place plan cards after the completed reply --- snapshots/web/plan-review/approved-expanded.expected.md | 2 +- snapshots/web/plan-review/approved.expected.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 55f4556dc9..9114b312b6 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -32,7 +32,6 @@ - text: Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with - code: exit_plan_mode - text: . -- 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - 'button "Tool call exit_plan_mode · # Add `--greeting` flag to CLI"': - img - img @@ -46,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": diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 6056dec33f..96557e3e34 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -20,12 +20,12 @@ - button "1 tool call · 1 message": - text: 1 tool call · 1 message - img -- 'button "Open plan: Add `--greeting` flag to CLI"': "Add `--greeting` flag to CLI Plan · Markdown Open" - button "System prompt": - img - 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": From 7de3757badcc8742725318fb02c806a8802901b1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 15:37:13 +0800 Subject: [PATCH 043/225] feat(web): show a concise plan summary in the review card --- ...2026-09-17-persistent-plan-cards.i18n.yaml | 4 +- .../2026-09-17-persistent-plan-cards.md | 2 +- .../2026-09-17-persistent-plan-cards.zh.md | 2 +- apps/web/tests/plan-review.e2e.ts | 6 ++- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- .../client/ui-plan/src/client/PlanCard.tsx | 6 +-- .../ui-plan/src/client/PlanPreview.module.css | 3 ++ packages/client/ui-plan/src/client/locales.ts | 2 + .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 6 +-- .../client/ui-user-questions/README.zh.md | 6 +-- .../src/client/PlanReviewPanel.module.css | 46 +++++++++++++------ .../src/client/PlanReviewPanel.tsx | 24 +++++----- .../src/client/contract/slots.ts | 14 ++---- .../ui-user-questions/src/client/locales.ts | 6 +-- .../tests/plan-review-panel.client.spec.tsx | 40 ++++++++++------ 18 files changed, 106 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml index d0edb29ca6..bf06dd8eed 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md -2026-09-17-persistent-plan-cards.md: dabf5c970f8cf77fb2f4ff5cedcc11e2a094751f -2026-09-17-persistent-plan-cards.zh.md: c84a20ca464ac69206f008b99e5039cd0791a2ca +2026-09-17-persistent-plan-cards.md: 53d0ca028a08faacec8f967c002fb525e50c33ac +2026-09-17-persistent-plan-cards.zh.md: cfc2a67c17a5b4ed76a7cb9df49b6fdfb15e3c71 diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md index dabf5c970f..53d0ca028a 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -14,7 +14,7 @@ A plan review occupies the composer only until the user answers or dismisses it. The review intent carries the tool-call identity. The question plugin declares an action slot, and the plan plugin contributes an opener there and on the historical card. Both open the resource identified by Session and invocation. 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. -The document is read-only. The pending review shows its status, sidebar opener, and decision buttons; the complete text opens automatically in the sidebar. A transient Session-scoped store records each automatically opened invocation, 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. 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. +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, 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 diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md index c84a20ca46..cfc2a67c17 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -14,7 +14,7 @@ Status: implemented 审批意图携带工具调用标识。提问插件声明操作插槽,计划插件在该插槽和历史卡片中提供打开入口。两者都打开由会话和调用标识确定的资源。侧边栏布局保留该地址;资源 provider 读取已有会话历史,当调用早于初始窗口时向前分页。临时 follow 在取得初始快照后关闭。 -文档只读。待处理审批显示状态、侧边栏入口和决定按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。 +文档只读。待处理审批显示状态、“查看全文”链接、标题、两行纯文本摘要,以及“要求修改”和“同意执行”按钮;完整正文自动在侧边栏打开。临时的会话级 store 记录每个已自动打开的调用,让审批组件重新挂载时仍保留用户的关闭选择。历史卡片不会触发自动导航。卡片采用文件产物样式,包含 Markdown 图标、计划标题和“打开”操作。“要求修改”取消待处理等待并恢复编辑器,供用户输入反馈。审批仍由待处理提问的属主负责;重新打开历史文档不会恢复已结束的审批,也不会授权实施。即使标题相同,不同提交仍彼此独立。 ## Alternatives considered diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 43c0080534..6df0c8ef1d 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -85,7 +85,9 @@ 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(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"]') @@ -186,7 +188,7 @@ describe('web e2e: dismissed plan history', () => { 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: 'Chat about it', exact: true }).click() + await review.getByRole('button', { name: 'Request changes', exact: true }).click() await settled const results = events.filter(event => event.type === 'tool/result') expect(results.some(event => JSON.stringify(event).includes('dismissed the plan review'))).toBe(true) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index ddf0e0e982..02e0e2458c 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -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: e0a0949ce4b09b4a4b87455b424b85094625d661 -README.zh.md: 2d1fc1654618abe75975645301b86588a7e9532c +README.md: 86529ad22d4849a6b3530828c7939a1a73579c16 +README.zh.md: bad2e8a847b4d8ea1b80eab498ee9d1b377f7b2a diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index e0a0949ce4..86529ad22d 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -33,7 +33,7 @@ While the effective target is plan mode, the seat renders the warn-colored "Plan ### 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 Open plan in sidebar button to read and copy the complete Markdown. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin. +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. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin. ### Failures diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 2d1fc16546..bad2e8a847 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 查看已提交的计划 -回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“在侧边栏打开计划”按钮可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 +回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 ### 失败 diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index f2ba382e3e..25e61f2356 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -1,6 +1,6 @@ /** Persistent transcript card and pending-review action use the same plan resource opener. */ import { useEffect } from 'react' -import { FileTypeIcon, IconFullscreenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +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' @@ -58,6 +58,6 @@ export function PlanReviewOpen({ review, openPlan, t, useStore, actions }: Props openPlan(callId) }, [callId, opened, openPlan, actions]) if (callId === undefined) return null - return + return } diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index f8762a6e88..f320f6af77 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -28,6 +28,9 @@ .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; } .reviewOpen { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } .reviewOpen:hover { background: var(--dsw-alias-interactive-bg-hover); } .preview { position: relative; height: 100%; overflow: auto; padding: 20px 24px 40px; box-sizing: border-box; } diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 7581602beb..bf5fa70b18 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -7,6 +7,7 @@ export const zh = { 'preview.document': '计划 · Markdown', 'preview.action': '打开', 'preview.open': '在侧边栏打开计划', + 'preview.full': '查看全文', 'preview.openNamed': '打开计划:{title}', 'preview.loading': '正在读取计划…', 'preview.failed': '无法读取计划', @@ -27,6 +28,7 @@ export const en = { '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', diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index 9c6e7038fd..a6985f3998 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -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: b6604aced9c5732ca302f93610c4fd313498397d -README.zh.md: 197427d337234eba44e1949de72d383fba1c3553 +README.md: a734be6a2c9464d5dc6c8696f5d7287eaf6d9d0d +README.zh.md: 38c62a969cc4440a690969a12400511489c0495a diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index b6604aced9..a734be6a2c 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -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 a compact waiting-approval card: a `Plan review` strip with the sidebar opener and one decision row of `Chat about it` / `Refuse` / `Approve`. The complete plan is read in the sidebar through this opener or its permanent Chat card. 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,7 +51,7 @@ 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 diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 197427d337..38c62a969c 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -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` 条带,以及一行 `Chat about it` / `Refuse` / `Approve` 的决定操作。通过该入口或聊天历史中的常驻卡片,在侧边栏阅读完整计划。Approve 与 Refuse 用提问方自己的选项标签回答;`Chat about it` 以 `ASK_CANCELLED` 拒绝该等待,让编辑器归位,用户可以直接说出他想说的话。 +`plan-review` 意图——由 `dsh-plan-mode` 在 `exit_plan_mode` 审阅上设置——渲染紧凑审批卡片:顶部为带“查看全文”链接的“计划待审”条带,正文显示计划标题和两行纯文本摘要,底部提供“要求修改”和“同意执行”。通过链接或聊天历史中的常驻卡片可在侧边栏阅读完整计划。“同意执行”用提问方的批准标签回答;“要求修改”以 `ASK_CANCELLED` 拒绝等待,让编辑器归位供用户输入反馈,不会提交批准。卡片不再单列拒绝按钮。 ### 失败与恢复 @@ -51,7 +51,7 @@ kind: "package-reference" ### 意图界面选择 -卡片只在能够发出该请求允许的每一个答案时才接管:只有一个问题、声明了意图、计划以 `detail` 存在、提供了被指名的批准标签,且是二元单选(除批准外最多一个选项,且非多选)。其他任何情形都留在能够表达它的通用流程上。意图改变的只是布局,从不改变可达的答案。 +卡片接管声明了意图、以 `detail` 携带计划、提供了被指名的批准标签的单个问题,要求除批准外最多一个选项,且非多选。次要操作返回编辑器供用户提出修改要求。更多选项或多选问题仍由通用流程处理。 ### 文案与 locale diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css index 7222c69107..225496c2d5 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css @@ -12,10 +12,11 @@ flex-direction: column; width: 100%; max-width: var(--dsh-chat-content-width); - 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); } @@ -30,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 { @@ -43,6 +44,7 @@ border-radius: 50%; corner-shape: round; background: var(--dsw-alias-state-warn-primary); + box-shadow: 0 0 0 3px var(--dsw-alias-state-warn-secondary); } .footer { @@ -68,16 +70,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) { diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx index 43a70e5eb6..9ae298a0e7 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react' -import { Button, IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { useMemo, useState } from 'react' +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' @@ -40,7 +40,11 @@ export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPa 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 (
    @@ -52,23 +56,19 @@ export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPa {renderSlot('conversation.plan-review.actions', { review })}
    +
    +

    {summary.title}

    + {summary.description !== '' &&

    {summary.description}

    } +
    {error}
    - {decline !== undefined && ( - - )} + const loadingThumbnail = variant === 'thumbnail' && src === null + if (error) return ( + + ) return ( <> {open && src !== null && } diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index e0fec746aa..ee14237ad5 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -2,7 +2,7 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { EMPTY_CHAT_SNAPSHOT, type MessageImagesProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { EMPTY_CONVERSATION_SNAPSHOT } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -151,6 +151,33 @@ describe('MessageImage', () => { expect(retry.getAttribute('data-variant')).toBe(variant) }) + it.each([ + ['Chinese', labels], + ['English', { ...labels, loading: 'Loading image…', loadFailed: 'Image failed to load; click to retry' }], + ] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => { + const pending = Promise.withResolvers() + const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') + const view = render() + const loading = view.getByRole('button', { name: statusLabels.loading }) + expect(loading.getAttribute('title')).toBe(statusLabels.loading) + expect(loading.getAttribute('aria-busy')).toBe('true') + expect(loading.textContent).toBe('') + expect(loading.querySelector('svg')).not.toBeNull() + + await act(async () => { pending.reject(new Error('offline')) }) + const retry = view.getByRole('button', { name: statusLabels.loadFailed }) + expect(retry.getAttribute('title')).toBe(statusLabels.loadFailed) + expect(retry.textContent).toBe('') + expect(retry.querySelector('svg')).not.toBeNull() + fireEvent.click(retry) + await view.findByAltText(attachment.name) + const opener = view.getByRole('button', { name: statusLabels.openNamed(attachment.name) }) + expect(opener.getAttribute('aria-busy')).toBeNull() + expect(opener.getAttribute('title')).toBe(statusLabels.open) + expect(load).toHaveBeenCalledTimes(2) + expect(load).toHaveBeenLastCalledWith(attachment) + }) + it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => { const { name: _name, ...unnamed } = attachment const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail') diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 432ba0653d..e99b96a962 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -122,15 +122,13 @@ function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< | 'timeSeconds' | 'startedAt' > { - // An empty text block yields an empty preview; treat it as absent so an - // image-bearing record still labels its row instead of rendering blank. const preview = previewContent(node.content) const previewMarkdown = preview === '' ? undefined : preview const images = imageBlockCount(node.content) const files = fileBlockCount(node.content) const attachmentSummary = [ images > 0 - ? t('layout.imageOnly', { count: images }) + ? t('layout.imageCount', { count: images }) : undefined, files > 0 ? t('layout.fileAttachments', { count: files }) : undefined, ].filter((value): value is string => value !== undefined).join(' · ') @@ -808,7 +806,7 @@ function summarizeAssistantActivity( return t('layout.toolCallOnly') } const images = blocks.filter(block => block.kind === 'image').length - if (images > 0) return t('layout.imageOnly', { count: images }) + if (images > 0) return t('layout.imageCount', { count: images }) return '' } @@ -1089,7 +1087,7 @@ function summarizeResult( } } const images = imageBlockCount(node.content) - if (images > 0) return { result: t('layout.imageOnly', { count: images }) } + if (images > 0) return { result: t('layout.imageCount', { count: images }) } return { result: t('record.noOutput') } } @@ -1116,7 +1114,7 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { .join('\n') if (text !== '') return text const images = imageBlockCount(node.content) - if (images > 0) return t('layout.imageOnly', { count: images }) + if (images > 0) return t('layout.imageCount', { count: images }) if ( node.content.length === 0 || node.content.every(block => diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 80ddf8fe7d..2e891ff981 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -182,7 +182,7 @@ export const zh = { 'layout.toolCallOnly': '仅工具调用', 'attachment.list': '附件', 'attachment.imageName': '图片 {index}', - 'layout.imageOnly': '图片 ×{count}', + 'layout.imageCount': '图片 ×{count}', 'layout.fileAttachments': '文件 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', @@ -384,7 +384,7 @@ export const en: Record = { 'layout.toolCallOnly': 'Tool call only', 'attachment.list': 'Attachments', 'attachment.imageName': 'Image {index}', - 'layout.imageOnly': 'Images ×{count}', + 'layout.imageCount': 'Images ×{count}', 'layout.fileAttachments': 'Files ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', diff --git a/snapshots/web/file-upload-round/trajectory-status.expected.md b/snapshots/web/file-upload-round/trajectory-status.expected.md new file mode 100644 index 0000000000..585455d2e2 --- /dev/null +++ b/snapshots/web/file-upload-round/trajectory-status.expected.md @@ -0,0 +1,15 @@ +# Loading + +- list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "Loading image…" + - text: reference-1.png image/png · 69B · 1 × 1 + +# Failed + +- list "Attachments": + - listitem: poem.txt TXT · 16B + - listitem: + - button "Image failed to load; click to retry" + - text: reference-1.png image/png · 69B · 1 × 1 From 9836c4daafcb3643b520c72c31b2db8a213dd3b0 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 15:45:33 +0800 Subject: [PATCH 047/225] docs(web): describe trajectory thumbnail status feedback Refs #4356 --- .../2026-09-15-trajectory-attachment-presentation.i18n.yaml | 4 ++-- .../bug-fix/2026-09-15-trajectory-attachment-presentation.md | 2 +- .../2026-09-15-trajectory-attachment-presentation.zh.md | 2 +- packages/client/ui-attachment/README.i18n.yaml | 4 ++-- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml index 0254ff4b08..0a3a425300 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md -2026-09-15-trajectory-attachment-presentation.md: 4a1528a230c0ded38d26d0859a181ebaa208180d -2026-09-15-trajectory-attachment-presentation.zh.md: e6accddb292613d9688b66847b6d6834ad6636c6 +2026-09-15-trajectory-attachment-presentation.md: fdf22a74ed2794cdea08a9c98a0eb384f992c447 +2026-09-15-trajectory-attachment-presentation.zh.md: d7088cd80dcf3eccde2abcccfff3eaaf89393ac2 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md index 4a1528a230..fdf22a74ed 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -17,7 +17,7 @@ Trajectory presents every attachment occurrence while keeping visual inspection | Ledger | Separate nonzero image and ordinary-file counts accompany the text excerpt, including messages without text. Each record stays on one compact line. | | Summary and Preview | Message text precedes one ordered attachment list. Both tabs use the same renderer; Summary retains its compact spacing and scroll container. | | Attachment list | Equal-width compact rows share spacing and corners. A file-type icon or contained image thumbnail accompanies the recorded name and available metadata. Repeated references retain their occurrence order. | -| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access, loading feedback, and retry after a failed read. | +| Image interaction | The complete image fits inside a 48px square thumbnail without cropping. Activation opens the existing image viewer, with keyboard access. Loading and retry icons fit the thumbnail and expose localized tooltips and accessible names. | | Raw | Image and file blocks use initially collapsed metadata disclosures with complete recorded fields. Original content-block order and unrendered text remain intact; generated display labels do not enter raw fields. | Recorded filenames take precedence over localized numbered image labels. Metadata comes from recorded fields or the filename extension; zero-byte sizes remain visible. Visually truncated names retain their complete accessible text and tooltip. Messages without text still expose all attachments inside the inspector's scroll area. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md index e6accddb29..d7088cd80d 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -17,7 +17,7 @@ Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已 | 列表 | 文本摘要旁分别显示非零的图片数量与普通文件数量,无正文的消息也显示计数。每条记录保持为紧凑单行。 | | 概述与预览 | 消息正文之后展示一个有序附件列表。两个标签页使用相同的渲染器;概述保留紧凑间距和滚动容器。 | | 附件列表 | 等宽的紧凑行使用一致的间距和圆角。文件类型图标或完整缩放的图片缩略图旁展示已记录的名称与可用元数据。重复引用保留各次出现的顺序。 | -| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问、加载反馈和读取失败后的重试。 | +| 图片交互 | 图片完整缩放到 48px 方形缩略图内,不裁剪。激活时打开现有图片查看器,保留键盘访问。加载与重试图标适配缩略图尺寸,并提供本地化的工具提示和可访问名称。 | | 原始内容 | 图片块和文件块使用默认收起的元数据折叠区,包含完整记录字段。原始内容块顺序与未渲染文本保持完整;生成的显示标签不进入原始字段。 | 已记录文件名优先于本地化的编号图片标签。元数据来自记录字段或文件扩展名,零字节大小仍然可见。视觉上截断的名称保留完整的无障碍文本和工具提示。无正文的消息仍在详情面板滚动区域内展示全部附件。 diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index a8548bfafa..5ce716a2ae 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -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-attachment/README.md -README.md: 8272703a56d05295e3cb14f3441bce1eeafe1629 -README.zh.md: ae8d687448f947306830982a770f7f54e019361f +README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e +README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 8272703a56..0765068c73 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -35,7 +35,7 @@ Images and generic files retain pick order in one non-wrapping horizontal rail. In Chat, one user message presents files and images in a right-aligned wrapping flow that preserves source order. A lone image without another attachment renders at 240px on its longer edge (aspect clamped to [0.25, 4], never upscaled); when the message has more than one attachment, each image is a fixed 64px square beside 240×64px file cards. A loaded image opens the document-level lightbox on click; a failed load shows a retry control instead. The lightbox closes on Escape, a mask press, or its close control, and restores focus to its opener. -Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry controls keep the same box, and the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup. +Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup. ### Drop overlay diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index ae8d687448..5976acfda5 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行的排列中,并保持来源顺序。消息仅有一张图片且没有其他附件时,图片按长边 240px 渲染(宽高比钳制在 [0.25, 4],从不放大);消息有多个附件时,每张图片显示为固定 64px 方块,与 240×64px 文件卡同排。加载完成的图片单击打开文档级灯箱;加载失败则显示重试控件。灯箱按 Escape、按下遮罩或点关闭按钮关闭,并把焦点还给打开者。 -Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试控件保持相同尺寸,图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 +Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 ### 拖放遮罩 From eeb9b03465b942c59ae6219adb2479ff8f658c72 Mon Sep 17 00:00:00 2001 From: Yichen Yan <5031346+oraluben@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:35:21 +0800 Subject: [PATCH 048/225] feat(web): serve the shell, API and plugin resources from the document directory --- ...-24-browser-token-authentication.i18n.yaml | 4 +- ...2026-08-24-browser-token-authentication.md | 4 +- ...6-08-24-browser-token-authentication.zh.md | 4 +- ...web-document-relative-app-routes.i18n.yaml | 6 + ...-09-14-web-document-relative-app-routes.md | 47 ++++++ ...-14-web-document-relative-app-routes.zh.md | 47 ++++++ .../tests/web-failure-matrix.expected.e2e.ts | 2 +- apps/cli/tests/web-auth.e2e.ts | 5 +- apps/web/public/manifest.webmanifest | 7 +- apps/web/tests/assembled-boot.ts | 11 +- apps/web/tests/prefix-proxy.ts | 145 +++++++++++++++++ apps/web/tests/public-mount.e2e.ts | 128 +++++++++++++++ apps/web/tests/pwa-manifest.e2e.ts | 10 +- apps/web/tests/scaffold.ts | 85 ++++++++-- apps/web/tsconfig.json | 2 + docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 9 +- docs/subsystems/client-modules.zh.md | 9 +- .../api/gateway/src/client/stream-client.ts | 17 +- .../api/gateway/tests/gateway.client.spec.ts | 32 ++-- packages/api/remotes/tests/built-lib.e2e.ts | 150 ++++++++++-------- packages/bundle/web-app/README.i18n.yaml | 4 +- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/browser-auth.ts | 18 +-- packages/client/connection/src/client/rpc.ts | 18 +-- packages/client/connection/src/rpc.ts | 4 +- .../tests/browser-auth.host.spec.ts | 27 +++- .../tests/client-apply.client.spec.ts | 6 +- packages/client/hmr/src/client/index.ts | 5 +- packages/client/hmr/src/events.ts | 6 + .../client/hmr/tests/node-half.client.spec.ts | 2 +- packages/client/modules/README.i18n.yaml | 4 +- packages/client/modules/README.md | 2 +- packages/client/modules/README.zh.md | 2 +- .../client/modules/src/client/manifest.ts | 11 +- packages/client/modules/src/index.ts | 73 +++++++-- packages/client/modules/src/invariant.ts | 4 +- .../modules/tests/loader.client.spec.ts | 50 +++--- .../modules/tests/node-half.client.spec.ts | 66 +++++--- .../inspector/src/client/cdp/sources.ts | 15 +- .../inspector/tests/plugin.client.spec.ts | 2 +- .../webworker-runtime/src/client/client.ts | 3 +- .../tests/client/apply-injections.spec.ts | 4 +- .../tests/client/load-bundle.spec.ts | 10 +- .../host/frontend-static/README.i18n.yaml | 4 +- packages/host/frontend-static/README.md | 4 +- packages/host/frontend-static/README.zh.md | 4 +- packages/host/frontend-static/src/index.ts | 7 +- .../tests/frontend-static.spec.ts | 15 +- packages/host/webserver/src/injections.ts | 2 +- .../client-runtime/src/assembly/roster.ts | 12 +- .../tests/assembly-modules.client.spec.ts | 8 +- .../tests/assembly-roster.client.spec.ts | 12 +- tsconfig.host.json | 2 + 58 files changed, 866 insertions(+), 280 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md create mode 100644 .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md create mode 100644 apps/web/tests/prefix-proxy.ts create mode 100644 apps/web/tests/public-mount.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 88e2561815..af52bdcb23 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4 -2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f +2026-08-24-browser-token-authentication.md: 47d6afe1bf4cd8169eb4e8ec92a30bf0fee32c8a +2026-08-24-browser-token-authentication.zh.md: 13ecf55e41c7bb1455177bdd6a87a3a83d94ca6f diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index cdfed1f250..47d6afe1bf 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -12,9 +12,9 @@ The Web Host runs tool-capable Sessions with the current operating-system user's `dsh-client-connection` authenticates the complete Host API before dispatch. Every API Proxy method, Remote unary call, generic Connection channel, and Remote WebSocket stream requires the same browser session; endpoint ownership and method names do not alter authority. The existing Host/Origin checks run first and retain their DNS-rebinding and cross-site-request role, returning 403 when they fail. A trusted Host without a valid browser session receives 401. The browser-trust rules remain owned by the [carrier-level browser trust decision](2026-07-28-api-browser-trust-boundary.md). -Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `/`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `/`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. +Each Host process generates a random launch token, retained by the root application context across Connection hot reloads. `dsh-web-app` prints and opens the normal root URL with that token in the query once per process. `frontend-static` asks Connection to authorize index responses: only `GET /?token=...` exchanges the process token for a cookie, then redirects to clean `./`; the token is not accepted on API paths or in an Authorization header. An obsolete token paired with a valid cookie redirects to clean `./`. Missing and invalid credentials receive one minimal 401 response. Static non-index assets remain public. -The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation or reverse-proxy-specific handling. +The cookie is a signed, authority-bound bearer. Its deterministic name and signed payload both include the normalized hostname plus port, so one Harness home can run independent Web ports without cookie collisions. The payload carries safe-integer issue and expiry times under an absolute lifetime; `cookieMaxAgeDays` defaults to 30. The cookie is host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`. It omits `Secure` because the shipped server uses loopback HTTP. There is no logout operation. The HMAC secret is a versioned `grant` record at `client-connection/browser-session` in `ctx.credentials`; the local provider stores it in `$DSH_HOME/.credentials.yaml`. Connection loads or creates the record during activation and retains the secret for synchronous request verification. An active Connection continues using its loaded secret if the durable record changes; the next activation loads the replacement or creates a missing record, so deleting the record and restarting the process revokes every existing cookie. Invalid owner payloads fail loud instead of being replaced. The launch token itself is never persisted and changes on every process start, while an unexpired cookie remains valid across restarts on the same authority. diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index 29f3a6c0a2..13ecf55e41 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -12,9 +12,9 @@ Web Host 以当前操作系统用户的权限运行具有工具能力的 Session `dsh-client-connection` 在分发前认证完整 Host API。每个 API Proxy 方法、Remote 一元调用、通用 Connection channel 和 Remote WebSocket stream 都要求同一个浏览器会话;endpoint 所有权与方法名称不改变 authority。既有 Host/Origin 校验先执行,继续负责 DNS rebinding 和跨站请求防御,失败时返回 403。Host 可信但没有有效浏览器会话时返回 401。浏览器信任规则仍由[载体级浏览器信任决策](2026-07-28-api-browser-trust-boundary.zh.md)持有。 -每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `/`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `/`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 +每个 Host 进程生成随机启动令牌,并由应用根 context 跨 Connection 热重载保留。`dsh-web-app` 每个进程只打印并打开一次 query 中带该令牌的普通根 URL。`frontend-static` 请求 Connection 授权 index 响应:只有 `GET /?token=...` 会把进程令牌交换为 cookie,再重定向到干净的 `./`;API 路径和 Authorization header 都不接受该令牌。过时令牌如果同时带有有效 cookie,会重定向到干净的 `./`。缺失与无效凭据得到同一份最小 401 响应。非 index 静态资产保持公开。 -cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作或反向代理专用处理。 +cookie 是签名且绑定 authority 的 bearer。确定性名称与签名 payload 都包含规范化 hostname 和 port,因此同一 Harness home 可以在不同 Web port 运行而不发生 cookie 冲突。payload 在绝对有效期内携带安全整数形式的签发与过期时间;`cookieMaxAgeDays` 默认为 30。cookie 是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`。随附服务器使用 loopback HTTP,因此不设置 `Secure`。这里没有 logout 操作。 HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` 的版本化 `grant` 记录;本地提供方将其存入 `$DSH_HOME/.credentials.yaml`。Connection 在激活期间加载或创建该记录,并保留密钥以同步校验请求。持久记录发生变化后,当前 Connection 继续使用已加载的密钥;下一次激活会加载替换记录或创建缺失记录,因此删除记录并重启进程会撤销全部既有 cookie。无效 owner payload 会明确失败,而不是被覆盖。启动令牌本身绝不持久化并在每次进程启动时变化;未过期 cookie 则能在相同 authority 上跨重启继续有效。 diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml new file mode 100644 index 0000000000..8f06bf7290 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md +2026-09-14-web-document-relative-app-routes.md: fbf30253bbfcc23eb09847f93c1b1f047459d31b +2026-09-14-web-document-relative-app-routes.zh.md: c302ec88a4205239148132513d1ff2a4385033e8 diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md new file mode 100644 index 0000000000..fbf30253bb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md @@ -0,0 +1,47 @@ +# Agent Note: Web app-owned routes are document-relative + +Status: implemented + +English | [中文](2026-09-14-web-document-relative-app-routes.zh.md) + +## Problem + +Every browser reference to a shell-owned route was origin-root absolute: `/api/...` RPC, `/plugins/??...` plugin bundles, the Remote stream mux, the HMR event stream, and the launch entry's token-cleanup redirect to `/`. One listener served behind a prefix-stripping proxy (a mount such as `https://host/tools/dsh/` that forwards `/tools/dsh/...` as `/...`) therefore saw those requests at the origin root, where no route answers them: the shell, its plugin bundles, and its streams all missed. A single bundle has to serve both the origin root and any mount without a second build. + +The mount cannot be recovered from the transport. A configured base URL makes it deployment input, forwarded-prefix inference makes a request header authoritative for routing, and response rewriting in the proxy reimplements the same fact in fragments. The one place that already knows the mount is the served document itself. + +## Decision + +The served HTTP shell owns its document base. Its index carries exactly one ``, spliced in after the opening head tag once the index taps have run, so it precedes the boot manifest, every plugin-resource row, and any markup a tap inserted. The base freezes the entry directory: `/mount/` and `/mount/index.html` are the supported entries, and a document URL that later moves (an in-page `pushState`) does not move the base. Nothing infers a prefix, so deep direct loads and reloads are not entry points; a bare mount still needs the proxy's own normalization to its slash form. + +Browser references to those routes are relative to that document — `api/x`, `plugins/x` — while server route keys stay absolute pathnames: the RPC channel key `/api`, the `webServer` registration paths, and the response tables keyed by the combo route `/plugins/??...&rev=...`. A shared constant that names a key (`*_PATH`, `*_ENDPOINT`) is stripped at the boundary (`KEY.slice(1)`) before browser code uses it, and the browser form lives beside it as `*_ROUTE`. + +The Host composes browser references in one place per producer. Module graph rows and batch descriptors carry the document-relative combo reference; the source-map trailer carries the combo query alone, because a script's source-map reference resolves against that script's own directory (`.../plugins/`) rather than the document. The response table stays keyed by the absolute route, which is also what a request reaches after the proxy strips its prefix. + +The launch entry follows the same rule. `authenticatedUrl` adds the process token to the caller's URL and keeps its authority and mount, and the token exchange redirects to `./`, which removes the query token while preserving the request URL's directory. The Host/Origin fence and the cookie rules are unchanged. + +Consumers that need an absolute URL resolve against `document.baseURI`: the Gateway when it constructs its WebSocket URL and selects `ws:` or `wss:`, and the Inspector when it locates its own bundle from the boot graph. + +Desktop is the other document owner. Its window loads `dsh-app://app/`, an origin that serves the same dist at that root and forwards every app-owned path to the shell-owned Host, so its document directory already is the root and no base row is injected for it; it names that Host origin for the Gateway through the transport's `streamBaseUrl` instead. Its resources live at that root, which is also why the served HTTP shell's `./` does not apply to it. + +## Alternatives considered + +**A configured base URL as the routing base (`publicUrl`).** Rejected: the document already knows its own URL, so this adds a second, drift-prone source for a fact the page can read, and it would have to be plumbed to every consumer instead of one shell decision. The advertised public URL remains an operator-facing print/open input, not a routing input. + +**Inferring the mount from a forwarded-prefix header.** Rejected: it makes a request header authoritative for routing, so any client that sets it could move app-owned routes, and the proxy contract becomes server-visible configuration. + +**A shared `Connection.resolveUrl` or routes base.** The HTTP carrier's document base and the worker tunnel's Host-root mapping differ. Resolving every target in Connection would prepend a static preview path to worker-local routes or lose a served mount. Each carrier resolves its own targets; UI resource consumers use the document base. + +**An absolute `/` redirect with the proxy rewriting `Location`.** Rejected: the browser already knows the request's directory, and a configured redirect target cannot be right for both the loopback entry and a proxy mount. + +## Consequences + +One bundle serves the origin root and any mount, and the served document is the single source of the mount. The costs are explicit: the supported entries are only the dist root and the configured index path, a bare mount depends on the proxy's slash normalization, and a reload of a deep in-page path re-enters at that path rather than the frozen one. + +Routes owned by feature packages (open-in-app, deliverables, session-log export, uploads, markdown media) follow the same rule through their own `*_ROUTE` constants; the static gate that keeps browser sources on this rule is recorded with those changes. + +## Testing + +The frontend-static real-composition test serves the index through the Loader and asserts one `` that precedes an injected plugin-resource row and tap markup. The client-modules node-half suite asserts that graph rows, batch descriptors, and the source-map trailer are document-relative while the response table still serves them by route, and the client test roster plus the assembled-boot fixture carry the same relative form. `browser-auth.host.spec.ts` covers the `./` redirect and the authority- and mount-preserving `authenticatedUrl`; `frontend-static.spec.ts` and `apps/cli/tests/web-auth.e2e.ts` run the exchange through a real Loader composition and the built CLI. + +`apps/web/tests/public-mount.e2e.ts` is the browser proof: a real Chromium reaches one listener through the prefix-stripping proxy, the mounted WebSocket streams both ways, a `pushState` to a deeper path leaves `document.baseURI` at the mount, and a document-relative `fetch('api/session/create')` still succeeds beneath it. diff --git a/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md new file mode 100644 index 0000000000..c302ec88a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.zh.md @@ -0,0 +1,47 @@ +# Agent Note: Web 应用自有路由相对文档寻址 + +Status: implemented + +[English](2026-09-14-web-document-relative-app-routes.md) | 中文 + +## 问题 + +浏览器对外壳自有路由的每一处引用都以源站根绝对形式书写:`/api/...` RPC、`/plugins/??...` 插件 bundle、Remote 流 mux、HMR 事件流,以及启动入口清理 token 时重定向到的 `/`。当一个监听端口位于剥离前缀的代理之后(例如 `https://host/tools/dsh/` 这样的挂载,把 `/tools/dsh/...` 转发为 `/...`),这些请求会落在源站根,那里没有任何路由应答:外壳、插件 bundle、流全部落空。同一份 bundle 必须同时服务源站根与任一挂载,而不能为此再构建一次。 + +挂载点无法从传输层恢复。配置 base URL 会把它变成部署输入,转发前缀推断会让请求头对路由具有权威性,代理内改写在碎片里重复同一事实。唯一已经知道挂载点的地方,就是所服务的文档自身。 + +## 决策 + +所服务的 HTTP 外壳拥有自己的文档 base。其 index 携带唯一的 ``,在原始 index 转换执行后拼接于起始 head 标签之后,因此它先于启动 manifest、每一行插件资源,以及任何转换插入的标记。该 base 冻结入口目录:`/mount/` 与 `/mount/index.html` 是受支持的入口,而文档 URL 之后移动(页面内 `pushState`)不会移动 base。系统不推断任何前缀,因此深层直接加载与重新加载不是入口;裸挂载仍需依赖代理自身的规范化补齐尾部斜杠。 + +浏览器对这些路由的引用相对该文档——`api/x`、`plugins/x`——而服务端路由键保持绝对路径名:RPC channel 键 `/api`、`webServer` 注册路径,以及以 combo 路由 `/plugins/??...&rev=...` 为键的响应表。命名路由键的共享常量(`*_PATH`、`*_ENDPOINT`)在边界处被剥去前导斜杠(`KEY.slice(1)`)后浏览器代码才使用,浏览器形式以 `*_ROUTE` 与之并列。 + +宿主在每个生产者里只在一处组合浏览器引用。模块启动图 row 与批次描述符携带文档相对的 combo 引用;source-map trailer 只携带 combo 查询,因为脚本的 source-map 引用相对该脚本自身目录(`.../plugins/`)解析,而非相对文档。响应表仍以绝对路由为键,而请求在代理剥去前缀后到达的也正是该路由。 + +启动入口遵循同一规则。`authenticatedUrl` 把进程 token 加到调用方的 URL 上并保留其 authority 与挂载;token 交换重定向到 `./`,移除 query token 的同时保留请求 URL 的目录。Host/Origin 栅栏与 cookie 规则不变。 + +需要绝对 URL 的消费方相对 `document.baseURI` 解析:Gateway 构造 WebSocket URL 并选择 `ws:` 或 `wss:` 时,以及 Inspector 从启动图定位自身 bundle 时。 + +Desktop 是另一个文档所有者。它的窗口加载 `dsh-app://app/`:该来源在根目录提供同一份 dist,并把其他所有应用路径转发给它拥有的 Host,因此它的文档目录本就是根目录,无需为它注入 base 行;它改为通过传输层的 `streamBaseUrl` 为 Gateway 指定该 Host origin。它的资源就位于该根目录,这也是所服务 HTTP 外壳的 `./` 不适用于它的原因。 + +## 曾考虑的替代方案 + +**把配置 base URL 当作路由 base(`publicUrl`)。** 否决:文档已经知道自己的 URL,因此这是为页面本可读取的事实引入第二个易漂移来源,而且必须把它接入每个消费方,而不是只做一次外壳决策。对外公告的公开 URL 仍是面向运维的打印/打开输入,不是路由输入。 + +**从转发前缀请求头推断挂载点。** 否决:这会让请求头对路由具有权威性,任何设置该头的客户端都能挪动应用自有路由,代理约定也会变成服务端可见的配置。 + +**共享 `Connection.resolveUrl` 或 routes base。** HTTP 载体的文档基准与 worker tunnel 的 Host 根路径映射不同。在 Connection 中统一解析所有目标,会把静态 preview 路径加到 worker 本地路由之前,或丢失所服务的挂载。各载体解析自己的目标;UI 资源消费方使用文档基准。 + +**保留绝对 `/` 重定向并让代理改写 `Location`。** 否决:浏览器已经知道请求所在目录,而配置出来的重定向目标无法同时适用于 loopback 入口与代理挂载。 + +## 后果 + +一份 bundle 服务源站根与任一挂载,所服务的文档是挂载点的唯一来源。代价是显式的:受支持的入口只有 dist 根与配置的 index 路径;裸挂载依赖代理的斜杠规范化;页面内深层路径重新加载会在该路径重新进入,而非冻结目录。 + +功能包自有的路由(open-in-app、deliverables、会话日志导出、上传、markdown 媒体)通过各自的 `*_ROUTE` 常量遵循同一规则;让浏览器源码持续遵守该规则的静态门禁随那些改动一并记录。 + +## 测试 + +frontend-static 的真实组合测试通过 Loader 提供 index,并断言唯一的 `` 先于注入的插件资源行与转换标记。client-modules node 半侧测试断言启动图 row、批次描述符与 source-map trailer 均为文档相对,而响应表仍按路由提供它们,客户端测试 roster 与 assembled-boot fixture 携带同一相对形式。`browser-auth.host.spec.ts` 覆盖 `./` 重定向与保留 authority 和挂载的 `authenticatedUrl`;`frontend-static.spec.ts` 与 `apps/cli/tests/web-auth.e2e.ts` 分别经真实 Loader 组合与构建后的 CLI 运行该交换。 + +`apps/web/tests/public-mount.e2e.ts` 是浏览器证据:真实 Chromium 经剥离前缀的代理到达同一监听端口,挂载下的 WebSocket 双向成流,`pushState` 到更深路径后 `document.baseURI` 仍停在挂载点,而文档相对的 `fetch('api/session/create')` 在其下依然成功。 diff --git a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts index 0c5b4c26d4..b1f89c11d1 100644 --- a/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts +++ b/apps/cli/tests/profiles/web/tests/web-failure-matrix.expected.e2e.ts @@ -138,7 +138,7 @@ function start(f: ReturnType, extra: string[] = []) { expect(response.status).toBe(200) const html = await response.text() expect(html).toContain('__DSH_BOOT__') - const bundlePath = /')) + // A plugin row stands in for the Host's plugin-resource rows: the base the + // shell inserts must precede it, not merely exist. + const offRows = loaded.on('webserver/index-inject', (rows) => { + rows.push({ kind: 'script-preload', src: 'plugins/boot.js' }) + }) for (const path of ['/', '/index.html', '/?view=test']) { const got = await request(port, path, authenticated()) expect(got.status).toBe(200) expect(got.type).toBe('text/html; charset=utf-8') expect(got.body).toContain('__T__') expect(got.body).toContain('shell') + // The served document carries the entry-directory base exactly once, and + // it precedes every injected row and tap markup, so the shell's + // app-owned routes and the Host's resource rows resolve under one mount. + expect(got.body.match(/') + expect(base).toBeLessThan(got.body.indexOf('')) + expect(base).toBeLessThan(got.body.indexOf('')) } + offRows() expect(await request(port, '/', authenticated({ method: 'HEAD' }))).toEqual({ status: 200, type: 'text/html; charset=utf-8', diff --git a/packages/host/webserver/src/injections.ts b/packages/host/webserver/src/injections.ts index 5b431918f5..79734fd34a 100644 --- a/packages/host/webserver/src/injections.ts +++ b/packages/host/webserver/src/injections.ts @@ -20,7 +20,7 @@ export type IndexInjection = /** * External classic script, executed in table order: a parser-blocking tag * when served, an awaited fetch-and-execute in the worker form (whose - * loader resolves worker-only URLs such as `/plugins/...`). + * loader resolves worker-only references such as `plugins/...`). */ | { kind: 'script-src'; placement: IndexInjectionPlacement; src: string } /** Advisory preload for an external classic script; static workers may ignore it. */ diff --git a/packages/test-support/client-runtime/src/assembly/roster.ts b/packages/test-support/client-runtime/src/assembly/roster.ts index 82aa14df93..80b1c824b1 100644 --- a/packages/test-support/client-runtime/src/assembly/roster.ts +++ b/packages/test-support/client-runtime/src/assembly/roster.ts @@ -24,17 +24,17 @@ const LOCAL_REV = 'local' /** * Synthesize the raw `WebBootGraph` for `rows`: one `application` batch - * holding every row, `rev: 'local'`, placeholder `/plugins//client.js` - * URLs, since every module is seeded in process and never fetched. Validation - * stays with the production `parseBootManifest` inside the module system: - * duplicate names and an empty roster are rejected there, not here. + * holding every row, `rev: 'local'`, placeholder `plugins//client.js` + * references, since every module is seeded in process and never fetched. + * Validation stays with the production `parseBootManifest` inside the module + * system: duplicate names and an empty roster are rejected there, not here. * @param rows - roster rows in composition order. * @returns the unparsed graph, as `createClientModuleSystem` consumes it. */ export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph { const entries: WebBootEntry[] = rows.map(row => ({ id: row.name, - url: `/plugins/${row.name}/client.js`, + url: `plugins/${row.name}/client.js`, rev: LOCAL_REV, ...(row.inject.length > 0 ? { inject: [...row.inject] } : {}), ...(row.immediately ? { immediately: true } : {}), @@ -44,7 +44,7 @@ export function graphFromRoster(rows: readonly ClientRosterRow[]): WebBootGraph entries, batches: [{ phase: 'application', - url: '/plugins/local.js', + url: 'plugins/local.js', rev: LOCAL_REV, entries: entries.map(entry => entry.id), }], diff --git a/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts index 999a99ea70..c3aded1f56 100644 --- a/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-modules.client.spec.ts @@ -25,8 +25,8 @@ const row = (name: string, immediately = false) => ({ name, inject: [], immediat function graph(names: readonly string[]): WebBootGraph { return { rev: 'local', - entries: names.map(id => ({ id, url: `/plugins/${id}/client.js`, rev: 'local' })), - batches: [{ phase: 'application', url: '/plugins/all.js', rev: 'local', entries: [...names] }], + entries: names.map(id => ({ id, url: `plugins/${id}/client.js`, rev: 'local' })), + batches: [{ phase: 'application', url: 'plugins/all.js', rev: 'local', entries: [...names] }], } } @@ -77,8 +77,8 @@ describe('createInProcessModules', () => { it('rejects loudly instead of fetching when a graph row has no loaded module', async () => { const system = createInProcessModules(graph(['ghost']), new Map()) - await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') - await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (/plugins/all.js)') + await expect(system.prefetch('ghost')).rejects.toThrow('in-process modules never load bundles (plugins/all.js)') + await expect(system.import('ghost', '', {})).rejects.toThrow('in-process modules never load bundles (plugins/all.js)') }) it('exposes the graph parsed by the production validator as its manifest', () => { diff --git a/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts index d1ccea2644..7ec7bf9711 100644 --- a/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-roster.client.spec.ts @@ -11,22 +11,22 @@ const ROWS: readonly ClientRosterRow[] = [ ] describe('graphFromRoster', () => { - it('synthesizes one application batch over every row with placeholder URLs', () => { + it('synthesizes one application batch over every row with placeholder references', () => { expect(graphFromRoster(ROWS)).toEqual({ rev: 'local', entries: [ - { id: MODULES_PACKAGE, url: `/plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true }, - { id: '@x/a', url: '/plugins/@x/a/client.js', rev: 'local' }, - { id: '@x/b', url: '/plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true }, + { id: MODULES_PACKAGE, url: `plugins/${MODULES_PACKAGE}/client.js`, rev: 'local', immediately: true }, + { id: '@x/a', url: 'plugins/@x/a/client.js', rev: 'local' }, + { id: '@x/b', url: 'plugins/@x/b/client.js', rev: 'local', inject: ['@x/a'], immediately: true }, ], - batches: [{ phase: 'application', url: '/plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }], + batches: [{ phase: 'application', url: 'plugins/local.js', rev: 'local', entries: [MODULES_PACKAGE, '@x/a', '@x/b'] }], }) }) it('parses through the production validator to plugin rows that mirror the roster, refusing duplicates and an empty roster', () => { const manifest = parseBootManifest(graphFromRoster(ROWS)) expect(manifest.plugins).toEqual(ROWS.map(row => ({ id: row.name, inject: [...row.inject], immediately: row.immediately }))) - expect(manifest.modules.map(row => row.initialUrl)).toEqual(['/plugins/local.js', '/plugins/local.js', '/plugins/local.js']) + expect(manifest.modules.map(row => row.initialUrl)).toEqual(['plugins/local.js', 'plugins/local.js', 'plugins/local.js']) const row = ROWS[1]! expect(() => parseBootManifest(graphFromRoster([row, row]))).toThrow('duplicate graph entry "@x/a"') expect(() => parseBootManifest(graphFromRoster([]))).toThrow('must be a non-empty string array') diff --git a/tsconfig.host.json b/tsconfig.host.json index 11fa5efe32..57ba05d8a4 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -12,6 +12,7 @@ "apps/web/tests/default-product-isolation.e2e.ts", "apps/web/tests/diff-context.e2e.ts", "apps/web/tests/scaffold.ts", + "apps/web/tests/prefix-proxy.ts", "apps/web/tests/auto-review-fixture.ts", "apps/web/tests/auto-review-denial.e2e.ts", "apps/web/tests/scaffold-generation.spec.ts", @@ -52,6 +53,7 @@ "apps/web/tests/onboarding-deepseek-config.e2e.ts", "apps/web/tests/onboarding-usable-provider.e2e.ts", "apps/web/tests/remote-welcome.e2e.ts", + "apps/web/tests/public-mount.e2e.ts", "apps/web/tests/workspace-new-session-folding.e2e.ts", "apps/web/tests/workspace-management.e2e.ts", "apps/web/tests/workspace-recency.e2e.ts", From 29182514edb2a154025434e8ae4dca92d97d3058 Mon Sep 17 00:00:00 2001 From: Yichen Yan <5031346+oraluben@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:37:04 +0800 Subject: [PATCH 049/225] fix(web): resolve feature routes from the document directory and gate browser routes --- ...eb-feature-routes-and-route-gate.i18n.yaml | 6 + ...09-17-web-feature-routes-and-route-gate.md | 31 ++ ...17-web-feature-routes-and-route-gate.zh.md | 31 ++ ...ession-prose-local-media-display.i18n.yaml | 4 +- ...09-07-session-prose-local-media-display.md | 2 +- ...07-session-prose-local-media-display.zh.md | 2 +- package.json | 1 + .../client/file-upload/src/client/runtime.ts | 19 +- packages/client/file-upload/src/protocol.ts | 6 + packages/client/file-upload/src/types.ts | 5 +- .../tests/file-upload.client.spec.ts | 46 ++- .../src/client/chat/AssistantMarkdown.tsx | 28 +- ...stant-markdown-path-images.client.spec.tsx | 34 +- .../client/ui-deliverables/src/changes.ts | 24 +- .../src/client/present-open.ts | 4 +- .../client/ui-deliverables/src/presented.ts | 13 +- .../tests/changes-open.host.spec.ts | 6 +- .../tests/deliverables.client.spec.tsx | 18 +- .../tests/present-open.client.spec.ts | 8 +- .../tests/present-open.host.spec.ts | 2 +- .../tests/review-tab.client.spec.tsx | 6 +- .../client/ui-open-in-app/README.i18n.yaml | 4 +- packages/client/ui-open-in-app/README.md | 2 +- packages/client/ui-open-in-app/README.zh.md | 2 +- .../ui-open-in-app/src/client/controller.ts | 10 +- .../client/ui-open-in-app/src/client/index.ts | 4 +- .../tests/browser-plugin.client.spec.ts | 8 +- .../tests/controller.client.spec.ts | 16 +- .../tests/open-in-app-action.client.spec.tsx | 4 +- packages/host/open-in-app/src/index.ts | 16 +- packages/host/open-in-app/src/shared.ts | 26 +- .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 4 +- .../session-log-export/README.zh.md | 4 +- .../src/client/controller.ts | 22 +- .../session-log-export/src/index.ts | 4 +- .../session-log-export/src/routes.ts | 11 + .../tests/controller.client.spec.ts | 19 +- .../session-log-export/tsconfig.client.json | 1 + .../session-log-export/tsconfig.host.json | 3 +- scripts/run-gates.spec.ts | 18 +- scripts/run-gates.ts | 18 +- scripts/ts-project.ts | 57 ++- .../verify-client-route-resolution.spec.ts | 192 ++++++++++ scripts/verify-client-route-resolution.ts | 332 ++++++++++++++++++ 45 files changed, 867 insertions(+), 210 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md create mode 100644 .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md create mode 100644 packages/session-query/session-log-export/src/routes.ts create mode 100644 scripts/verify-client-route-resolution.spec.ts create mode 100644 scripts/verify-client-route-resolution.ts diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml new file mode 100644 index 0000000000..d1515fb973 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md +2026-09-17-web-feature-routes-and-route-gate.md: 5c18e23e827c4556c0012147f857f214ecc5641a +2026-09-17-web-feature-routes-and-route-gate.zh.md: 2ab045e288e61eb9ba8dc6df222bf3048a11363a diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md new file mode 100644 index 0000000000..5c18e23e82 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md @@ -0,0 +1,31 @@ +# Agent Note: Feature routes follow the document base, and a gate keeps them there + +Status: implemented + +English | [中文](2026-09-17-web-feature-routes-and-route-gate.zh.md) + +## Problem + +[Web app-owned routes are document-relative](2026-09-14-web-document-relative-app-routes.md) makes the shell, its plugin bundles, and its streams reach one listener under any mount. Routes owned by feature packages still addressed the origin root: `/open-in-app/...`, the deliverables `present.*` and `changes.*` routes, the session-log export download, the upload worker's `/api/session/uploadFileBinary` post, and the `/api/file` route behind local markdown image paths. Under a prefix-stripping proxy each of those requests missed. Nothing stopped a new browser-face reference from binding the bundle to one mount again. + +## Decision + +Every feature producer keeps its absolute registration key and derives the browser form beside it: `OPEN_IN_APP_*_PATH`/`*_ROUTE`, `PRESENT_*_PATH`/`*_ROUTE`, `CHANGED_FILES_PATH` and `CHANGES_*_PATH`/`*_ROUTE`, `SESSION_LOG_EXPORT_PATH`/`_ROUTE`, `FILE_UPLOAD_PATH`/`_ROUTE`. Browser code addresses only the `*_ROUTE` form, and a Fetch-shaped carrier a page installs (`FileUploadFetch`, `RpcFetch`) receives that relative route and resolves it against its own base. + +Two consumers need an absolute URL and resolve against `document.baseURI`: the upload worker, whose own base is a `blob:` URL, and the markdown image vocabulary, which emits only absolute `http(s)`, `blob`, or `data` destinations. + +`verify-client-route-resolution` runs in `hygiene` and CI static checks over every browser source the client compiler face compiles. It governs request targets — the first argument of a request constructor, a `fetch`-shaped call, or a dynamic import; an assigned resource property; or a JSX `src`/`href` attribute — and rejects a root-absolute, protocol-relative, or absolute app route (`api`, `plugins`, `open-in-app` prefixes), and a relative app route resolved against a `location` read. A shared `*_PATH`/`*_ENDPOINT` key used as a request target must be stripped. The same pass checks the Host producers of browser references: a `url`, `src`, or `href` field stamped with a root-absolute app route is rejected, while route keys and response-table keys stay absolute and out of scope. + +## Alternatives considered + +**Banning every `location` read in browser code.** Rejected: identity comparisons, the preview's own query read, and the worker tunnel's host-relative mapping are all legitimate. The gate governs request targets, which is where a mount-bound route appears. + +**Rewriting feature responses in the proxy.** Rejected: it duplicates the mount in deployment configuration for each route the proxy does not know about, and the document already resolves relative references. + +## Consequences + +The gate reads inline literal fragments only, so a composed reference (`comboReference(...)`, `artifact.url.slice(1)`) is not traced; `packages/client/modules/tests/node-half.client.spec.ts` owns the regression signal for composed graph rows, batch descriptors, and the source-map trailer. Preview pages do not expose their worker's file API at the document URL, so markdown local-file image URLs cannot reach that Host. [Session prose local media display](../feature/2026-09-07-session-prose-local-media-display.md) owns the image vocabulary. + +## Testing + +The gate's own spec pins a negative control per rule: interpolated targets (`${origin}/api/file`), protocol-relative and absolute forms, an unstripped shared key, a relative route resolved against a `location` read, and root-absolute producer fields. Each feature package's specs assert the relative route its browser half sends; `markdown-images.e2e.ts` keeps the local-image path covered end to end. diff --git a/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md new file mode 100644 index 0000000000..2ab045e288 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 功能路由遵循文档 base,并由门禁维持 + +Status: implemented + +[English](2026-09-17-web-feature-routes-and-route-gate.md) | 中文 + +## 问题 + +[Web 应用自有路由相对文档寻址](2026-09-14-web-document-relative-app-routes.zh.md)让外壳、插件 bundle 与流在任一挂载下到达同一监听端口。功能包自有的路由仍寻址源站根:`/open-in-app/...`、deliverables 的 `present.*` 与 `changes.*` 路由、会话日志导出下载、上传 worker 的 `/api/session/uploadFileBinary` 提交,以及本地 markdown 图片路径背后的 `/api/file` 路由。在剥离前缀的代理之后,这些请求全部落空。也没有任何机制阻止新的浏览器侧引用再次把 bundle 绑定到单一挂载。 + +## 决策 + +每个功能生产者保留其绝对注册键,并在旁边派生浏览器形式:`OPEN_IN_APP_*_PATH`/`*_ROUTE`、`PRESENT_*_PATH`/`*_ROUTE`、`CHANGED_FILES_PATH` 与 `CHANGES_*_PATH`/`*_ROUTE`、`SESSION_LOG_EXPORT_PATH`/`_ROUTE`、`FILE_UPLOAD_PATH`/`_ROUTE`。浏览器代码只寻址 `*_ROUTE` 形式;页面安装的 Fetch 形载体(`FileUploadFetch`、`RpcFetch`)收到该相对路由,并相对自身基准解析。 + +两个消费方需要绝对 URL,并相对 `document.baseURI` 解析:上传 worker(其自身基准是 `blob:` URL)与 markdown 图片词汇表(只输出绝对的 `http(s)`、`blob` 或 `data` 目标)。 + +`verify-client-route-resolution` 在 `hygiene` 与 CI 静态检查中运行,覆盖 client 编译面编译的每个浏览器源文件。它治理请求目标——请求构造器、`fetch` 形调用或动态 import 的第一个参数;赋值的资源属性;JSX 的 `src`/`href` 属性——拒绝根绝对、协议相对或绝对的应用路由(`api`、`plugins`、`open-in-app` 前缀),以及相对 `location` 读取解析的相对应用路由。作为请求目标使用的共享 `*_PATH`/`*_ENDPOINT` 键必须先剥去前导斜杠。同一遍检查还覆盖宿主侧浏览器引用的生产者:以根绝对应用路由写入的 `url`、`src` 或 `href` 字段被拒绝,而路由键与响应表键保持绝对,不在范围内。 + +## 曾考虑的替代方案 + +**在浏览器代码中禁止一切 `location` 读取。** 否决:身份比较、preview 自身查询读取、worker tunnel 的宿主相对映射都属正当。门禁治理请求目标——绑定挂载点的路由真正出现的位置。 + +**在代理中改写功能响应。** 否决:这会为代理并不知晓的每条路由在部署配置里重复挂载点,而文档本就能解析相对引用。 + +## 后果 + +门禁只读取内联字面片段,因此组合出来的引用(`comboReference(...)`、`artifact.url.slice(1)`)不被追踪;组合而成的启动图 row、批次描述符与 source-map trailer 的回归信号由 `packages/client/modules/tests/node-half.client.spec.ts` 持有。Preview 页面不会在文档 URL 下暴露其 worker 的文件 API,因此 markdown 本地文件图片 URL 无法到达该 Host。[会话正文本地媒体展示](../feature/2026-09-07-session-prose-local-media-display.zh.md)持有图片词汇表。 + +## 测试 + +门禁自身的 spec 为每条规则固定了负向对照:插值目标(`${origin}/api/file`)、协议相对与绝对形式、未剥前导斜杠的共享键、相对路由相对 `location` 读取解析,以及生产者中根绝对的引用字段。各功能包的 spec 断言其浏览器半侧发出的相对路由;`markdown-images.e2e.ts` 端到端维持本地图片路径的覆盖。 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml index c52f0a47a2..daa947a20c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md -2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd -2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840 +2026-09-07-session-prose-local-media-display.md: fe769d1cbe0b54eed355a7e699bf853a497820cb +2026-09-07-session-prose-local-media-display.zh.md: 2cfff4016f42a13d94a1097602ff2dadbd28d2a7 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md index ed740acd0d..fe769d1cbe 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md @@ -14,7 +14,7 @@ Local media paths in Session prose render through a same-origin file route. This `ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again. -`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. +`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `api/file?path=…` resolved against `document.baseURI`, so the request stays under whatever mount the served document came from. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. `session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation. diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md index de8a7f99d7..2cfff4016f 100644 --- a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md @@ -14,7 +14,7 @@ Session 正文中的本地媒体路径通过同源文件路由渲染。本记录 `ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。 -`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 +`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到相对 `document.baseURI` 解析的 `api/file?path=…`,因此请求始终停留在所服务文档来源的那个挂载之下。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 `session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。 diff --git a/package.json b/package.json index 7eb8fb2782..4cb2f0e828 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", + "verify-client-route-resolution": "tsx scripts/verify-client-route-resolution.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", diff --git a/packages/client/file-upload/src/client/runtime.ts b/packages/client/file-upload/src/client/runtime.ts index 6601da4878..b7866edfa6 100644 --- a/packages/client/file-upload/src/client/runtime.ts +++ b/packages/client/file-upload/src/client/runtime.ts @@ -5,13 +5,14 @@ import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { FILE_UPLOAD_PATH } from '../protocol.ts' +import { FILE_UPLOAD_ROUTE } from '../protocol.ts' import type { ClientFileUploadHooks, EncodedFileUploadRequest, FileUploadFetch, FileUploadValue, } from '../types.ts' import type { FileUploadBody, FileUploadService } from './contract.ts' interface FileUploadRequest { + /** Document-relative app-owned upload route, query string included. */ readonly path: string readonly body: FileUploadBody readonly headers?: Readonly> @@ -198,7 +199,7 @@ export class FileUploadRuntime extends Service implements FileUploadService { const query = new URLSearchParams({ sessionId }) if (name !== undefined) query.set('name', name) const response = await this.post({ - path: `${FILE_UPLOAD_PATH}?${query.toString()}`, + path: `${FILE_UPLOAD_ROUTE}?${query.toString()}`, body: data, headers: { 'content-type': 'application/octet-stream' }, ...(signal === undefined ? {} : { signal }), @@ -230,7 +231,7 @@ function customTransport(customFetch: FileUploadFetch): FileUploadTransport { ...(request.body instanceof ReadableStream ? { duplex: 'half' as const } : {}), ...(request.signal === undefined ? {} : { signal: request.signal }), } - const response = await customFetch(resolveUrl(request.path), init) + const response = await customFetch(request.path, init) return { status: response.status, body: await response.text() } }, } @@ -284,7 +285,8 @@ function workerTransport(): FileUploadTransport { } request.signal?.addEventListener('abort', abort, { once: true }) const message: UploadWorkerStart = { - url: resolveUrl(request.path).href, + // The Worker's own base is `blob:`, so its request URL must be absolute. + url: new URL(request.path, document.baseURI).href, body: request.body, headers: request.headers ?? {}, } @@ -295,15 +297,6 @@ function workerTransport(): FileUploadTransport { } } -function resolveUrl(path: string): URL { - const pageLocation = Reflect.get(globalThis, 'location') as unknown - const origin = typeof pageLocation === 'object' && pageLocation !== null - && 'origin' in pageLocation && typeof pageLocation.origin === 'string' - ? pageLocation.origin - : undefined - return new URL(path, origin === undefined || origin === 'null' ? 'http://dsh.internal' : origin) -} - function parseFileUploadResult(body: string): RemoteResult { const value = JSON.parse(body) as unknown if (!isRecord(value) || typeof value.ok !== 'boolean') { diff --git a/packages/client/file-upload/src/protocol.ts b/packages/client/file-upload/src/protocol.ts index 67ec32811a..17edde2a70 100644 --- a/packages/client/file-upload/src/protocol.ts +++ b/packages/client/file-upload/src/protocol.ts @@ -1,2 +1,8 @@ /** Authenticated raw-byte route owned by the file-upload service. */ export const FILE_UPLOAD_PATH = '/api/session/uploadFileBinary' + +/** + * Browser-relative form of {@link FILE_UPLOAD_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const FILE_UPLOAD_ROUTE = FILE_UPLOAD_PATH.slice(1) diff --git a/packages/client/file-upload/src/types.ts b/packages/client/file-upload/src/types.ts index b63b2452bf..ff153925af 100644 --- a/packages/client/file-upload/src/types.ts +++ b/packages/client/file-upload/src/types.ts @@ -23,11 +23,12 @@ export type FileUploadReceiptId = Branded<'file-upload-receipt-id'> /** * Fetch-shaped carrier installed by a page that owns its Host transport. - * @param input - absolute same-origin upload URL. + * @param input - document-relative upload route; a carrier that needs an + * absolute URL resolves it against its own base. * @param init - raw request body, headers, and cancellation signal. * @returns the Host response. */ -export type FileUploadFetch = (input: URL, init: RequestInit) => Promise +export type FileUploadFetch = (input: string | URL, init: RequestInit) => Promise /** Pre-Cordis hook supplied by a page whose Host runs in another execution context. */ export interface ClientFileUploadHooks { diff --git a/packages/client/file-upload/tests/file-upload.client.spec.ts b/packages/client/file-upload/tests/file-upload.client.spec.ts index ac9e63f499..2392feed55 100644 --- a/packages/client/file-upload/tests/file-upload.client.spec.ts +++ b/packages/client/file-upload/tests/file-upload.client.spec.ts @@ -213,8 +213,7 @@ describe('file upload worker body', () => { describe('file upload service', () => { it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => { - vi.stubGlobal('location', { origin: 'https://preview.test' }) - const fetch = vi.fn((_url: URL, _init?: RequestInit) => + const fetch = vi.fn((_url: string | URL, _init?: RequestInit) => Promise.resolve(new Response('accepted', { status: 202 }))) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } const ctx = new Context() @@ -223,30 +222,29 @@ describe('file upload service', () => { const blob = new Blob(['opaque']) const signal = new AbortController().signal await expect((ctx.fileUpload as FileUploadRuntime).post({ - path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, + path: 'api/upload', body: blob, headers: { 'x-test': 'yes' }, signal, })).resolves.toEqual({ status: 202, body: 'accepted' }) - expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), { + expect(fetch).toHaveBeenLastCalledWith('api/upload', { method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal, }) const stream = new ReadableStream({ start(controller) { controller.close() } }) - await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) - expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), { + await (ctx.fileUpload as FileUploadRuntime).post({ path: 'stream', body: stream }) + expect(fetch).toHaveBeenLastCalledWith('stream', { method: 'POST', body: stream, duplex: 'half', }) await fiber.dispose() }) - it('mounts through the plugin entry and resolves non-browser URLs', async () => { - vi.stubGlobal('location', { origin: 'null' }) + it('mounts through the plugin entry with a document-relative route', async () => { const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch } const ctx = new Context() const fiber = ctx.plugin({ apply }) await fiber const body = new Blob() - await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body }) - expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), { + await (ctx.fileUpload as FileUploadRuntime).post({ path: 'api/session/uploadFileBinary', body }) + expect(fetch).toHaveBeenCalledWith('api/session/uploadFileBinary', { method: 'POST', body, }) await fiber.dispose() @@ -257,7 +255,7 @@ describe('file upload service', () => { const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber - await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() })) .rejects.toThrow('background upload requires Web Worker support') await fiber.dispose() }) @@ -274,19 +272,19 @@ describe('file upload service', () => { constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this } } vi.stubGlobal('Worker', FakeWorker) - vi.stubGlobal('location', { origin: 'https://harness.test' }) + vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' }) const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber const progress = vi.fn() const blob = new Blob(['bytes']) - const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: 'api/upload', body: blob, onProgress: progress }) const worker = FakeWorker.last if (worker === undefined) throw new Error('worker missing') expect(created).toHaveBeenCalledOnce() expect(revoked).toHaveBeenCalledWith('blob:worker') expect(worker.postMessage).toHaveBeenCalledWith({ - url: 'https://harness.test/api/upload', body: blob, headers: {}, + url: 'https://harness.test/mount/api/upload', body: blob, headers: {}, }) worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent) worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent) @@ -313,11 +311,12 @@ describe('file upload service', () => { constructor() { FakeWorker.last = this } } vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' }) const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber const stream = new ReadableStream({ start(controller) { controller.close() } }) - const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream }) + const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: 'stream', body: stream }) const worker = FakeWorker.last if (worker === undefined) throw new Error('worker missing') expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream]) @@ -338,31 +337,32 @@ describe('file upload service', () => { constructor() { FakeWorker.all.push(this) } } vi.stubGlobal('Worker', FakeWorker) + vi.stubGlobal('document', { baseURI: 'https://harness.test/mount/' }) const ctx = new Context() const fiber = ctx.plugin(FileUploadRuntime) await fiber - const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() }) FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent) await expect(reported).rejects.toThrow('network failed') - const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() }) FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent) await expect(errored).rejects.toThrow('worker crashed') - const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }) + const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob() }) FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent) await expect(unnamed).rejects.toThrow('background upload worker failed') const controller = new AbortController() - const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal }) + const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob(), signal: controller.signal }) controller.abort() await expect(aborted).rejects.toMatchObject({ name: 'AbortError' }) expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce() const already = new AbortController() already.abort() - await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal })) + await expect((ctx.fileUpload as FileUploadRuntime).post({ path: 'upload', body: new Blob(), signal: already.signal })) .rejects.toMatchObject({ name: 'AbortError' }) expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled() await fiber.dispose() @@ -390,9 +390,8 @@ describe('Session-addressed file upload', () => { } it('assembles the scoped streaming request and parses progress and receipt fields', async () => { - vi.stubGlobal('location', { origin: 'https://preview.test' }) const progress = vi.fn() - const fetch = vi.fn((_url: URL, init: RequestInit) => { + const fetch = vi.fn((_url: string | URL, init: RequestInit) => { expect(init.body).toBeInstanceOf(Blob) progress({ loaded: 2, total: 4 }) return Promise.resolve(new Response(JSON.stringify({ @@ -416,7 +415,7 @@ describe('Session-addressed file upload', () => { }, }) expect(fetch).toHaveBeenCalledWith( - new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'), + 'api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf', expect.objectContaining({ method: 'POST', headers: { 'content-type': 'application/octet-stream' }, @@ -448,7 +447,6 @@ describe('Session-addressed file upload', () => { }) it('rejects malformed background results', async () => { - vi.stubGlobal('location', { origin: 'https://preview.test' }) ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch: () => Promise.resolve(new Response(null, { status: 413 })), } diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 3212801e00..13ff7fae4b 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -10,19 +10,16 @@ import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' /** - * Map one authored media destination to the same-origin workspace-file URL. - * @param protocol - `window.location.protocol` at render time. - * @param origin - `window.location.origin` at render time. - * @param value - The authored markdown destination, exactly as written. - * @returns The API URL for an absolute POSIX path on an HTTP(S) page, or - * undefined when the destination cannot be a Host-served local file - * (non-HTTP transport such as Electron `file://`, protocol-relative or - * relative destinations). + * Resolve an authored POSIX image path against the document's file API. + * @param base - canonical `document.baseURI` at render time. + * @param value - authored markdown destination. + * @returns an absolute HTTP(S) file-API URL, or undefined for unsupported + * protocols and non-local paths. */ -export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined { - if (protocol !== 'http:' && protocol !== 'https:') return undefined - if (value.length === 0 || !value.startsWith('/') || value.startsWith('//')) return undefined - return `${origin}/api/file?path=${encodeURIComponent(value)}` +export function localPathMediaUrl(base: string, value: string): string | undefined { + if (!value.startsWith('/') || value.startsWith('//')) return undefined + if (!base.startsWith('http:') && !base.startsWith('https:')) return undefined + return new URL(`api/file?path=${encodeURIComponent(value)}`, base).href } export interface AssistantMarkdownProps { @@ -50,12 +47,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const labels = useMemo(() => markdownLabels(t), [t]) - // Local media paths in the closing prose rewrite to the same-origin file - // API (policy re-validation lives host-side). The vocabulary identity is - // stable per page load because MarkdownText memoizes on it. + // MarkdownText memoizes its vocabulary; keep its identity stable across renders. const pathImages = useMemo(() => { - const { protocol, origin } = window.location - return { resolve: value => localPathMediaUrl(protocol, origin, value) } + return { resolve: value => localPathMediaUrl(document.baseURI, value) } }, []) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 564f5a6a41..7af96d9cd5 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -14,31 +14,39 @@ function textBlock(text: string): AssistantBlock { return { kind: 'text', text } } -const ORIGIN = 'http://127.0.0.1:3080' +const BASE = 'http://127.0.0.1:3080/' +const MOUNTED_BASE = 'http://127.0.0.1:3080/tools/dsh/' describe('localPathMediaUrl', () => { - it('maps an absolute POSIX path on an HTTP page to the file API', () => { - expect(localPathMediaUrl('http:', ORIGIN, '/tmp/graph.png')) - .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) - expect(localPathMediaUrl('https:', 'https://127.0.0.1:3080', '/tmp/graph.png')) + it('maps an absolute POSIX path on an HTTP page to the file route of its document', () => { + expect(localPathMediaUrl(BASE, '/tmp/graph.png')) + .toBe(`${BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + expect(localPathMediaUrl('https://127.0.0.1:3080/', '/tmp/graph.png')) .toBe(`https://127.0.0.1:3080/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) }) + it('keeps the route beneath a mount the document is served from', () => { + expect(localPathMediaUrl(MOUNTED_BASE, '/tmp/graph.png')) + .toBe(`${MOUNTED_BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + expect(localPathMediaUrl('http://127.0.0.1:3080/tools/dsh/index.html', '/tmp/graph.png')) + .toBe(`${MOUNTED_BASE}api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + }) + it('keeps non-HTTP transports inert', () => { - expect(localPathMediaUrl('file:', 'file:///app', '/tmp/graph.png')).toBeUndefined() - expect(localPathMediaUrl('ws:', ORIGIN, '/tmp/graph.png')).toBeUndefined() + expect(localPathMediaUrl('about:blank', '/tmp/graph.png')).toBeUndefined() + expect(localPathMediaUrl('dsh-app://app/', '/tmp/graph.png')).toBeUndefined() }) it('keeps destinations that cannot be Host-served local files inert', () => { - expect(localPathMediaUrl('http:', ORIGIN, '')).toBeUndefined() - expect(localPathMediaUrl('http:', ORIGIN, '//cdn.example.com/x.png')).toBeUndefined() - expect(localPathMediaUrl('http:', ORIGIN, 'relative.png')).toBeUndefined() - expect(localPathMediaUrl('http:', ORIGIN, 'C:\\tmp\\x.png')).toBeUndefined() + expect(localPathMediaUrl(BASE, '')).toBeUndefined() + expect(localPathMediaUrl(BASE, '//cdn.example.com/x.png')).toBeUndefined() + expect(localPathMediaUrl(BASE, 'relative.png')).toBeUndefined() + expect(localPathMediaUrl(BASE, 'C:\\tmp\\x.png')).toBeUndefined() }) it('encodes the full path including spaces', () => { - expect(localPathMediaUrl('http:', ORIGIN, '/tmp/my graph.png')) - .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/my graph.png')}`) + expect(localPathMediaUrl(BASE, '/tmp/my graph.png')) + .toBe(`${BASE}api/file?path=${encodeURIComponent('/tmp/my graph.png')}`) }) }) diff --git a/packages/client/ui-deliverables/src/changes.ts b/packages/client/ui-deliverables/src/changes.ts index 1e4ec7dddd..2124b41ff1 100644 --- a/packages/client/ui-deliverables/src/changes.ts +++ b/packages/client/ui-deliverables/src/changes.ts @@ -11,6 +11,18 @@ export const CHANGES_DIFF_PATH = '/api/changes.diff' /** Authenticated POST route for opening a changed file on the Host desktop. */ export const CHANGES_OPEN_PATH = '/api/changes.open' +/** + * Browser-relative form of {@link CHANGED_FILES_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const CHANGED_FILES_ROUTE = CHANGED_FILES_PATH.slice(1) + +/** Browser-relative form of {@link CHANGES_DIFF_PATH}. */ +export const CHANGES_DIFF_ROUTE = CHANGES_DIFF_PATH.slice(1) + +/** Browser-relative form of {@link CHANGES_OPEN_PATH}. */ +export const CHANGES_OPEN_ROUTE = CHANGES_OPEN_PATH.slice(1) + /** Resource-address prefix of a turn's review tab in the right Sidebar. */ export const CHANGES_REVIEW_ADDRESS = 'dsh-resource://changes-review/session/' @@ -94,10 +106,10 @@ export function isChangesEvent(value: unknown): value is { turn: number } { * Build authenticated coordinates for the summary one `workspace/changes` event announced. * @param sessionId - owning Session. * @param seq - event sequence. - * @returns same-origin summary URL. + * @returns document-relative summary route. */ export function changesSummaryUrl(sessionId: SessionId, seq: number): string { - return `${CHANGED_FILES_PATH}?${new URLSearchParams({ sessionId, seq: String(seq) })}` + return `${CHANGED_FILES_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq) })}` } /** @@ -105,10 +117,10 @@ export function changesSummaryUrl(sessionId: SessionId, seq: number): string { * @param sessionId - owning Session. * @param seq - workspace/changes event sequence. * @param index - original index in the summary's files array. - * @returns same-origin comparison URL. + * @returns document-relative comparison route. */ export function changesDiffUrl(sessionId: SessionId, seq: number, index: number): string { - return `${CHANGES_DIFF_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` + return `${CHANGES_DIFF_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** @@ -116,10 +128,10 @@ export function changesDiffUrl(sessionId: SessionId, seq: number, index: number) * @param sessionId - owning Session. * @param seq - workspace/changes event sequence. * @param index - original index in the summary's files array. - * @returns same-origin action URL. + * @returns document-relative action route. */ export function changedFileUrl(sessionId: SessionId, seq: number, index: number): string { - return `${CHANGES_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` + return `${CHANGES_OPEN_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index aef36604aa..d724816d19 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -2,7 +2,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { changedFileUrl } from '../changes.ts' -import { presentedFileUrl, PRESENT_HOST_PATH, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' +import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' /** State of the latest explicit open gesture for one saved file. */ export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' @@ -86,7 +86,7 @@ export class PresentedOpenController { private async readHost(signal: AbortSignal): Promise { let host: PresentedHost | 'error' = 'error' try { - const response = await fetch(PRESENT_HOST_PATH, { signal }) + const response = await fetch(PRESENT_HOST_ROUTE, { signal }) if (response.ok) { const value: unknown = await response.json() if (isPresentedHost(value)) host = value diff --git a/packages/client/ui-deliverables/src/presented.ts b/packages/client/ui-deliverables/src/presented.ts index f4f2ed92d4..f9274f067a 100644 --- a/packages/client/ui-deliverables/src/presented.ts +++ b/packages/client/ui-deliverables/src/presented.ts @@ -9,6 +9,15 @@ export const PRESENT_OPEN_PATH = '/api/present.open' /** Authenticated desktop availability and destination metadata. */ export const PRESENT_HOST_PATH = '/api/present.host' +/** + * Browser-relative form of {@link PRESENT_OPEN_PATH}; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ +export const PRESENT_OPEN_ROUTE = PRESENT_OPEN_PATH.slice(1) + +/** Browser-relative form of {@link PRESENT_HOST_PATH}. */ +export const PRESENT_HOST_ROUTE = PRESENT_HOST_PATH.slice(1) + /** Native file action selected by an explicit user gesture. */ export type PresentedAction = 'open' | 'reveal' @@ -49,10 +58,10 @@ export function isPresentedFile(value: unknown): value is PresentedFile { * @param sessionId - owning Session. * @param seq - deliverables/presented event sequence. * @param index - original index in the event's files array. - * @returns same-origin file action URL. + * @returns document-relative file action route. */ export function presentedFileUrl(sessionId: SessionId, seq: number, index: number): string { - return `${PRESENT_OPEN_PATH}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` + return `${PRESENT_OPEN_ROUTE}?${new URLSearchParams({ sessionId, seq: String(seq), index: String(index) })}` } /** diff --git a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts index 78078c7a80..b391263de5 100644 --- a/packages/client/ui-deliverables/tests/changes-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/changes-open.host.spec.ts @@ -77,7 +77,7 @@ async function fixture() { describe('change summary route', () => { it('serves the Host-held summary without its working directory, and 404 once it is gone', async () => { const { read, data, summary } = await fixture() - expect(changesSummaryUrl(SessionId('owner'), 9)).toBe(`${CHANGED_FILES_PATH}?sessionId=owner&seq=9`) + expect(changesSummaryUrl(SessionId('owner'), 9)).toBe('api/changes.summary?sessionId=owner&seq=9') const response = await read() expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') @@ -95,7 +95,7 @@ describe('change summary route', () => { describe('change comparison route', () => { it('serves the Host-computed comparison, 404 once it is gone, and 500 when the read fails', async () => { const { compare, comparison, diff } = await fixture() - expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_DIFF_PATH}?sessionId=owner&seq=9&index=0`) + expect(changesDiffUrl(SessionId('owner'), 9, 0)).toBe('api/changes.diff?sessionId=owner&seq=9&index=0') const response = await compare() expect(response.status).toBe(200) expect(response.headers.get('cache-control')).toBe('no-store') @@ -132,7 +132,7 @@ describe('change comparison route', () => { describe('changed files native open route', () => { it('opens a listed file inside or outside the workspace with its verified Host path', async () => { const { cwd, open, opener, outside } = await fixture() - expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`) + expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe('api/changes.open?sessionId=owner&seq=9&index=0') const response = await open() expect(response.status).toBe(204) expect(response.headers.get('cache-control')).toBe('no-store') diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index cedb8a2d04..83e898212b 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -621,8 +621,8 @@ describe('ChangedFiles card', () => { it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => { const controller = new PresentedOpenController() controller.state.set({ - '/api/changes.open?sessionId=child-session&seq=5&index=0': 'opening', - '/api/changes.open?sessionId=child-session&seq=5&index=1': 'error', + 'api/changes.open?sessionId=child-session&seq=5&index=0': 'opening', + 'api/changes.open?sessionId=child-session&seq=5&index=1': 'error', }) const { view } = renderCard(controller) // Native-open gestures belong to the review tab; the card shows counts only. @@ -747,31 +747,31 @@ describe('plugin registration', () => { expect(face.hooks.presentedHost.getSnapshot()).toMatchObject({ name: 'desktop' }) fetcher.mockResolvedValueOnce(Response.json({ turn: 1, files: [], total: 0, added: 0, deleted: 0 })) await face.loadChangesSummary(SessionId('child-session'), 5) - expect(face.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }) + expect(face.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=5']).toEqual({ turn: 1, files: [], total: 0, added: 0, deleted: 0 }) ctx.emit('connection/reset') expect(face.hooks.presentedHost.getSnapshot()).toBeNull() // The replaced connection may reach a Host that no longer serves the summaries read so far. expect(face.hooks.changesSummary.getSnapshot()).toEqual({}) await face.openPresented(SessionId('child-session'), 2, 0) - expect(face.hooks.presentedOpen.getSnapshot()['/api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') + expect(face.hooks.presentedOpen.getSnapshot()['api/present.open?sessionId=child-session&seq=2&index=0']).toBe('opened') await face.openChanged(SessionId('child-session'), 5, 0) - expect(face.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened') + expect(face.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=0']).toBe('opened') face.openChangesReview({ sessionId: SessionId('child-session'), seq: 5, turn: 3 }, 1) expect(openResource).toHaveBeenCalledWith('dsh-resource://changes-review/session/child-session/5/3', { params: { index: 1 } }) expect((registered as { title(address: string): string }).title('dsh-resource://changes-review/session/child-session/5/3')).toBe('Review · turn 3') const tabFace = tabEntry!.inject!(SessionId('child-session') as never) as unknown as ReviewInjected fetcher.mockResolvedValueOnce(Response.json({ turn: 3, files: [], total: 0, added: 0, deleted: 0 })) await tabFace.loadChangesSummary(SessionId('child-session'), 6) - expect(tabFace.hooks.changesSummary.getSnapshot()['/api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) + expect(tabFace.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })) await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) - expect(tabFace.hooks.changesDiff.getSnapshot()['/api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) + expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost) fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) await tabFace.reloadPresentedHost() fetcher.mockResolvedValueOnce(new Response(null, { status: 204 })) await tabFace.openChanged(SessionId('child-session'), 5, 1) - expect(tabFace.hooks.presentedOpen.getSnapshot()['/api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened') + expect(tabFace.hooks.presentedOpen.getSnapshot()['api/changes.open?sessionId=child-session&seq=5&index=1']).toBe('opened') ctx.emit('connection/reset') expect(tabFace.hooks.changesDiff.getSnapshot()).toEqual({}) // A turn that produced nothing yields no vocabulary at all. @@ -905,7 +905,7 @@ it('lets one delivered file span the complete row without an expansion control', it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits retries after failure', (phase) => { const controller = new PresentedOpenController() - controller.state.set({ '/api/present.open?sessionId=session&seq=2&index=0': phase }) + controller.state.set({ 'api/present.open?sessionId=session&seq=2&index=0': phase }) const props = openProps(controller) const view = render( { vi.unstubAllGlobals() }) const id = SessionId('fork') -const url = '/api/present.open?sessionId=fork&seq=2&index=1' +const url = 'api/present.open?sessionId=fork&seq=2&index=1' it('coalesces concurrent card and mention gestures, then allows another open', async () => { const reply = Promise.withResolvers() @@ -31,11 +31,11 @@ it('opens changed files through their own coordinates', async () => { vi.stubGlobal('fetch', fetcher) const controller = new PresentedOpenController() await controller.openChanged(id, 9, 1) - expect(fetcher.mock.calls.map(call => call[0])).toEqual(['/api/changes.open?sessionId=fork&seq=9&index=1']) - expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened') + expect(fetcher.mock.calls.map(call => call[0])).toEqual(['api/changes.open?sessionId=fork&seq=9&index=1']) + expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('opened') fetcher.mockResolvedValueOnce(new Response(null, { status: 422 })) await controller.openChanged(id, 9, 1) - expect(controller.state.getSnapshot()['/api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable') + expect(controller.state.getSnapshot()['api/changes.open?sessionId=fork&seq=9&index=1']).toBe('nativeUnavailable') await controller.dispose() }) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index 1282a47b85..072529cdde 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -61,7 +61,7 @@ describe('Presented workspace file native open route', () => { it('opens the source itself with current bytes and leaves it intact at disposal', async () => { const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) - expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe(`${PRESENT_OPEN_PATH}?sessionId=owner&seq=7&index=0`) + expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404) expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) for (const contents of ['current source', 'edited source']) { diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 84e76ddc3a..cd94ff1010 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -270,11 +270,11 @@ describe('ReviewTab', () => { expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin')) fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' })) expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1) - act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) }) + act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) }) expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true) - act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) }) + act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) }) expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true) - act(() => { controller.state.set({ '/api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) }) + act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) }) expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() act(() => { controller.state.set({}) diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index bab8f0bd17..158485bb7c 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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-open-in-app/README.md -README.md: d242d5a84dffbf0895485f7c79eafd5e332fe446 -README.zh.md: e954b7c71e8c4dbd1b19a041aafb7fe154631fbb +README.md: d3de7f60390b0a1b7065bcb71025c3f3e4db19cd +README.zh.md: b547fd144c464c01692d7a2cd47e8dda684976ac diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index d242d5a84d..d3de7f6039 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -39,7 +39,7 @@ The main button shows the remembered application's icon — the real application
    Implementation internals — click to expand -The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Route paths and wire payload types are inlined from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster. +The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. The node half is an empty `apply` that keeps the plugin on the host roster.
    diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index e954b7c71e..b547fd144c 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference"
    实现内幕——点击展开 -插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。路由路径与 wire 载荷类型从主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared` 内联。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。 +插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。
    diff --git a/packages/client/ui-open-in-app/src/client/controller.ts b/packages/client/ui-open-in-app/src/client/controller.ts index bb9e146e49..53da6a8fc8 100644 --- a/packages/client/ui-open-in-app/src/client/controller.ts +++ b/packages/client/ui-open-in-app/src/client/controller.ts @@ -8,12 +8,6 @@ import { type Fetch = (input: string | URL, init?: RequestInit) => Promise -/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ -function hostBase(): string { - const origin = (globalThis as { location?: { origin?: string } }).location?.origin - return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal' -} - /** * Owns the once-per-page availability read, the persisted last choice, and * the launch POST. Availability and choice publish through uSES-safe sources @@ -60,7 +54,7 @@ export class OpenInAppController { */ async launch(appId: string, path: string): Promise { const body: OpenInAppOpenPayload = { app: appId, path } - const response = await this.fetcher(new URL(OPEN_IN_APP_OPEN_ROUTE, hostBase()), { + const response = await this.fetcher(OPEN_IN_APP_OPEN_ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), @@ -71,7 +65,7 @@ export class OpenInAppController { private async run(): Promise { let apps: readonly string[] = [] try { - const response = await this.fetcher(new URL(OPEN_IN_APP_APPS_ROUTE, hostBase()), { + const response = await this.fetcher(OPEN_IN_APP_APPS_ROUTE, { headers: { accept: 'application/json' }, }) if (response.ok) { diff --git a/packages/client/ui-open-in-app/src/client/index.ts b/packages/client/ui-open-in-app/src/client/index.ts index c3e4d88f98..039e0a9f50 100644 --- a/packages/client/ui-open-in-app/src/client/index.ts +++ b/packages/client/ui-open-in-app/src/client/index.ts @@ -10,7 +10,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' -import { OPEN_IN_APP_ICON_PREFIX } from '@deepseek-ai/dsh-host-open-in-app/shared' +import { OPEN_IN_APP_ICON_PREFIX_ROUTE } from '@deepseek-ai/dsh-host-open-in-app/shared' import { OpenInAppController } from './controller.ts' import { OpenInAppAction, type OpenInAppActionInjected } from './OpenInAppAction.tsx' import { en, NS, zh, type OpenInAppKey } from './locales.ts' @@ -47,7 +47,7 @@ export function apply(ctx: ClientContext): void { }, launch: (appId, path) => controller.launch(appId, path), choose: (appId) => { controller.choose(appId) }, - iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX}/${appId}`, + iconUrl: appId => `${OPEN_IN_APP_ICON_PREFIX_ROUTE}/${appId}`, }), }, OpenInAppAction)) } diff --git a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts index eefdae245d..07a4fdcfce 100644 --- a/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/browser-plugin.client.spec.ts @@ -58,7 +58,7 @@ describe('open-in-app browser half', () => { const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void init const url = String(input) - if (url.includes('/open-in-app/apps')) { + if (url.includes('open-in-app/apps')) { return new Response(JSON.stringify({ apps: ['finder', 'cursor', 7] }), { status: 200 }) } return new Response(JSON.stringify({ ok: true }), { status: 200 }) @@ -71,13 +71,13 @@ describe('open-in-app browser half', () => { await vi.waitFor(() => { expect(injected.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor']) }) - expect(injected.iconUrl('cursor')).toBe('/open-in-app/icon/cursor') + expect(injected.iconUrl('cursor')).toBe('open-in-app/icon/cursor') injected.choose('cursor') expect(injected.hooks.openInAppChoice.getSnapshot()).toBe('cursor') await injected.launch('cursor', '/w/dir') - const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('/open-in-app/open')) + const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('open-in-app/open')) expect(openCall?.[1]).toMatchObject({ method: 'POST', headers: { 'content-type': 'application/json' }, @@ -88,7 +88,7 @@ describe('open-in-app browser half', () => { it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => { vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => { - if (String(input).includes('/open-in-app/apps')) throw new Error('down') + if (String(input).includes('open-in-app/apps')) throw new Error('down') return new Response('', { status: 502 }) })) const { ctx, fiber } = await bench() diff --git a/packages/client/ui-open-in-app/tests/controller.client.spec.ts b/packages/client/ui-open-in-app/tests/controller.client.spec.ts index dc60321271..ff8c9d9f62 100644 --- a/packages/client/ui-open-in-app/tests/controller.client.spec.ts +++ b/packages/client/ui-open-in-app/tests/controller.client.spec.ts @@ -1,4 +1,4 @@ -/** Controller wire behavior: host-base resolution, availability filtering, and launch errors. */ +/** Controller wire behavior: document-relative routes, availability filtering, and launch errors. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { OpenInAppController } from '../src/client/controller.ts' @@ -36,20 +36,11 @@ describe('OpenInAppController availability', () => { expect(malformed.apps.getSnapshot()).toEqual([]) }) - it('resolves routes against the page origin when the page has one', async () => { - vi.stubGlobal('location', { origin: 'http://dsh.example:8080' }) + it('requests the document-relative availability route', async () => { const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) }) const controller = new OpenInAppController(fetcher) await controller.load() - expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.example:8080/open-in-app/apps') - }) - - it('falls back to the internal host base under a null origin', async () => { - vi.stubGlobal('location', { origin: 'null' }) - const fetcher = vi.fn(async (input: string | URL) => { void input; return jsonResponse({ apps: [] }) }) - const controller = new OpenInAppController(fetcher) - await controller.load() - expect(String(fetcher.mock.calls[0]?.[0])).toBe('http://dsh.internal/open-in-app/apps') + expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/apps') }) }) @@ -72,6 +63,7 @@ describe('OpenInAppController launching', () => { const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => { void input; void init; return jsonResponse({ ok: true }) }) const controller = new OpenInAppController(fetcher) await controller.launch('cursor', '/w/dir') + expect(fetcher.mock.calls[0]?.[0]).toBe('open-in-app/open') expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'POST', headers: { 'content-type': 'application/json' }, diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 42d6ee0a02..540a918a77 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -55,7 +55,7 @@ function bench(over: { useOpenInAppChoice: useSelector(choice), launch, choose, - iconUrl: (appId: string) => `/open-in-app/icon/${appId}`, + iconUrl: (appId: string) => `open-in-app/icon/${appId}`, t, } as unknown as OpenInAppActionProps return { props, launch, choose } @@ -234,7 +234,7 @@ describe('OpenInAppAction launching', () => { const b = bench({ apps: ['terminal'], cwd: '/w/dir' }) const { container } = render() const img = container.querySelector('img') - expect(img?.getAttribute('src')).toBe('/open-in-app/icon/terminal') + expect(img?.getAttribute('src')).toBe('open-in-app/icon/terminal') if (img !== null) fireEvent.error(img) await waitFor(() => { expect(container.querySelector('img')).toBeNull() diff --git a/packages/host/open-in-app/src/index.ts b/packages/host/open-in-app/src/index.ts index 0c065e0111..8e8da91ec4 100644 --- a/packages/host/open-in-app/src/index.ts +++ b/packages/host/open-in-app/src/index.ts @@ -36,7 +36,7 @@ import { import { extractAppIcon, type OpenInAppIcon } from './icons.ts' import { internals } from './internals.ts' import { - OPEN_IN_APP_APPS_ROUTE, OPEN_IN_APP_ICON_PREFIX, OPEN_IN_APP_OPEN_ROUTE, + OPEN_IN_APP_APPS_PATH, OPEN_IN_APP_ICON_PREFIX_PATH, OPEN_IN_APP_OPEN_PATH, } from './shared.ts' export type * from './shared.ts' @@ -192,7 +192,7 @@ export function apply(ctx: Context, config: Config): void { ctx.effect(() => ctx.webServer.register({ kind: 'exact', - path: OPEN_IN_APP_APPS_ROUTE, + path: OPEN_IN_APP_APPS_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'GET') { @@ -201,11 +201,11 @@ export function apply(ctx: Context, config: Config): void { } sendJson(res, 200, { apps: [...(await availability()).keys()] }) }, - }), `open-in-app: GET ${OPEN_IN_APP_APPS_ROUTE}`) + }), `open-in-app: GET ${OPEN_IN_APP_APPS_PATH}`) ctx.effect(() => ctx.webServer.register({ kind: 'prefix', - path: OPEN_IN_APP_ICON_PREFIX, + path: OPEN_IN_APP_ICON_PREFIX_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'GET') { @@ -214,7 +214,7 @@ export function apply(ctx: Context, config: Config): void { } // Node always sets url on server requests; String keeps that fact local. const pathname = new URL(String(req.url), 'http://localhost').pathname - const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX.length).replace(/^\//, '') + const id = pathname.slice(OPEN_IN_APP_ICON_PREFIX_PATH.length).replace(/^\//, '') const noIcon = (): void => { sendJson(res, 404, { code: 'not-found', message: `no icon for ${id}` }) } const app = OPEN_IN_APP_CATALOG.find(entry => entry.id === id) if (app === undefined) { @@ -236,11 +236,11 @@ export function apply(ctx: Context, config: Config): void { res.setHeader('cache-control', 'public, max-age=3600') res.end(icon.bytes) }, - }), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX}/`) + }), `open-in-app: GET ${OPEN_IN_APP_ICON_PREFIX_PATH}/`) ctx.effect(() => ctx.webServer.register({ kind: 'exact', - path: OPEN_IN_APP_OPEN_ROUTE, + path: OPEN_IN_APP_OPEN_PATH, handler: async (req, res) => { if (rejected(req, res)) return if (req.method !== 'POST') { @@ -307,5 +307,5 @@ export function apply(ctx: Context, config: Config): void { sendJson(res, 502, { code: 'launch-failed', message: `failed to launch ${app.id}` }) } }, - }), `open-in-app: POST ${OPEN_IN_APP_OPEN_ROUTE}`) + }), `open-in-app: POST ${OPEN_IN_APP_OPEN_PATH}`) } diff --git a/packages/host/open-in-app/src/shared.ts b/packages/host/open-in-app/src/shared.ts index 8b9ff47c6d..9e703c1703 100644 --- a/packages/host/open-in-app/src/shared.ts +++ b/packages/host/open-in-app/src/shared.ts @@ -1,17 +1,29 @@ /** * Route paths and wire payloads shared verbatim by the host routes and the * browser package (`@deepseek-ai/dsh-client-ui-open-in-app`), published as - * the `./shared` subpath. Browser-safe: constants and types only. + * the `./shared` subpath. Browser-safe: constants and types only. Each route + * carries the absolute pathname the Host registers beside the + * document-relative form the browser addresses; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. */ -/** GET route serving the probed application ids. */ -export const OPEN_IN_APP_APPS_ROUTE = '/open-in-app/apps' +/** GET route path serving the probed application ids. */ +export const OPEN_IN_APP_APPS_PATH = '/open-in-app/apps' -/** GET prefix serving one PNG bundle icon per application id. */ -export const OPEN_IN_APP_ICON_PREFIX = '/open-in-app/icon' +/** Browser-relative form of {@link OPEN_IN_APP_APPS_PATH}. */ +export const OPEN_IN_APP_APPS_ROUTE = OPEN_IN_APP_APPS_PATH.slice(1) -/** POST route launching one application on one workspace directory. */ -export const OPEN_IN_APP_OPEN_ROUTE = '/open-in-app/open' +/** GET prefix path serving one PNG bundle icon per application id. */ +export const OPEN_IN_APP_ICON_PREFIX_PATH = '/open-in-app/icon' + +/** Browser-relative form of {@link OPEN_IN_APP_ICON_PREFIX_PATH}. */ +export const OPEN_IN_APP_ICON_PREFIX_ROUTE = OPEN_IN_APP_ICON_PREFIX_PATH.slice(1) + +/** POST route path launching one application on one workspace directory. */ +export const OPEN_IN_APP_OPEN_PATH = '/open-in-app/open' + +/** Browser-relative form of {@link OPEN_IN_APP_OPEN_PATH}. */ +export const OPEN_IN_APP_OPEN_ROUTE = OPEN_IN_APP_OPEN_PATH.slice(1) /** Apps-route response: catalog ids probed as installed, in menu order. */ export interface OpenInAppAppsPayload { diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ae845c1725..12136da5b7 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -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/session-query/session-log-export/README.md -README.md: 8697b0c2c105c928f5325f953b7fb663ae68e18e -README.zh.md: fe60c6ab8cc8d2d5e7515416bb72624584c11b9d +README.md: 165f0a8ecdd268d33a3cb1b8c0d506e12791f6d6 +README.zh.md: 3602f9aa1b4cb403498132fb1247200b3bc7e341 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 8697b0c2c1..165f0a8ecd 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -50,7 +50,7 @@ The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-u | Input | Result | |---|---| -| `/export` | Records a human-command lifecycle; the submitting browser downloads `GET /api/session.export?sessionId=&includeDescendants=true` | +| `/export` | Records a human-command lifecycle; the submitting browser downloads the document-relative `api/session.export?sessionId=&includeDescendants=true` (Host route `/api/session.export`) | | `/export ` | An error; browser downloads choose their destination through the browser's ordinary download behavior | ### What to expect @@ -77,7 +77,7 @@ The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) regis ### Download flow -Both entry paths issue a `HEAD` preflight to `/api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. +Both entry paths issue a `HEAD` preflight to the document-relative `api/session.export?...`, then hand the GET route to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, handle-based log reads and attachment reads, ZIP generation, and HTTP status semantics. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index fe60c6ab8c..3602f9aa1b 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -50,7 +50,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` | 输入 | 结果 | |---|---| -| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载 `GET /api/session.export?sessionId=&includeDescendants=true` | +| `/export` | 记录用户命令的生命周期;提交命令的浏览器下载文档相对的 `api/session.export?sessionId=&includeDescendants=true`(Host 路由 `/api/session.export`) | | `/export ` | 错误;浏览器下载通过浏览器的普通下载行为选择目标位置 | ### 预期行为 @@ -77,7 +77,7 @@ Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` ### 下载流程 -两条入口都会先向 `/api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 +两条入口都会先向文档相对的 `api/session.export?...` 发出 `HEAD` 预检请求,然后把 GET 路由交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 Host 路由是由该功能拥有的精确 Fetch 路由贡献。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、基于句柄的日志读取与附件读取、ZIP 生成和 HTTP 状态语义。 diff --git a/packages/session-query/session-log-export/src/client/controller.ts b/packages/session-query/session-log-export/src/client/controller.ts index f7f01cceb9..071b99f55e 100644 --- a/packages/session-query/session-log-export/src/client/controller.ts +++ b/packages/session-query/session-log-export/src/client/controller.ts @@ -2,6 +2,7 @@ import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { SESSION_LOG_EXPORT_ROUTE } from '../routes.ts' /** Download phases presented by the shared modal. */ export type SessionLogDownloadStatus = 'downloading' | 'success' | 'error' @@ -18,6 +19,7 @@ export interface SessionLogDownloadState { bySession: Record } +/** HTTP carrier for the export route. */ type Fetch = (input: string | URL, init?: RequestInit) => Promise type Save = (url: string, filename: string) => void @@ -33,8 +35,9 @@ export function sessionLogZipFilename(sessionId: SessionId): string { } /** - * Hand a Host download URL to the browser download manager. - * @param url - same-origin Host download URL. + * Hand a Host download route to the browser download manager, which resolves it + * against the document's own base. + * @param url - document-relative Host download route. * @param filename - browser download filename. */ export function downloadUrl(url: string, filename: string): void { @@ -44,12 +47,6 @@ export function downloadUrl(url: string, filename: string): void { anchor.click() } -/** Resolve the browser's Host base with the connection carrier's null-origin fallback. */ -function hostBase(): string { - const origin = (globalThis as { location?: { origin?: string } }).location?.origin - return origin !== undefined && origin !== 'null' ? origin : 'http://dsh.internal' -} - function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } @@ -112,15 +109,14 @@ export class SessionLogDownloadController { private async run(sessionId: SessionId, signal: AbortSignal): Promise { this.publish(sessionId, { open: true, status: 'downloading', error: null }) try { - const url = new URL('/api/session.export', hostBase()) - url.searchParams.set('sessionId', sessionId) - url.searchParams.set('includeDescendants', 'true') - const response = await this.fetcher(url, { method: 'HEAD', signal }) + const query = new URLSearchParams({ sessionId, includeDescendants: 'true' }) + const route = `${SESSION_LOG_EXPORT_ROUTE}?${query.toString()}` + const response = await this.fetcher(route, { method: 'HEAD', signal }) if (!response.ok) { const detail = await response.text().catch(() => '') throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) } - this.save(url.toString(), sessionLogZipFilename(sessionId)) + this.save(route, sessionLogZipFilename(sessionId)) const open = this.store.getSnapshot().bySession[String(sessionId)]?.open ?? true this.publish(sessionId, { open, status: 'success', error: null }) } catch (error: unknown) { diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index f907054d1a..98870d0618 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -17,6 +17,7 @@ import { type SessionLogCompressionLevel, type SessionLogExportReady, } from './archive.ts' +import { SESSION_LOG_EXPORT_PATH } from './routes.ts' export { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, @@ -39,8 +40,7 @@ export type { export const name = 'session-log-download' export const inject = ['commands', 'connection'] -/** Stable browser download path retained across the transport migration. */ -export const SESSION_LOG_EXPORT_PATH = '/api/session.export' +export { SESSION_LOG_EXPORT_PATH } from './routes.ts' /** Session-log archive policy. */ export interface Config { diff --git a/packages/session-query/session-log-export/src/routes.ts b/packages/session-query/session-log-export/src/routes.ts new file mode 100644 index 0000000000..e603351995 --- /dev/null +++ b/packages/session-query/session-log-export/src/routes.ts @@ -0,0 +1,11 @@ +/** + * The absolute pathname the Host registers the export under and the + * document-relative form the browser addresses; see + * .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md. + */ + +/** Absolute registration path for the ZIP download route. */ +export const SESSION_LOG_EXPORT_PATH = '/api/session.export' + +/** Browser-relative form of {@link SESSION_LOG_EXPORT_PATH}. */ +export const SESSION_LOG_EXPORT_ROUTE = SESSION_LOG_EXPORT_PATH.slice(1) diff --git a/packages/session-query/session-log-export/tests/controller.client.spec.ts b/packages/session-query/session-log-export/tests/controller.client.spec.ts index a5fc95852f..b12afab861 100644 --- a/packages/session-query/session-log-export/tests/controller.client.spec.ts +++ b/packages/session-query/session-log-export/tests/controller.client.spec.ts @@ -21,14 +21,12 @@ describe('SessionLogDownloadController', () => { await controller.download(SID) expect(fetcher).toHaveBeenCalledOnce() - const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit] - expect(url.pathname).toBe('/api/session.export') - expect(url.searchParams.get('sessionId')).toBe(SID) - expect(url.searchParams.get('includeDescendants')).toBe('true') + const [route, init] = fetcher.mock.calls[0] as unknown as [string, RequestInit] + expect(route).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`) expect(init.method).toBe('HEAD') expect(init.signal).toBeInstanceOf(AbortSignal) expect(save).toHaveBeenCalledWith( - url.toString(), + route, 'dsh-session-session-export-controller.zip', ) expect(controller.store.getSnapshot().bySession[SID]).toEqual({ @@ -99,8 +97,7 @@ describe('SessionLogDownloadController', () => { await controller.dispose() }) - it('uses the null-origin fallback and default browser operations', async () => { - vi.stubGlobal('location', { origin: 'null' }) + it('requests the document-relative route through the default carrier', async () => { const fetcher = vi.fn(async (_input: string | URL, _init?: RequestInit) => new Response('zip')) vi.stubGlobal('fetch', fetcher) const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) @@ -108,7 +105,7 @@ describe('SessionLogDownloadController', () => { await controller.download(SID) - expect((fetcher.mock.calls[0]?.[0] as URL).origin).toBe('http://dsh.internal') + expect(fetcher.mock.calls[0]?.[0]).toBe(`api/session.export?sessionId=${SID}&includeDescendants=true`) expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: 'HEAD' }) expect(click).toHaveBeenCalledOnce() }) @@ -133,14 +130,14 @@ describe('SessionLogDownloadController', () => { }) describe('browser download helpers', () => { - it('sanitizes the archive filename and hands the URL to a download anchor', () => { + it('sanitizes the archive filename and hands the relative route to a download anchor', () => { const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) expect(sessionLogZipFilename('a/b' as SessionId)).toBe('dsh-session-a_b.zip') - downloadUrl('http://host/api/session.export?sessionId=a', 'archive.zip') + downloadUrl('api/session.export?sessionId=a', 'archive.zip') expect(click).toHaveBeenCalledOnce() const anchor = click.mock.instances[0] as HTMLAnchorElement - expect(anchor.href).toBe('http://host/api/session.export?sessionId=a') + expect(anchor.getAttribute('href')).toBe('api/session.export?sessionId=a') expect(anchor.download).toBe('archive.zip') }) }) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json index 6e3e746c4c..8bcfe79da6 100644 --- a/packages/session-query/session-log-export/tsconfig.client.json +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -11,6 +11,7 @@ "src/client/controller.ts", "src/client/index.ts", "src/client/locales.ts", + "src/routes.ts", "src/css-modules.d.ts" ], "references": [ diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json index 78ad234797..0832fbd1ac 100644 --- a/packages/session-query/session-log-export/tsconfig.host.json +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -7,7 +7,8 @@ }, "files": [ "src/archive.ts", - "src/index.ts" + "src/index.ts", + "src/routes.ts" ], "references": [ { "path": "../../../vendor/cordis" }, diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index fb61abe8b5..fd43c9549d 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -260,9 +260,14 @@ describe('gate graph validation', () => { expect(ids).toEqual([ 'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', - 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config', - 'runtime-closure', + 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher', + 'cordis-config', 'runtime-closure', ]) + }) + + it('caps the local hygiene aggregate at four workers', () => { + const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id)) + expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ workers: 4, source: '8 available CPU(s), hygiene cap 4', @@ -344,6 +349,15 @@ describe('gate graph validation', () => { }, ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( + 'keeps browser app-route resolution enforcement in %s', + (mode) => { + const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id)) + + expect(ids).toContain('client-route-resolution') + }, + ) + it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)( 'keeps application entrypoint enforcement in %s', (mode) => { diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 025723ccbf..4e14031827 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -304,14 +304,21 @@ function ciSharedStaticGates(): Gate[] { pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + ...sharedHygieneGates(), + pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ] +} + +function sharedHygieneGates(): Gate[] { + return [ pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { label: 'optional dependency imports', }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), + pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }), pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), - pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }), - pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] } @@ -711,12 +718,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { label: 'node-next types', ...artifactOptions, }), - pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', { - label: 'optional dependency imports', - }), - pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), - pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), - pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), + ...sharedHygieneGates(), ] } diff --git a/scripts/ts-project.ts b/scripts/ts-project.ts index 53b100ceb0..669285b1ed 100644 --- a/scripts/ts-project.ts +++ b/scripts/ts-project.ts @@ -29,31 +29,54 @@ export const repositoryConfigHost: ts.ParseConfigFileHost = { }, } -/** - * Parse one face aggregate tsconfig and flatten all referenced projects into one - * semantic graph. Never seed the root solution: flattening host+client into one - * program collides the cordis Context merges. - */ -function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph { - const rootConfigPath = resolve(projectRoot, `tsconfig.${face}.json`) - const rootConfig = parseConfig(rootConfigPath) - const rootNames = new Set() - const visited = new Set() +/** One compiler face aggregate: its own config and every project it compiles. */ +export interface FaceConfigs { + /** The aggregate's `tsconfig..json`. */ + readonly root: ts.ParsedCommandLine + /** Every config the face compiles, its own aggregate included, keyed by resolved config path. */ + readonly byPath: ReadonlyMap +} - const collect = (configPath: string, parsed: ts.ParsedCommandLine): void => { - if (visited.has(configPath)) return - visited.add(configPath) - for (const fileName of parsed.fileNames) rootNames.add(fileName) +/** + * Parse one compiler face aggregate and every project it references. Never seed + * the root solution: the two faces are separate graphs, and flattening + * host+client into one program collides the cordis Context merges. + * @param projectRoot - repository root holding `tsconfig..json`. + * @param face - which aggregate to parse. + * @returns the aggregate's root config and every config it compiles. + */ +export function faceConfigs(projectRoot: string, face: CompilerFace): FaceConfigs { + const rootPath = resolve(projectRoot, `tsconfig.${face}.json`) + const root = parseConfig(rootPath) + const byPath = new Map([[rootPath, root]]) + const collect = (parsed: ts.ParsedCommandLine): void => { for (const reference of parsed.projectReferences ?? []) { const referencePath = ts.resolveProjectReferencePath(reference) - collect(referencePath, parseConfig(referencePath)) + if (byPath.has(referencePath)) continue + const referenced = parseConfig(referencePath) + byPath.set(referencePath, referenced) + collect(referenced) } } - collect(rootConfigPath, rootConfig) + collect(root) + return { root, byPath } +} +/** + * Flatten one compiler face aggregate into one semantic graph. + * @param projectRoot - repository root holding `tsconfig..json`. + * @param face - which aggregate to flatten. + * @returns every root name the face compiles, with the aggregate's options. + */ +function loadProjectGraph(projectRoot: string, face: CompilerFace): ProjectGraph { + const { root, byPath } = faceConfigs(projectRoot, face) + const rootNames = new Set() + for (const parsed of byPath.values()) { + for (const fileName of parsed.fileNames) rootNames.add(fileName) + } return { rootNames: [...rootNames], - options: rootConfig.options, + options: root.options, } } diff --git a/scripts/verify-client-route-resolution.spec.ts b/scripts/verify-client-route-resolution.spec.ts new file mode 100644 index 0000000000..6182a16523 --- /dev/null +++ b/scripts/verify-client-route-resolution.spec.ts @@ -0,0 +1,192 @@ +/** Regression coverage for the browser app-route guard. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { describe, expect, it } from 'vitest' +import { browserFaceSources, findRouteResolutionViolations, type RouteGateFace } from './verify-client-route-resolution.ts' + +function patterns(file: string, source: string, face?: RouteGateFace): string[] { + return findRouteResolutionViolations(file, source, face).map(violation => violation.pattern) +} + +function rules(file: string, source: string, face?: RouteGateFace): string[] { + return findRouteResolutionViolations(file, source, face).map(violation => violation.rule) +} + +const CLIENT_FILE = 'packages/client/ui-example/src/client/View.tsx' +const PRODUCER_FILE = 'packages/client/modules/src/index.ts' + +describe('browser app-route guard', () => { + it('accepts document-relative request targets and routes that are not targets', () => { + expect(patterns(CLIENT_FILE, ` + export const PRESENT_OPEN_PATH = '/api/present.open' + const CHANNEL = '/api' + export async function load(id: string) { + const bundle = 'plugins/local.js' + const icon = \`open-in-app/icon/\${id}\` + const file = new URL(\`api/file?path=\${id}\`, document.baseURI) + await customFetch('api/session.export?sessionId=s1') + await connection.rpc.call('/api', 'goals/create', {}) + webserver.register({ path: '/plugins' }) + const identity = window.location.hostname + const foreign = 'https://cdn.example/app.js' + const asset = '/assets/app.js' + return [bundle, icon, file.href, CHANNEL, identity, foreign, asset] + } + `)).toEqual([]) + }) + + it('rejects every request target that binds a route to one mount', () => { + expect(findRouteResolutionViolations(CLIENT_FILE, ` + export async function load(id: string) { + const origin = window.location.origin + await fetch('/api/remote.mux') + await fetch(\`/plugins/\${id}/client.js\`) + await fetch(\`\${origin}/api/file?path=\${id}\`) + await this.fetcher('/api/custom') + new EventSource('/plugins/events') + new WebSocket('wss://harness.example/api/remote.mux') + document.querySelector('script').src = '//harness.example/api/file' + return id + } + `).map(violation => violation.line)).toEqual([4, 5, 6, 7, 8, 9, 10]) + expect(rules(CLIENT_FILE, ` + await fetch('/api/remote.mux') + await fetch(\`\${window.location.origin}/api/file\`) + `)).toEqual(['request-target', 'request-target']) + }) + + it('rejects a root-absolute app route in a JSX resource attribute', () => { + expect(findRouteResolutionViolations(CLIENT_FILE, ` + export function View({ id }: { id: string }) { + return
    + + + + apps + cdn +
    + } + `).map(violation => violation.line)).toEqual([4, 5, 6]) + }) + + it('rejects a shared host route key used without stripping its leading slash', () => { + const source = ` + import { PRESENT_OPEN_PATH, EVENTS_ENDPOINT } from '../presented.ts' + await fetch(PRESENT_OPEN_PATH) + await fetch(\`\${EVENTS_ENDPOINT}?since=1\`) + await fetch(PRESENT_OPEN_PATH.slice(1)) + ` + expect(findRouteResolutionViolations(CLIENT_FILE, source).map(violation => violation.line)).toEqual([3, 4]) + expect(rules(CLIENT_FILE, source)).toEqual(['host-route-key', 'host-route-key']) + }) + + it('rejects a relative app route resolved against a location read', () => { + expect(findRouteResolutionViolations(CLIENT_FILE, ` + const url = new URL('api/session.export?sessionId=s1', window.location.origin) + const ok = new URL('api/session.export?sessionId=s1', document.baseURI) + const mapped = new URL(input, globalThis.location.origin) + return [url, ok, mapped] + `)).toEqual([ + expect.objectContaining({ line: 2, rule: 'location-base' }), + ]) + }) + + it('leaves identity and transport reads of location alone', () => { + expect(patterns(CLIENT_FILE, ` + const trusted = window.location.hostname === 'localhost' + const requested = new URL(location.href).searchParams.get('source') + const { origin } = document.location + return [trusted, requested, origin] + `)).toEqual([]) + }) +}) + +describe('browser-reference producers', () => { + it('rejects a root-absolute app route stamped into a field the browser resolves', () => { + expect(findRouteResolutionViolations(PRODUCER_FILE, ` + const bootstrap = { phase: 'bootstrap', url: \`/plugins/??\${resources}&rev=\${rev}\`, rev, entries } + const row = { id, url: 'plugins/boot.js', initialUrl: '/plugins/all.js', rev } + const map = { src: 'https://harness.example/plugins/??a/client.js.map&rev=r' } + const link = { href: '/open-in-app/apps' } + const script = { src: '//harness.example/plugins/a.js' } + const key = \`/plugins/\${comboSearch(ids, rev)}\` + return [bootstrap, row, map, link, script, key] + `, 'reference-producer').map(violation => violation.rule)).toEqual(Array(5).fill('reference-producer')) + }) + + it('leaves internal route keys and generated source names alone', () => { + expect(patterns(PRODUCER_FILE, ` + const PLUGIN_ROUTE = '/plugins' + const key = \`\${PLUGIN_ROUTE}/\${comboSearch(ids, rev)}\` + const section = { map: { sources: [\`/plugins/\${record.entry.id}/client.js\`] } } + return [key, section] + `, 'reference-producer')).toEqual([]) + }) + +}) + +/** + * Write one fixture project: its config, its source files, and its declared + * libraries. The `src` tree the discovery anchors on always exists. + */ +function writeProject(root: string, name: string, options: { + lib: readonly string[] + /** Project-relative source paths to write. */ + sources: readonly string[] + /** Config `include` patterns; defaults to the `src` tree. */ + include?: readonly string[] +}): void { + const dir = join(root, name) + mkdirSync(join(dir, 'src'), { recursive: true }) + writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { lib: options.lib, noEmit: true }, + include: [...options.include ?? ['src']], + })) + for (const source of options.sources) { + mkdirSync(dirname(join(dir, source)), { recursive: true }) + writeFileSync(join(dir, source), 'export {}\n') + } +} + +/** Write the two face aggregates of one fixture root over the named projects. */ +function writeFaceAggregates(root: string, client: readonly string[], host: readonly string[]): void { + const references = (names: readonly string[]): { path: string }[] => names.map(name => ({ path: `./${name}` })) + writeFileSync(join(root, 'tsconfig.client.json'), JSON.stringify({ files: [], references: references(client) })) + writeFileSync(join(root, 'tsconfig.host.json'), JSON.stringify({ files: [], references: references(host) })) +} + +describe('browser face discovery', () => { + it('takes sources from the Client aggregate alone', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-')) + try { + writeProject(root, 'browser-package', { lib: ['ES2024', 'DOM'], sources: ['src/view.ts', 'src/globals.d.ts'] }) + writeProject(root, 'shared-package', { lib: ['ES2024', 'DOM'], sources: ['src/both-faces.ts'] }) + writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] }) + writeFaceAggregates(root, ['browser-package', 'shared-package', 'host-package'], ['shared-package', 'host-package']) + + // DOM libraries alone do not make a browser face: a project the Host + // aggregate also compiles has a host half, and a project without them is + // host-side. Declarations carry no request target and stay out. + expect(browserFaceSources(root)).toEqual(['browser-package/src/view.ts']) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('fails loud when a DOM client project compiles nothing from its source tree', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-route-face-moved-')) + try { + // The DOM face still has a `src` directory, but its config now compiles a + // tree the discovery does not know: scanning less must not pass quietly. + writeProject(root, 'browser-package', { lib: ['DOM'], sources: ['lib/moved.ts'], include: ['lib'] }) + writeProject(root, 'host-package', { lib: ['ES2024'], sources: ['src/host.ts'] }) + writeFaceAggregates(root, ['browser-package'], ['host-package']) + + expect(() => browserFaceSources(root)).toThrow(/contributes no source file/u) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/verify-client-route-resolution.ts b/scripts/verify-client-route-resolution.ts new file mode 100644 index 0000000000..89ac2cf829 --- /dev/null +++ b/scripts/verify-client-route-resolution.ts @@ -0,0 +1,332 @@ +/** + * Browser-face route gate: an app-owned route reachable from browser code is + * document-relative. Only request targets are governed: a root-absolute + * (`/api/x`), protocol-relative (`//host/api/x`) or absolute + * (`https://host/api/x`) app route used to address a resource binds the bundle + * to one mount, and a relative app route resolved against a `location` read + * rebuilds the base the served document already provides. A request target is + * the first argument of a request constructor, of a `fetch`/`fetcher` callee, + * or of a dynamic import; an assigned resource property (`script.src = ...`); + * or a JSX `src`/`href` attribute. + * + * Route keys stay absolute pathnames — the RPC channel key, the path a route is + * registered under, and the shared `*_PATH`/`*_ENDPOINT` constants carrying + * them — so a literal that is not a request target is out of scope. A browser + * half addresses such a key by stripping its leading slash, which the shared-key + * rule below requires. + * + * The static producers of browser references are checked in the same pass: a + * field the browser resolves against its document (`url`, `src`, `href`) + * must not be stamped with a root-absolute app route. Internal route keys and + * response-table keys stay absolute and are not references. This rule reads + * inline literal fragments only, so a composed reference + * (`comboReference(...)`, `artifact.url.slice(1)`) is not traced: + * `packages/client/modules/tests/node-half.client.spec.ts` owns the regression + * signal for composed graph rows, batch descriptors, and the source-map trailer. + * + * Rationale: .agents/notes/implemented/architecture/2026-09-17-web-feature-routes-and-route-gate.md. + */ + +import { existsSync, globSync, readFileSync } from 'node:fs' +import { dirname, relative, resolve, sep } from 'node:path' +import ts from 'typescript' +import { faceConfigs } from './ts-project.ts' + +const root = resolve(import.meta.dirname, '..') +const GATE = 'verify-client-route-resolution' +/** App-owned route prefixes; a foreign path (`/assets/x`) is not governed here. */ +const APP_ROUTE_PREFIX = '(?:api|plugins|open-in-app)(?:/|$)' +/** A root-absolute or protocol-relative app route literal. */ +const ROOT_ABSOLUTE_ROUTE = new RegExp(`^/{1,2}${APP_ROUTE_PREFIX}`) +/** An absolute or protocol-relative app route literal on any origin or scheme. */ +const ABSOLUTE_ROUTE = new RegExp(`^(?:[A-Za-z][A-Za-z\\d+.-]*:)?//[^/]+/${APP_ROUTE_PREFIX}`) +/** A relative app route reference (`api/x`, `./plugins/x`). */ +const RELATIVE_APP_ROUTE = new RegExp(`^(?:\\./)*${APP_ROUTE_PREFIX}`) +/** A shared host route key: the absolute pathname a route is registered under. */ +const HOST_ROUTE_KEY = /(?:^|_)(?:PATH|ENDPOINT)$/ + +/** Request constructors whose first argument addresses a resource. */ +const REQUEST_CONSTRUCTORS: Record = { Request: true, EventSource: true, WebSocket: true, URL: true } +/** Assigned properties and JSX attributes that address a resource. */ +const REQUEST_PROPERTIES: Record = { href: true, src: true } +/** Fields a browser resolves against its own document. */ +const REFERENCE_FIELDS: Record = { url: true, src: true, href: true, initialUrl: true } + +/** + * Static producer of browser references: the node half composes the boot graph + * the browser resolves against its document. No request-target position in + * browser code sees those values, so its reference fields are checked directly. + */ +const REFERENCE_PRODUCERS = [ + 'packages/client/modules/src/index.ts', +] + +/** Rule that produced a violation, for diagnostics and for the gate's own coverage. */ +export type RouteResolutionRule = 'request-target' | 'location-base' | 'host-route-key' | 'reference-producer' + +/** What a scanned file is: browser face, or a static producer of browser references. */ +export type RouteGateFace = 'browser' | 'reference-producer' + +const HINT: Record = { + 'request-target': 'write the route document-relative (api/x)', + 'location-base': 'resolve the route against document.baseURI, not against a location read', + 'host-route-key': 'strip the leading slash before the browser uses this key (KEY.slice(1))', + 'reference-producer': 'emit the browser reference document-relative (plugins/x)', +} + +/** One app-owned route reached from a place that binds it to one mount. */ +export interface RouteResolutionViolation { + /** One-based source column. */ + column: number + /** Repository-relative source path. */ + file: string + /** One-based source line. */ + line: number + /** The offending expression, as written. */ + pattern: string + /** Rule that recognized the route. */ + rule: RouteResolutionRule +} + +/** A location expression: `location`, optionally behind its owning global. */ +const LOCATION_EXPRESSION = /^(?:(?:window|self|globalThis|document|top|parent|frames)\.)?(?:defaultView\.)?location$/ + +/** Source text of a location expression, without whitespace. */ +function locationText(node: ts.Expression): string | undefined { + const text = node.getText().replace(/\s+/g, '') + return LOCATION_EXPRESSION.test(text) ? text : undefined +} + +/** Whether an expression rebuilds a base from the page location anywhere inside it. */ +function readsLocation(node: ts.Node): boolean { + if (ts.isExpression(node) && locationText(node) !== undefined) return true + return node.forEachChild(readsLocation) === true +} + +/** Host route keys an expression references without stripping their leading slash. */ +function unstrippedHostRouteKeys(node: ts.Node): ts.Identifier[] { + if (ts.isIdentifier(node)) { + if (!HOST_ROUTE_KEY.test(node.text)) return [] + const derived = ts.isPropertyAccessExpression(node.parent) && node.parent.name.text === 'slice' + return derived ? [] : [node] + } + const keys: ts.Identifier[] = [] + node.forEachChild((child) => { keys.push(...unstrippedHostRouteKeys(child)) }) + return keys +} + +/** Callee name of a call or construction, for constructors and `fetch`-shaped carriers. */ +function calleeName(expression: ts.Expression): string | undefined { + if (ts.isIdentifier(expression)) return expression.text + return ts.isPropertyAccessExpression(expression) ? expression.name.text : undefined +} + +/** + * The request target one node carries, when it addresses a resource. + * @param node - any node visited in the tree. + * @returns the target expression, or undefined when the node is not a target. + */ +function requestTargetOf(node: ts.Node): ts.Expression | undefined { + if (ts.isCallExpression(node)) { + const name = calleeName(node.expression) + if ((name !== undefined && /fetch(er)?$/iu.test(name)) || node.expression.kind === ts.SyntaxKind.ImportKeyword) return node.arguments[0] + return undefined + } + if (ts.isNewExpression(node)) { + const name = calleeName(node.expression) + return name !== undefined && REQUEST_CONSTRUCTORS[name] === true ? node.arguments?.[0] : undefined + } + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken + && ts.isPropertyAccessExpression(node.left) && REQUEST_PROPERTIES[node.left.name.text] === true) { + return node.right + } + if (ts.isJsxAttribute(node) && ts.isIdentifier(node.name) && REQUEST_PROPERTIES[node.name.text] === true) { + const { initializer } = node + if (initializer === undefined) return undefined + return ts.isJsxExpression(initializer) ? initializer.expression : initializer + } + return undefined +} + +/** One literal fragment of a composed value, with the node that carries it. */ +interface LiteralFragment { node: ts.Node; text: string } + +/** + * Literal fragments of a value composed of literals, `+` concatenation, + * templates, and conditionals. Template fragments are the spans between + * substitutions, so an interpolated prefix (`${origin}/api/x`) is visible. + * @param node - the expression to decompose. + * @returns fragments in source order. + */ +function literalFragments(node: ts.Expression): LiteralFragment[] { + if (ts.isParenthesizedExpression(node)) return literalFragments(node.expression) + if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) { + return [...literalFragments(node.left), ...literalFragments(node.right)] + } + if (ts.isConditionalExpression(node)) { + return [...literalFragments(node.whenTrue), ...literalFragments(node.whenFalse)] + } + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [{ node, text: node.text }] + if (ts.isTemplateExpression(node)) { + // Every fragment reports the template itself: an interpolated prefix makes + // the offending span start mid-expression, and one template is one route. + return [ + { node, text: node.head.text }, + ...node.templateSpans.map(span => ({ node, text: span.literal.text })), + ] + } + return [] +} + +/** + * Find app-owned routes that bind one source file to one mount. + * @param file - repository-relative path used in diagnostics. + * @param sourceText - TypeScript or TSX source. + * @param face - `browser` for request targets, `reference-producer` for the + * fields a static host emits into the browser. + * @returns violations in source order. + */ +export function findRouteResolutionViolations( + file: string, + sourceText: string, + face: RouteGateFace = 'browser', +): RouteResolutionViolation[] { + const source = ts.createSourceFile( + file, + sourceText, + ts.ScriptTarget.Latest, + true, + file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ) + const violations = new Map() + + const report = (node: ts.Node, rule: RouteResolutionRule): void => { + const start = node.getStart(source) + if (violations.has(start)) return + const { character, line } = source.getLineAndCharacterOfPosition(start) + violations.set(start, { column: character + 1, file, line: line + 1, pattern: node.getText(source), rule }) + } + + /** A request target's own literals must be document-relative. */ + const checkTarget = (target: ts.Expression): void => { + for (const fragment of literalFragments(target)) { + if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) { + report(fragment.node, 'request-target') + } + } + // A shared route key used as-is: the browser half strips the leading slash. + for (const key of unstrippedHostRouteKeys(target)) report(key, 'host-route-key') + } + + /** A relative app route belongs to the document base, not to a rebuilt origin. */ + const checkUrlBase = (node: ts.NewExpression): void => { + const [first, second] = node.arguments ?? [] + if (first === undefined || second === undefined || !readsLocation(second)) return + const literal = literalFragments(first) + if (literal.some(fragment => RELATIVE_APP_ROUTE.test(fragment.text))) report(first, 'location-base') + } + + /** A field the browser resolves against its document carries a reference. */ + const checkReferenceField = (value: ts.Expression): void => { + for (const fragment of literalFragments(value)) { + if (ROOT_ABSOLUTE_ROUTE.test(fragment.text) || ABSOLUTE_ROUTE.test(fragment.text)) { + report(fragment.node, 'reference-producer') + } + } + } + + const visit = (node: ts.Node): void => { + if (face === 'browser') { + const target = requestTargetOf(node) + if (target !== undefined) checkTarget(target) + if (ts.isNewExpression(node) && calleeName(node.expression) === 'URL') checkUrlBase(node) + } else if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) + && REFERENCE_FIELDS[node.name.text] === true && ts.isExpression(node.initializer)) { + checkReferenceField(node.initializer) + } + ts.forEachChild(node, visit) + } + visit(source) + + return [...violations.values()].sort((left, right) => left.line - right.line || left.column - right.column) +} + +/** + * Source files of every browser-side TypeScript project in the Client aggregate. + * + * A browser half belongs to `src/client`; a package that keeps its whole UI in + * plain `src` is found through the project itself, which carries the Client + * compiler shape (DOM libraries). A project the Host aggregate also compiles + * has a host half rather than a browser-only surface, so only its `src/client` + * tree is browser face. Every such face must contribute at least one source + * file: a face that contributes none means the aggregate's layout moved out + * from under this discovery, which must fail loud rather than scan less. + * @param projectRoot - repository root to discover from. + * @returns sorted repository-relative source paths. + */ +export function browserFaceSources(projectRoot: string = root): string[] { + const sources = new Set() + const add = (file: string): void => { + const normalized = file.replaceAll('\\', '/') + if (normalized.endsWith('.d.ts')) return + sources.add(normalized) + } + const hostConfigs = faceConfigs(projectRoot, 'host').byPath + + for (const [configPath, parsed] of faceConfigs(projectRoot, 'client').byPath) { + if (parsed.options.lib?.includes('lib.dom.d.ts') !== true || hostConfigs.has(configPath)) continue + const sourceRoot = `${dirname(configPath).replaceAll('\\', '/')}/src/` + // An aggregate or app shell (the root Client program) carries the compiler + // shape but no `src/` tree of its own; its browser sources arrive through + // the references and `src/client` discovery below. + if (!existsSync(sourceRoot)) continue + const faceSources = parsed.fileNames.filter((file) => { + const normalized = file.replaceAll('\\', '/') + return normalized.startsWith(sourceRoot) && /\.tsx?$/.test(normalized) + }) + if (faceSources.length === 0) { + throw new Error( + `${GATE}: browser face ${relative(projectRoot, configPath).split(sep).join('/')} has DOM libraries but contributes no source file; discovery is broken.`, + ) + } + for (const file of faceSources) add(relative(projectRoot, file).split(sep).join('/')) + } + + for (const file of globSync('packages/*/*/src/client/**/*.{ts,tsx}', { cwd: projectRoot })) { + add(relative(projectRoot, resolve(projectRoot, file)).split(sep).join('/')) + } + + return [...sources].sort() +} + +/** Scan one file for one face; a producer that disappeared must fail loud. */ +function scan(file: string, face: RouteGateFace): RouteResolutionViolation[] { + const path = resolve(root, file) + if (face === 'reference-producer' && !existsSync(path)) { + throw new Error(`${GATE}: browser-reference producer ${file} is gone; discovery is broken.`) + } + return findRouteResolutionViolations(file, readFileSync(path, 'utf8'), face) +} + +function main(): void { + const files = browserFaceSources() + const violations = [ + ...files.flatMap(file => scan(file, 'browser')), + ...REFERENCE_PRODUCERS.flatMap(file => scan(file, 'reference-producer')), + ] + if (violations.length > 0) { + console.error(`${GATE}: ${violations.length} app-owned route(s) that are not document-relative:`) + for (const violation of violations) { + console.error( + ` ${violation.file}:${violation.line}:${violation.column} ${violation.pattern} — ${HINT[violation.rule]}`, + ) + } + process.exitCode = 1 + return + } + console.log( + `${GATE}: ${files.length} browser source file(s) and ${REFERENCE_PRODUCERS.length} browser-reference producer(s) keep app-owned routes document-relative.`, + ) +} + +if (import.meta.filename === resolve(process.argv[1] ?? '')) main() From 5d173b170d2e883bf53ffeb98ac9adb1dcdee5bf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 16:09:29 +0800 Subject: [PATCH 050/225] test(web): strengthen plan dismissal checks and clarify review contracts --- .../2026-09-17-persistent-plan-cards.i18n.yaml | 4 ++-- .../feature/2026-09-17-persistent-plan-cards.md | 2 +- .../feature/2026-09-17-persistent-plan-cards.zh.md | 2 +- apps/web/tests/plan-review.e2e.ts | 12 +++++++++++- packages/client/ui-plan/package.json | 1 + .../client/ui-plan/src/client/PlanPreview.module.css | 6 +++--- packages/client/ui-plan/src/client/PlanPreview.tsx | 10 ++++++++-- packages/client/ui-plan/src/client/plan-resource.ts | 5 ++++- .../ui-user-questions/src/client/contract/slots.ts | 2 +- .../ui-user-questions/src/client/markdown-labels.ts | 2 +- 10 files changed, 33 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml index bf06dd8eed..c1121b7795 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md -2026-09-17-persistent-plan-cards.md: 53d0ca028a08faacec8f967c002fb525e50c33ac -2026-09-17-persistent-plan-cards.zh.md: cfc2a67c17a5b4ed76a7cb9df49b6fdfb15e3c71 +2026-09-17-persistent-plan-cards.md: 341175aabc41100b98b7d2f16120fc9ac91a7020 +2026-09-17-persistent-plan-cards.zh.md: 8276bb6cc9985f0b856193a4ab861751089dd8e9 diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md index 53d0ca028a..341175aabc 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md @@ -26,4 +26,4 @@ The document is read-only. The pending review shows its status, View full plan l ## Consequences -Plans survive review closure and browser reload without a new Session event or file. 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. +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. diff --git a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md index cfc2a67c17..8276bb6cc9 100644 --- a/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md +++ b/.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md @@ -26,4 +26,4 @@ Status: implemented ## Consequences -计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。 +计划在审批关闭和浏览器刷新后仍可阅读,无需新增会话事件或文件。即使工具拒绝调用,包括在计划模式之外调用,完整的计划参数也会保留卡片;卡片不代表批准或执行成功。读取旧计划可能需要多个历史分页。provider 校验保存的地址和调用参数,并明确报告历史不可用。已有 Web 计划审批录制场景覆盖两个入口、重复打开、运行过程折叠、批准和刷新;定向测试覆盖多次提交、PTC 去重、裁剪历史及资源读取失败。 diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index 6df0c8ef1d..1aae08e395 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -190,8 +190,17 @@ describe('web e2e: dismissed plan history', () => { 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') - expect(results.some(event => JSON.stringify(event).includes('dismissed the plan review'))).toBe(true) + 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' }) @@ -208,6 +217,7 @@ describe('web e2e: dismissed plan history', () => { 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() diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index a2e837347a..1fc5b2f465 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -33,6 +33,7 @@ "@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" ], diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index f320f6af77..de4a4660a4 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -22,7 +22,7 @@ } :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, .reviewOpen:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: 3px; } +.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; } @@ -31,8 +31,8 @@ .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; } -.reviewOpen { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; } -.reviewOpen:hover { background: var(--dsw-alias-interactive-bg-hover); } +.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; } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index 71d2e6444e..ff0ad857b8 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -16,7 +16,13 @@ type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'pl export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { const tab = useTabInfo() const resource = useResource<'plan'>(tab.tab.navigation.address) - const labels = useMemo(() => ({ code: { copyLabel: t('copy'), copiedLabel: t('copied') }, footnotes: t('markdown.footnotes') }), [t]) + const labels = useMemo(() => ({ + code: { + copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'), + lineNumbersLabel: t('markdown.code.lineNumbers'), + }, + footnotes: t('markdown.footnotes'), + }), [t]) const plan = resource.value if (plan === undefined) return (
    @@ -26,7 +32,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { ) return (
    -
    diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts index 695ee27386..467170ddef 100644 --- a/packages/client/ui-plan/src/client/plan-resource.ts +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -1,4 +1,4 @@ -/** Read immutable plan arguments through the existing cold-safe Session history API. */ +/** 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' @@ -28,6 +28,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** * 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. */ diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index 61445a1d12..0fcdd8b713 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -94,7 +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 }, + ...(intent.callId === undefined ? {} : { callId: intent.callId }), approve, ...(decline === undefined ? {} : { decline }), } diff --git a/packages/client/ui-user-questions/src/client/markdown-labels.ts b/packages/client/ui-user-questions/src/client/markdown-labels.ts index ff2e62fcaf..414982af25 100644 --- a/packages/client/ui-user-questions/src/client/markdown-labels.ts +++ b/packages/client/ui-user-questions/src/client/markdown-labels.ts @@ -1,4 +1,4 @@ -/** Localized Markdown controls shared by question and plan-review bodies. */ +/** Localized Markdown controls for question details. */ import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type { QuestionComposerProps } from './contract/slots.ts' From 730bcc7c856141e09566c35c835b1109dd6c90a6 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 17 Sep 2026 16:11:04 +0800 Subject: [PATCH 051/225] docs: require lasting rationale before creating agent notes --- .agents/notes/README.i18n.yaml | 4 ++-- .agents/notes/README.md | 10 ++++++++-- .agents/notes/README.zh.md | 10 ++++++++-- .agents/skills/dsh-archive-agent-notes/SKILL.md | 4 ++++ AGENTS.md | 2 +- docs/AGENTS.md | 2 +- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index af337114a0..89763f2488 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md -README.md: 8bf1adf875b54eb4580b4d189dfeed07c9f671c0 -README.zh.md: ff9bdb358d584d4f4ad0aea295c31d8d8fff9376 +README.md: c88ea652570865d7a96f6ce1e332e14dd93b597e +README.zh.md: caa3540f7cfdf750e393324a9f90b1009365d208 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 8bf1adf875..c88ea65257 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -43,9 +43,15 @@ Once sealed, every archived triplet is permanently frozen. Do not edit, translat ## When to write one -Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a contract shared across files or packages, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). +Add or update an Agent Note in the same PR only when a decision has lasting maintenance value: identify a concrete mistake a future maintainer could plausibly make and the non-obvious constraint or genuine trade-off that code, tests, and existing documentation do not explain. Behavior changes, user visibility, file count, and added tests alone do not qualify. Put ordinary change rationale in the PR description and current behavior in its existing documentation. -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no change to behavior, contracts, structure, process, or rationale is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Local UI presentation and interaction changes default to no new note: copy, spacing, colors, icons, status indicators, layout, and visibility conditions. Changes involving persistence, protocols, permissions, cross-component state ownership, or shared interaction rules still use the lasting-value test above; UI code is not a blanket exemption. Removing a redundant status dot while retaining transition indicators needs no note when the reason fits in the PR description and tests capture the behavior. + +Do not create notes that merely expand a change summary, implementation detail, or test inventory, or invent alternatives to fill the template. If a new implemented note would immediately qualify for low-future-value archival, do not create it. + +Update an existing owning note when its facts become stale, even for a local edit; see [implemented/AGENTS.md](implemented/AGENTS.md). Extend that note for rationale about the same decision instead of creating a duplicate. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. + +Qualifying proposals start in `proposed/`; decisions already made start in `implemented/`. Pick the matching [class](#classification). An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index ff9bdb358d..caa3540f7c 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -47,9 +47,15 @@ ## 何时需要写一份 -每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包约定、流程或工具、测试策略、磁盘存储格式、协议格式(wire format)或配置格式,或者维护者可能合理重新审视的其他决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 +只有决策具有长期维护价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note:指出未来维护者可能犯的具体错误,以及代码、测试和现有文档未能解释的非显然约束或真实取舍。行为变化、用户可见、文件数量和新增测试本身均不构成理由。普通变更的理由写在 PR 描述中,当前行为写在其现有文档中。 -更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、约定、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 +局部 UI 展示和交互调整默认不新增 note,包括文案、间距、颜色、图标、状态标记、布局和显隐条件。涉及持久化、协议、权限、跨组件状态归属或共享交互规则的改动,仍须按上述长期价值标准判断;UI 代码并非一概豁免。移除冗余状态圆点并保留过渡状态标记,若其理由可以在 PR 描述中说清且测试已覆盖行为,就无需 note。 + +不得把变更摘要、实现细节或测试清单扩写成 note,也不得为填满模板编造备选方案。如果一份新 implemented note 会立即符合低未来价值的归档条件,就不要创建它。 + +已有决策记录中的事实过时时,即使是局部编辑也必须同步更新;见 [implemented/AGENTS.md](implemented/AGENTS.md)。同一决策的理由补充到已有记录中,不要创建重复记录。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。 + +符合标准的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择匹配的[类别](#classification)。 被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index ef20de8097..e98f290997 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -11,6 +11,10 @@ Reduce the active decision corpus without erasing history that can still guide w Read [the Agent Note rules](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. +## Check whether a note is needed + +Apply the [creation criteria](../../notes/README.md#when-to-write-one) before drafting. Local UI changes default to no new note. Require a concrete future maintenance mistake and rationale absent from code, tests, and existing docs; user-visible behavior or added tests alone do not qualify. Do not create a note that would immediately belong in the low-future-value archive. Keep existing owning notes accurate. + ## Check supersession when adding a note Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. diff --git a/AGENTS.md b/AGENTS.md index 15e2ec41f4..4e01b5dc24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. -- **Non-trivial changes MUST include an Agent Note in the same PR;** only mechanical/local edits are exempt ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). +- **Create Agent Notes only for durable decision rationale;** local UI changes default to no new note ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 902a1510ed..e8680bf1c3 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -37,7 +37,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb ## Writing rules - **Document current state.** Keep history in commits, PRs, Agent Notes, postmortems, or scoped persistence records. Other prose names live mechanisms, not changes or stack positions. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence. -- **Every non-trivial change includes at least one Agent Note in the same PR.** Update the owning note or add one; only mechanical/local edits are exempt ([scope](../.agents/notes/README.md#when-to-write-one)). +- **Apply the Agent Note creation criteria.** Local UI changes default to no new note; keep existing owning notes accurate ([scope](../.agents/notes/README.md#when-to-write-one)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). From 926534c44b2f5aa20c34a8c87619e6da526194d9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 17 Sep 2026 16:18:11 +0800 Subject: [PATCH 052/225] docs: keep agent note criteria edits minimal --- .agents/notes/README.i18n.yaml | 4 ++-- .agents/notes/README.md | 10 ++-------- .agents/notes/README.zh.md | 10 ++-------- .agents/skills/dsh-archive-agent-notes/SKILL.md | 6 +----- 4 files changed, 7 insertions(+), 23 deletions(-) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 89763f2488..f1307cf9ed 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md -README.md: c88ea652570865d7a96f6ce1e332e14dd93b597e -README.zh.md: caa3540f7cfdf750e393324a9f90b1009365d208 +README.md: 0b2dac40e7879d987a465d79f53e156b9621b350 +README.zh.md: 41433c2380663a51cddd9bcae7d83fabfdde4eb6 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index c88ea65257..0b2dac40e7 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -43,15 +43,9 @@ Once sealed, every archived triplet is permanently frozen. Do not edit, translat ## When to write one -Add or update an Agent Note in the same PR only when a decision has lasting maintenance value: identify a concrete mistake a future maintainer could plausibly make and the non-obvious constraint or genuine trade-off that code, tests, and existing documentation do not explain. Behavior changes, user visibility, file count, and added tests alone do not qualify. Put ordinary change rationale in the PR description and current behavior in its existing documentation. +Add or update an Agent Note in the same PR only for lasting decision rationale that code, tests, and existing documentation do not explain. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Local UI presentation and interaction changes default to no new note: copy, spacing, colors, icons, status indicators, layout, and visibility conditions. Changes involving persistence, protocols, permissions, cross-component state ownership, or shared interaction rules still use the lasting-value test above; UI code is not a blanket exemption. Removing a redundant status dot while retaining transition indicators needs no note when the reason fits in the PR description and tests capture the behavior. - -Do not create notes that merely expand a change summary, implementation detail, or test inventory, or invent alternatives to fill the template. If a new implemented note would immediately qualify for low-future-value archival, do not create it. - -Update an existing owning note when its facts become stale, even for a local edit; see [implemented/AGENTS.md](implemented/AGENTS.md). Extend that note for rationale about the same decision instead of creating a duplicate. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. - -Qualifying proposals start in `proposed/`; decisions already made start in `implemented/`. Pick the matching [class](#classification). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Mechanical edits and local UI presentation or interaction changes require no new note unless they introduce such rationale; a behavior change alone does not qualify. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index caa3540f7c..41433c2380 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -47,15 +47,9 @@ ## 何时需要写一份 -只有决策具有长期维护价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note:指出未来维护者可能犯的具体错误,以及代码、测试和现有文档未能解释的非显然约束或真实取舍。行为变化、用户可见、文件数量和新增测试本身均不构成理由。普通变更的理由写在 PR 描述中,当前行为写在其现有文档中。 +只有代码、测试和现有文档未能解释的决策理由具有长期价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -局部 UI 展示和交互调整默认不新增 note,包括文案、间距、颜色、图标、状态标记、布局和显隐条件。涉及持久化、协议、权限、跨组件状态归属或共享交互规则的改动,仍须按上述长期价值标准判断;UI 代码并非一概豁免。移除冗余状态圆点并保留过渡状态标记,若其理由可以在 PR 描述中说清且测试已覆盖行为,就无需 note。 - -不得把变更摘要、实现细节或测试清单扩写成 note,也不得为填满模板编造备选方案。如果一份新 implemented note 会立即符合低未来价值的归档条件,就不要创建它。 - -已有决策记录中的事实过时时,即使是局部编辑也必须同步更新;见 [implemented/AGENTS.md](implemented/AGENTS.md)。同一决策的理由补充到已有记录中,不要创建重复记录。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。 - -符合标准的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择匹配的[类别](#classification)。 +更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。机械性编辑和局部 UI 展示或交互调整无需新增 note,除非引入了上述决策理由;行为变化本身不构成理由。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 diff --git a/.agents/skills/dsh-archive-agent-notes/SKILL.md b/.agents/skills/dsh-archive-agent-notes/SKILL.md index e98f290997..a08e4b5179 100644 --- a/.agents/skills/dsh-archive-agent-notes/SKILL.md +++ b/.agents/skills/dsh-archive-agent-notes/SKILL.md @@ -11,13 +11,9 @@ Reduce the active decision corpus without erasing history that can still guide w Read [the Agent Note rules](../../notes/README.md), [the archive instructions](../../notes/archived/AGENTS.md), and the applicable active lifecycle instructions before classifying. Use current code, configuration, package docs, generated catalogs, newer Agent Notes, and inbound links to establish whether a rationale still owns or constrains anything. -## Check whether a note is needed - -Apply the [creation criteria](../../notes/README.md#when-to-write-one) before drafting. Local UI changes default to no new note. Require a concrete future maintenance mistake and rationale absent from code, tests, and existing docs; user-visible behavior or added tests alone do not qualify. Do not create a note that would immediately belong in the low-future-value archive. Keep existing owning notes accurate. - ## Check supersession when adding a note -Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. +Before drafting, apply the [creation criteria](../../notes/README.md#when-to-write-one). Every new Agent Note triggers a scoped audit of active notes covering the same decision, mechanism, or rejected alternative. Classify each full or partial supersession while writing the new note: archive qualifying implemented triplets in the same PR, retain and cross-link partial supersessions or independently useful rationale, reject obsolete proposals, and delete rejected notes that no longer prevent a plausible mistake. Apply the Agent Note consolidation rule when the new owner absorbs every unique proposition; do not defer a known match to a later corpus audit. ## Classify by future value From adce1b4b5c87fcc75d9fbcad47344ec687ef342d Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 17 Sep 2026 16:23:18 +0800 Subject: [PATCH 053/225] docs: preserve mechanical and local edit exemptions --- .agents/notes/README.i18n.yaml | 4 ++-- .agents/notes/README.md | 2 +- .agents/notes/README.zh.md | 2 +- AGENTS.md | 2 +- docs/AGENTS.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index f1307cf9ed..c07b56468c 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md -README.md: 0b2dac40e7879d987a465d79f53e156b9621b350 -README.zh.md: 41433c2380663a51cddd9bcae7d83fabfdde4eb6 +README.md: 9fdb4039b525bbc4051ab3c8058b117e4de5f2f4 +README.zh.md: 2d7e07b963d3c4d77da9148b011e1478fa2ae5da diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 0b2dac40e7..9fdb4039b5 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -45,7 +45,7 @@ Once sealed, every archived triplet is permanently frozen. Do not edit, translat Add or update an Agent Note in the same PR only for lasting decision rationale that code, tests, and existing documentation do not explain. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Mechanical edits and local UI presentation or interaction changes require no new note unless they introduce such rationale; a behavior change alone does not qualify. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Mechanical or local edits, including local UI presentation and interaction changes, are exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, required verification, and named coverage gap; repair every inbound link; and delete the Chinese counterpart and consistency record in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 41433c2380..2d7e07b963 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -49,7 +49,7 @@ 只有代码、测试和现有文档未能解释的决策理由具有长期价值时,才在同一 PR(Pull Request)中新增或更新 Agent Note。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。机械性编辑和局部 UI 展示或交互调整无需新增 note,除非引入了上述决策理由;行为变化本身不构成理由。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 +更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。机械性或局部编辑均可豁免,包括局部 UI 展示和交互调整。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、必需的验证和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件和一致性记录。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 diff --git a/AGENTS.md b/AGENTS.md index 4e01b5dc24..ce14413b4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -147,7 +147,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Ban `prove` + `nance`** ([rule](.agents/notes/implemented/process/2026-08-26-ban-ambiguous-origin-label.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. -- **Create Agent Notes only for durable decision rationale;** local UI changes default to no new note ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). +- **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). diff --git a/docs/AGENTS.md b/docs/AGENTS.md index e8680bf1c3..56485c7234 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -37,7 +37,7 @@ Placement: bugs → postmortems; rationale → Agent Notes; procedures → cookb ## Writing rules - **Document current state.** Keep history in commits, PRs, Agent Notes, postmortems, or scoped persistence records. Other prose names live mechanisms, not changes or stack positions. General Session-format prose links [version/status authority](session-format-status.md); retain numbers for version-specific contracts, examples, or evidence. -- **Apply the Agent Note creation criteria.** Local UI changes default to no new note; keep existing owning notes accurate ([scope](../.agents/notes/README.md#when-to-write-one)). +- **Apply the Agent Note creation criteria.** Mechanical/local edits are exempt, including local UI changes; keep existing owning notes accurate ([scope](../.agents/notes/README.md#when-to-write-one)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. - **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc use ` ```ts type-equiv `, while a body-stripped public class declaration uses ` ```ts public-api `; register either in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The owning [subsystems page](subsystems/README.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types; a type is documented on its declaring package group's page ([page scoping](../.agents/notes/implemented/process/2026-08-03-package-anchored-subsystem-pages.md)). From 4c546124b95ce0d9bfac04deb552ac7dd3bc4e33 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 17 Sep 2026 16:39:53 +0800 Subject: [PATCH 054/225] refactor(web): route Markdown file navigation through delegate provider --- ...9-15-markdown-file-preview-links.i18n.yaml | 4 +- .../2026-09-15-markdown-file-preview-links.md | 6 +- ...26-09-15-markdown-file-preview-links.zh.md | 6 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../src/client/chat/AssistantMarkdown.tsx | 5 +- .../src/client/chat/AssistantNodeView.tsx | 1 - .../ui-chat/src/client/chat/ChatView.tsx | 2 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- packages/client/ui-primitives/src/index.ts | 2 +- .../src/markdown/MarkdownDelegate.tsx | 37 +++++-- .../src/markdown/MarkdownText.tsx | 13 +-- .../ui-primitives/src/markdown/render.tsx | 51 +++++----- .../tests/markdown-file-links.client.spec.tsx | 97 ++++++++++++++++--- 17 files changed, 167 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml index 45af01e0ff..182ff386d9 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md -2026-09-15-markdown-file-preview-links.md: 0114e8cf3a7024273ff5973f0fb09a28fc3244cb -2026-09-15-markdown-file-preview-links.zh.md: f872abb18c26716f4a169958bb7215ef4ac16685 +2026-09-15-markdown-file-preview-links.md: d9d535afc5b005ae453bd7623e05c411e4d6932b +2026-09-15-markdown-file-preview-links.zh.md: 63c18e284b785ed5686721a0b04cf73c78c0ceb3 diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md index 0114e8cf3a..d9d535afc5 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.md @@ -10,7 +10,7 @@ Assistant explanations link to existing source files that the turn does not modi ## Decision -Settled Assistant Markdown passes explicit local link destinations to the Chat file opener. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path. +Settled Assistant Markdown passes explicit local link destinations to the Chat file opener through `MarkdownDelegateProvider`. The provider owns both file and HTTP(S) navigation callbacks; nested link components read its current callbacks without adding navigation props to intermediate renderers. The renderer recognizes absolute and workspace-relative paths, decodes percent escapes once, and separates `#L24` or `#L24-L30` into a first-line navigation request. A file control preserves the authored label and shows a file-category icon. It never navigates the browser to the authored path. The existing [Sidebar navigation](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) owns Session addressing, tab reuse, and preview selection. The Host file service retains access checks and missing-file errors. Inline-code produced-file matching remains independent. External URLs retain their protocol allowlist; queries, fragment-only destinations, unsupported fragments, malformed escapes, and invalid line ranges remain inert. @@ -24,8 +24,10 @@ The Web file-reference prompt asks for a link on every existing-file mention out **Add a preview service.** Chat already supplies the required opener and line parameter. +**Thread navigation callbacks through renderer props.** Every new navigation capability would enlarge unrelated intermediate interfaces. Reading the scoped delegate at link components also lets cached Markdown observe handler changes. + **Filename-first or display-only prompt variants.** The three-variant development comparison favored A for occurrence-level link coverage and the user preferred its output. B produced more answers with no missing links, so these observations do not establish a universal winner. Visible `#L` suffixes were rejected in favor of the familiar colon notation; retaining anchor syntax in destinations preserves existing navigation. ## Consequences -Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing and callback wiring; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar. +Source references need no new Session event. The static Web guidance is logged through the existing system-message mechanism. Links become active when the message settles. A range selects its first line; the preview does not highlight a multi-line selection. Unit tests cover destination parsing, shared file and HTTP(S) routing, nested scopes, and callback replacement or removal; the keyless `markdown-file-links` Web snapshot covers file content, colon labels, line navigation, and tab reuse through the shipped composition. The package tests check the guidance in every Web prompt sidecar. diff --git a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md index f872abb18c..63c18e284b 100644 --- a/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md +++ b/.agents/notes/implemented/feature/2026-09-15-markdown-file-preview-links.zh.md @@ -10,7 +10,7 @@ Assistant 讲解会链接到本轮未修改或交付的现有源码文件。若 ## Decision -落定后的 Assistant Markdown 将显式本地链接目标传给 Chat 文件打开器。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。 +落定后的 Assistant Markdown 通过 `MarkdownDelegateProvider` 将显式本地链接目标传给 Chat 文件打开器。Provider 同时拥有文件及 HTTP(S) 导航回调;内部链接组件读取其当前回调,中间渲染器无需增加导航 props。渲染器识别绝对路径和工作区相对路径,对百分号转义解码一次,并将 `#L24` 或 `#L24-L30` 分离为起始行导航请求。文件控件保留原始标签并显示文件类别图标,绝不让浏览器导航到原始路径。 现有[侧栏导航](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)负责 Session 寻址、标签复用及预览选择。Host 文件服务保留访问检查和文件缺失错误。行内代码的产出文件匹配保持独立。外部 URL 保留协议允许列表;查询串、仅有片段的目标、不支持的片段、错误转义及无效行号范围均不可点击。 @@ -24,8 +24,10 @@ Web 文件引用提示词要求链接命令、配置表达式和代码块以外 **增加预览服务。** Chat 已提供所需打开器和行号参数。 +**通过渲染器 props 逐层传递导航回调。** 每种新增导航能力都会扩大无关的中间接口。链接组件读取作用域内的 delegate,也让缓存的 Markdown 能响应回调变化。 + **文件名优先或仅规范显示的提示词。** 三版开发对照中 A 的逐次引用链接覆盖率较高,用户也更偏好其输出。B 完全没有漏链的回答更多,因此这些观察不能证明某版普遍更优。用户选择熟悉的冒号格式,放弃显示 `#L` 后缀;目标保留锚点语法以维持现有导航。 ## Consequences -源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析和回调连接;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。 +源码引用无需新增 Session 事件。静态 Web 指导通过现有系统消息机制记录。链接在消息落定后可用。范围定位到起始行;预览不会高亮多行选区。单元测试覆盖目标解析、共享的文件与 HTTP(S) 路由、嵌套作用域及回调替换或移除;无密钥的 `markdown-file-links` Web 快照通过正式组合覆盖文件内容、冒号标签、行号导航和标签复用。包测试校验每份 Web 提示词 sidecar 中的指导。 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 81c74cefb6..7b0bcb7ce2 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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-chat/README.md -README.md: 5ccbb29d98c5693dd0d9da8a4b6b5a03522c4c57 -README.zh.md: 4013f2894067cc506dcc923eeb5cf82dcf81b855 +README.md: 0d4e3376ffc34247490cc72a268120a9fe3180fa +README.zh.md: 2494e932e1d143f6d167ad6044fa34d8501b367c diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 5ccbb29d98..0d4e3376ff 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -30,7 +30,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Reference previews -Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. +Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` around its node list. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 4013f28940..2494e932e1 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -30,7 +30,7 @@ kind: "package-reference" ## 引用预览 -Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 +Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index 44d2f1f558..3212801e00 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -38,8 +38,6 @@ export interface AssistantMarkdownProps { revealProcess?: (() => void) | undefined /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined - /** Open an authored Markdown file link in the Session's sidebar preview. */ - openFile?: ChatNodeOwnerProps['openFile'] | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -47,7 +45,7 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ blocks, streaming, interrupted, renderMessageImages, - reasoningHidden = false, revealProcess, mentions, openFile, t, + reasoningHidden = false, revealProcess, mentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -81,7 +79,6 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ labels={labels} fileMentions={mentions} pathImages={pathImages} - openFile={openFile} />, ) break diff --git a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx index abdbc89665..399897a9b5 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantNodeView.tsx @@ -35,7 +35,6 @@ export const AssistantNodeView = memo(function AssistantNodeView({ reasoningHidden={reasoningHidden} revealProcess={revealProcess} mentions={mentions} - openFile={openFile} t={t} /> ) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 3ad01f83b3..4c407c8e8a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -782,7 +782,7 @@ export function ChatView({
    )} - + void -const MarkdownDelegateContext = createContext(undefined) +/** Navigation capabilities supplied by the nearest Markdown owner. */ +export interface MarkdownDelegate { + /** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */ + readonly openExternalLink?: MarkdownExternalLinkHandler | undefined + /** + * Open a decoded local destination from settled Markdown; absent handlers leave plain text. + * @param path - Absolute or workspace-relative file path. + * @param options - First line to reveal when the destination specifies a line or range. + */ + readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined +} + +const MarkdownDelegateContext = createContext({}) /** Props for one Markdown navigation scope. */ -export interface MarkdownDelegateProviderProps { +export interface MarkdownDelegateProviderProps extends MarkdownDelegate { readonly children: ReactNode - readonly openExternalLink: MarkdownExternalLinkHandler } /** - * Delegate ordinary Markdown HTTP(S) activation without threading callbacks through renderers. - * @param props - child tree and its link handler. + * Scope Markdown navigation without threading callbacks through renderers. + * Nested providers replace the enclosing capabilities. Handler changes reach cached links. + * @param props - Child tree and its file and HTTP(S) link handlers. * @returns the scoped child tree. */ export function MarkdownDelegateProvider({ children, openExternalLink, + openFile, }: MarkdownDelegateProviderProps): ReactNode { + const delegate = useMemo(() => ({ openExternalLink, openFile }), [openExternalLink, openFile]) return ( - + {children} ) } -/** Read the nearest optional Markdown HTTP(S) navigation delegate. */ -export function useMarkdownExternalLinkDelegate(): MarkdownExternalLinkHandler | undefined { +/** + * Read the nearest Markdown navigation capabilities. + * @returns Owner callbacks, or an empty delegate outside a provider. + */ +export function useMarkdownDelegate(): MarkdownDelegate { return useContext(MarkdownDelegateContext) } diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 67e0ac8980..2977db6042 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -36,7 +36,6 @@ function renderSettled( previews: FencePreviewCatalog | undefined, fileMentions: MarkdownFileMentions | undefined, pathImages: MarkdownPathImages | undefined, - openFile: MarkdownRenderContext['openFile'], ): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() @@ -47,7 +46,6 @@ function renderSettled( previews, fileMentions, pathImages, - openFile, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -178,21 +176,20 @@ class StreamingRenderer { * modified clicks retain native behavior. `variant="compact"` uses secondary * text sizing, uniform bold headings, and tight block spacing; the default * `body` variant uses the full document typography. - * `openFile` enables local Markdown links in settled messages, including - * `#L24` and `#L24-L30` destinations (ranges open at their first line). + * The provider's `openFile` enables local Markdown links in settled messages, + * including `#L24` and `#L24-L30` destinations (ranges open at their first line). * @returns A GFM document with TeX math rendered through KaTeX; raw HTML and * unsafe protocols are disabled. Local links without an opener remain text; * absolute HTTP(S) images render directly. */ export const MarkdownText = memo(function MarkdownText({ - text, streaming = false, labels, fileMentions, pathImages, openFile, variant = 'body', + text, streaming = false, labels, fileMentions, pathImages, variant = 'body', }: { text: string streaming?: boolean labels: MarkdownLabels fileMentions?: MarkdownFileMentions | undefined pathImages?: MarkdownPathImages | undefined - openFile?: MarkdownRenderContext['openFile'] variant?: 'body' | 'compact' }) { const streamRef = useRef(null) @@ -201,14 +198,14 @@ export const MarkdownText = memo(function MarkdownText({ const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, labels, previews, fileMentions, pathImages, openFile) + return renderSettled(text, labels, previews, fileMentions, pathImages) } if (streamRef.current === null || streamLabelsRef.current !== labels) { streamRef.current = new StreamingRenderer(labels, previews) streamLabelsRef.current = labels } return streamRef.current.render(text) - }, [text, streaming, labels, previews, fileMentions, pathImages, openFile]) + }, [text, streaming, labels, previews, fileMentions, pathImages]) return
    {children}
    }) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index ab947fe3a8..3815056e8c 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -31,7 +31,7 @@ import { renderMermaid } from './mermaid.ts' import type { PreviewLabels } from './SourcePreview.tsx' import { renderTexToReact } from './katex.tsx' import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx' -import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx' +import { useMarkdownDelegate } from './MarkdownDelegate.tsx' import type { PositionedBlock } from './incremental.ts' import css from './MarkdownText.module.css' @@ -233,8 +233,6 @@ export interface MarkdownFileMentions { * numbering accumulated in document order while references render. */ export interface MarkdownRenderContext { - /** Opens authored local file links in the owner's preview; settled renders only. */ - readonly openFile?: ((path: string, options?: { line?: number }) => void) | undefined /** Streaming arm: fences highlight incrementally as they grow; TeX (including ```math fences) stays literal until the settled pass. */ readonly streaming: boolean /** Localized fence copy-button labels. */ @@ -403,7 +401,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'link': return renderAnchor( node.url, renderChildren(node.children, { ...context, inLink: true }), key, - !anchorWrapsOnlyImages(node.children), context.openFile, + !anchorWrapsOnlyImages(node.children), context.streaming, ) case 'linkReference': return renderLinkReference(node, key, context) @@ -614,7 +612,7 @@ function MarkdownAnchor({ href, glyph, children }: { readonly glyph: boolean readonly children: ReactNode[] }): ReactNode { - const openExternalLink = useMarkdownExternalLinkDelegate() + const { openExternalLink } = useMarkdownDelegate() const external = ['http:', 'https:'].includes(new URL(href).protocol) const open = external ? openExternalLink : undefined return ( @@ -633,26 +631,35 @@ function MarkdownAnchor({ href, glyph, children }: { ) } -/** Local destinations use the preview callback; external destinations use the URL allowlist. */ -function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, openFile?: MarkdownRenderContext['openFile']): ReactNode { - const file = openFile === undefined ? undefined : parseFileLink(url) - if (file !== undefined && openFile !== undefined) { - return ( - - ) +/** Local destinations use the scoped file delegate after settlement. */ +function renderAnchor(url: string, children: ReactNode[], key: Key, glyph = true, streaming = false): ReactNode { + const file = streaming ? undefined : parseFileLink(url) + if (file !== undefined) { + return {children} } return renderSafeLink(normalizeUri(url), children, key, glyph) } +function MarkdownFileLink({ file, glyph, children }: { + readonly file: { path: string; line?: number } + readonly glyph: boolean + readonly children: ReactNode[] +}): ReactNode { + const { openFile } = useMarkdownDelegate() + if (openFile === undefined) return <>{children} + return ( + + ) +} + /** * The complete inline-code value when it is exactly an absolute HTTP(S) URL * (no surrounding whitespace); anything else stays inert code. @@ -714,7 +721,7 @@ function renderLinkReference( return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } const rendered = renderChildren(node.children, { ...context, inLink: true }) - return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.openFile) + return renderAnchor(definition.url, rendered, key, !anchorWrapsOnlyImages(node.children), context.streaming) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx index ceb7257163..bff4bcac36 100644 --- a/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-file-links.client.spec.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { MarkdownDelegateProvider } from '../src/index.ts' import { MarkdownText } from './markdown-test-components.tsx' afterEach(cleanup) @@ -16,7 +17,11 @@ describe('Markdown file links', () => { ['file%23name%3F.txt', 'file#name?.txt', undefined], ])('opens %s through the preview callback', (target, path, options) => { const openFile = vi.fn() - const view = render() + const view = render( + + + , + ) const link = view.getByRole('button', { name: 'source' }) expect(link.getAttribute('title')).toBe(path) fireEvent.click(link) @@ -25,23 +30,34 @@ describe('Markdown file links', () => { }) it.each(['', '![](https://example.com/image.png)'])('names an empty label %s with the decoded path', (label) => { - const view = render() + const view = render( + + + , + ) expect(view.getByRole('button', { name: 'docs/My Notes.md' })).toBeTruthy() }) it('preserves an image label’s alternative text as the accessible name', () => { - const view = render() + const view = render( + + + , + ) expect(view.getByRole('button', { name: 'diagram' })).toBeTruthy() }) it('handles reference links and code labels without nesting file-mention buttons', () => { const openFile = vi.fn() const resolve = vi.fn() - const view = render() + const view = render( + + + , + ) fireEvent.click(view.getByRole('button', { name: 'see index.ts' })) expect(openFile).toHaveBeenCalledWith('src/index.ts', { line: 3 }) expect(resolve).not.toHaveBeenCalled() @@ -56,7 +72,11 @@ describe('Markdown file links', () => { 'file.ts#L9007199254740992', 'file.ts#L1-L9007199254740992', ])('keeps unsupported destination %s inert', (target) => { const openFile = vi.fn() - const view = render() + const view = render( + + + , + ) expect(view.getByText('source')).toBeTruthy() expect(view.container.querySelector('button, a')).toBeNull() expect(openFile).not.toHaveBeenCalled() @@ -73,14 +93,67 @@ describe('Markdown file links', () => { const first = vi.fn() const second = vi.fn() const text = '[source](src/a.ts)\n\nmore\n\n' - const view = render() + const view = render( + + + , + ) expect(view.queryByRole('button')).toBeNull() - view.rerender() + view.rerender( + + + , + ) fireEvent.click(view.getByRole('button', { name: 'source' })) expect(first).toHaveBeenCalledOnce() - view.rerender() + view.rerender( + + + , + ) fireEvent.click(view.getByRole('button', { name: 'source' })) expect(second).toHaveBeenCalledOnce() expect(first).toHaveBeenCalledOnce() }) + + it('routes file and HTTP(S) links through the same provider', () => { + const openFile = vi.fn() + const openExternalLink = vi.fn() + const view = render( + + + , + ) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(openFile).toHaveBeenCalledExactlyOnceWith('src/a.ts', { line: 4 }) + expect(openExternalLink).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('link', { name: 'web' })) + expect(openExternalLink).toHaveBeenCalledExactlyOnceWith('https://example.com') + expect(openFile).toHaveBeenCalledOnce() + }) + + it('updates cached links when the nearest provider gains, replaces, or removes its file handler', () => { + const outer = vi.fn() + const first = vi.fn() + const second = vi.fn() + const markdown = + const scope = (openFile?: (path: string) => void) => ( + + {markdown} + + ) + const view = render(scope()) + expect(view.queryByRole('button')).toBeNull() + view.rerender(scope(first)) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(first).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined) + view.rerender(scope(second)) + fireEvent.click(view.getByRole('button', { name: 'source' })) + expect(second).toHaveBeenCalledExactlyOnceWith('src/a.ts', undefined) + expect(first).toHaveBeenCalledOnce() + view.rerender(scope()) + expect(view.queryByRole('button')).toBeNull() + expect(view.getByText('source')).toBeTruthy() + expect(outer).not.toHaveBeenCalled() + }) }) From 9ce43f925c94b9cb988b16f92bffd92a54405670 Mon Sep 17 00:00:00 2001 From: Kaige-Gao Date: Thu, 17 Sep 2026 16:40:13 +0800 Subject: [PATCH 055/225] feat(web): add feedback to session header menu --- ...8-feedback-dialog-and-categories.i18n.yaml | 4 +-- ...26-09-08-feedback-dialog-and-categories.md | 2 ++ ...09-08-feedback-dialog-and-categories.zh.md | 2 ++ apps/web/tests/message-feedback.e2e.ts | 36 +++++++++++++++++++ .../ui-message-feedback/README.i18n.yaml | 4 +-- packages/client/ui-message-feedback/README.md | 4 +-- .../client/ui-message-feedback/README.zh.md | 4 +-- .../ui-message-feedback/src/client/index.ts | 22 +++++++++++- .../tests/browser-plugin.client.spec.tsx | 14 ++++++++ .../session-log-export/README.i18n.yaml | 4 +-- .../session-log-export/README.md | 2 ++ .../session-log-export/README.zh.md | 2 ++ .../session-log-export/package.json | 1 + .../src/client/HeaderAction.tsx | 33 +++++++++++++---- .../session-log-export/src/client/index.ts | 17 ++++++--- .../session-log-export/src/client/locales.ts | 2 ++ .../tests/client-apply.client.spec.tsx | 21 +++++++++++ .../tests/header-action.client.spec.tsx | 32 ++++++++++++++--- .../session-log-export/tsconfig.client.json | 1 + pnpm-lock.yaml | 3 ++ scripts/gen-cordis-catalog.ts | 1 + 21 files changed, 184 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml index 16c17f3405..ad7aa6aa6e 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md -2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31 -2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0 +2026-09-08-feedback-dialog-and-categories.md: 271eb080dcca866ddbb5fee9df2d72ef9a1300ee +2026-09-08-feedback-dialog-and-categories.zh.md: 10f662e7d020c283bc250ffc75d71f757ddd6516 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md index ee656946a9..271eb080dc 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -14,6 +14,8 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe `ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor. +The Session Header’s more-actions menu also opens the Session dialog through the `feedbackUi.openSession` Client service. The menu remains owned by `session-log-export`, while the existing per-session feedback controller owns the draft and submission. An observable tracks the optional feedback plugin’s availability, so unloading feedback removes only its menu row and leaves export usable. Opening or dismissing the form writes no feedback event; Submit records one Session remark. + The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md index ef2b2d4670..10f662e7d0 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -14,6 +14,8 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` `ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。 +会话标题栏的更多操作菜单也通过 `feedbackUi.openSession` Client 服务打开 Session 弹窗。菜单仍由 `session-log-export` 持有,现有的逐 Session 反馈控制器持有草稿与提交。一个可观察值跟踪可选反馈插件的可用状态,因此卸载反馈插件只移除其菜单行,导出仍然可用。打开或关闭表单不写反馈事件;点击提交才记录一条 Session 反馈。 + 弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。 ## 考虑过的替代方案 diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index df89180c18..f26202d4b8 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -145,4 +145,40 @@ describe('web e2e: durable per-message feedback', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) + + it.skipIf(MODE === 'record')('opens the shared dialog from the header and records Session feedback only on submit', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-header-feedback')) + await openSeededSession() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const feedbackEvents = () => agent.session.snapshotEvents().filter(event => event.type.startsWith('feedback/')) + const before = feedbackEvents().length + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Download session log' }).waitFor() + await page.getByRole('menuitem', { name: 'Feedback', exact: true }).click() + await dialog.waitFor() + expect(await page.getByRole('menu').count()).toBe(0) + expect(feedbackEvents()).toHaveLength(before) + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('discarded draft') + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + expect(feedbackEvents()).toHaveLength(before) + + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Feedback', exact: true }).click() + await dialog.waitFor() + expect(await dialog.getByRole('textbox', { name: 'Feedback details' }).inputValue()).toBe('') + await dialog.getByRole('button', { name: 'Product features and interaction', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Feedback from the Session menu.') + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count()).toBe(0) + await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor() + expect(feedbackEvents().slice(before)).toMatchObject([ + { type: 'feedback/record', data: { category: 'product-interaction', text: 'Feedback from the Session menu.' } }, + ]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) }) diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index bdd5f1e664..642559b51f 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -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-message-feedback/README.md -README.md: b37278e8d115a2ad61bb17ef8d884b0d888cd6a7 -README.zh.md: cd233061640f5e52ea9ca54ffe973bff3b957197 +README.md: bc33b02c6966bfa851614ef616c1ff9c583ae67d +README.zh.md: 88c1ba476c1a9c2a2df4b6cad471d78af65b83c9 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index b37278e8d1..bc33b02c69 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -25,7 +25,7 @@ This package is the Web GUI's feedback surface: the Like/Dislike pair in the fin ## Use this package -Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and the Feedback row of the composer menu opens the dialog. A recorded rating shows the filled glyph and stays visible without hover. Like and Dislike both open the dialog: seven category chips and a detail box are optional, and Submit records the selected judgment with whatever was filled in before the toast thanks the user; the conversation log travels with every feedback event. Clicking the recorded rating retracts it without opening the dialog. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback ` keeps the Host command path and its acknowledgement row. +Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and Feedback in either the composer menu or the Session Header’s more-actions menu opens the same dialog. A recorded rating shows the filled glyph and stays visible without hover. Like and Dislike both open the dialog: seven category chips and a detail box are optional, and Submit records the selected judgment with whatever was filled in before the toast thanks the user; the conversation log travels with every feedback event. Clicking the recorded rating retracts it without opening the dialog. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback ` keeps the Host command path and its acknowledgement row. ### Failures @@ -39,7 +39,7 @@ A rating or list-load failure shows inline in the row; a submission failure show
    Implementation internals — click to expand -The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row, and the `feedback-dialog` entry (order 2) of `conversation.input.overlay`, which renders the Modal and Toast primitives through body portals and centers the toast over the composer card it mounts inside. The `/feedback` decoration is an `action` registered through `ctx.commandUi.decorate`, so a menu pick or a bare Enter consumes the trigger token and opens the dialog while an argued line still reaches the Host command. +The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row, and the `feedback-dialog` entry (order 2) of `conversation.input.overlay`, which renders the Modal and Toast primitives through body portals and centers the toast over the composer card it mounts inside. The `feedbackUi.openSession(sessionId)` Client service opens that same Session-scoped dialog without recording feedback; the Header menu and command decoration both use it. The `/feedback` decoration is an `action` registered through `ctx.commandUi.decorate`, so a menu pick or a bare Enter consumes the trigger token and opens the dialog while an argued line still reaches the Host command. Per Session, one `MessageFeedbackController` backs every message control and one `FeedbackDialogController` owns the dialog draft, the submission, and the toast sequence. The message controller reads `messageFeedback.list` once, deferred to the first hover or focus rather than fired on mount, and serializes mutations so each carries the version last observed; a `version-conflict` reply carries the authoritative item and reconciles the view without refetching. Before either rating action proceeds, the row checks the committed item: the matching rating calls `retract`, which rechecks the rating in the serialized queue and becomes a no-op after a concurrent change, while any other state opens the dialog with the requested rating. The dialog controller submits by target: a message target puts that rating with the dialog's note and category through the message controller, and the Session target records through `ctx.remote.sessionFeedback`. Success closes the draft and raises the acknowledgement toast; a late success from a superseded draft raises that toast without closing the new draft; a failure keeps the draft open and raises a longer-lived warning toast. diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index cd23306164..88c1ba476c 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞和点踩都会打开弹窗:七个分类标签和一个详情框都可不填;提交后才会记录带所填内容的对应评分并弹出感谢 toast,对话日志随每个反馈事件一起投递。再次点击已记录的评分会直接撤回,不打开弹窗。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback ` 仍走宿主命令路径并显示确认行。 +与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单或会话标题栏更多操作菜单里的「反馈」会打开同一个弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞和点踩都会打开弹窗:七个分类标签和一个详情框都可不填;提交后才会记录带所填内容的对应评分并弹出感谢 toast,对话日志随每个反馈事件一起投递。再次点击已记录的评分会直接撤回,不打开弹窗。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback ` 仍走宿主命令路径并显示确认行。 ### 失败 @@ -39,7 +39,7 @@ kind: "package-reference"
    实现细节——点击展开 -本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内;同时贡献 `conversation.input.overlay` 的 `feedback-dialog` 条目(order 2),它通过 body portal 渲染 Modal 与 Toast 基元,并让 toast 以其所在的输入框卡片为中心。`/feedback` 装饰是经 `ctx.commandUi.decorate` 注册的 `action`,因此菜单选中或不带参数的回车会消费触发 token 并打开弹窗,而带参数的命令行仍到达宿主命令。 +本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内;同时贡献 `conversation.input.overlay` 的 `feedback-dialog` 条目(order 2),它通过 body portal 渲染 Modal 与 Toast 基元,并让 toast 以其所在的输入框卡片为中心。`feedbackUi.openSession(sessionId)` Client 服务打开同一个 Session 级弹窗,不记录反馈;标题栏菜单和命令装饰共用此方法。`/feedback` 装饰是经 `ctx.commandUi.decorate` 注册的 `action`,因此菜单选中或不带参数的回车会消费触发 token 并打开弹窗,而带参数的命令行仍到达宿主命令。 每个 Session 有一个 `MessageFeedbackController` 支撑所有消息控件,以及一个 `FeedbackDialogController` 拥有弹窗草稿、提交与 toast 序号。消息控制器只读取一次 `messageFeedback.list`,且延迟到首次 hover 或 focus 才发起,而非挂载时触发;变更串行执行,每次都携带最后观察到的版本,`version-conflict` 响应带回权威条目,据此对账视图而不重新拉取。任一评分操作执行前,该行都会检查已提交条目:评分相同则调用 `retract`,它会在串行队列内重新检查评分并在并发变更后变为无操作;其他状态则携带所选评分打开弹窗。弹窗控制器按目标提交:消息目标通过消息控制器 put 一条带弹窗备注与分类的对应评分,Session 目标通过 `ctx.remote.sessionFeedback` 记录。成功会关闭草稿并弹出确认 toast;被替换的旧草稿迟到的成功只弹确认 toast、不关闭新草稿;失败会保留弹窗并弹出停留时间更长的警告 toast。 diff --git a/packages/client/ui-message-feedback/src/client/index.ts b/packages/client/ui-message-feedback/src/client/index.ts index 91e6718f62..d421bf4d72 100644 --- a/packages/client/ui-message-feedback/src/client/index.ts +++ b/packages/client/ui-message-feedback/src/client/index.ts @@ -37,6 +37,21 @@ export type { } from './slots.ts' export type { MessageFeedbackKey } from './locales.ts' +/** Opens the existing Session feedback form without submitting feedback. */ +export interface FeedbackUi { + /** + * Open the Session feedback draft without recording feedback. + * @param sessionId - Session whose feedback draft to open. + */ + openSession(sessionId: SessionId): void +} + +declare module '@deepseek-ai/cordis' { + interface Context { + feedbackUi: FeedbackUi + } +} + /** Dictionary namespace owned by this plugin. */ const NS = 'feedback' @@ -65,6 +80,11 @@ export function apply(ctx: ClientContext): void { surfaces.clear() }, 'ui-message-feedback: per-session surfaces') + const feedbackUi: FeedbackUi = { + openSession: (sessionId) => { surfaceFor(sessionId).dialog.open({ kind: 'session' }) }, + } + ctx.provide('feedbackUi', feedbackUi) + // A reconnect can only invalidate what was already read; a cold Session // stays cold until something asks for it. ctx.on('connection/reset', () => { @@ -114,7 +134,7 @@ export function apply(ctx: ClientContext): void { scope.effect(() => scope.commandUi.decorate({ name: 'feedback', available: () => true, - ui: { kind: 'action', run: (session) => { surfaceFor(session.sessionId).dialog.open({ kind: 'session' }) } }, + ui: { kind: 'action', run: (session) => { feedbackUi.openSession(session.sessionId) } }, }), 'ui-message-feedback: /feedback decoration') }) } diff --git a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx index 2bad94b036..e41e7d599a 100644 --- a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx @@ -164,6 +164,20 @@ describe('ui-message-feedback browser plugin', () => { expect(b.dialogEntry()!.inject!(sid('s2')).hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 0 }) }) + it('opens the shared Session dialog through feedbackUi without recording anything', async () => { + const b = await bench() + await b.fiber.await() + const dialog = b.dialogEntry()!.inject!(sid('s1')) + b.ctx.feedbackUi.openSession(sid('s1')) + expect(dialog.hooks.dialog.getSnapshot().target).toEqual({ kind: 'session' }) + expect(b.dialogEntry()!.inject!(sid('s2')).hooks.dialog.getSnapshot().target).toBeNull() + expect(b.calls).toEqual([]) + dialog.dismiss() + expect(b.calls).toEqual([]) + await b.fiber.dispose() + expect(b.ctx.get('feedbackUi')).toBeUndefined() + }) + it('records a Session remark through the sessionFeedback Remote and a message judgment through put', async () => { const b = await bench() await b.fiber.await() diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ae845c1725..3e072029dd 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -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/session-query/session-log-export/README.md -README.md: 8697b0c2c105c928f5325f953b7fb663ae68e18e -README.zh.md: fe60c6ab8cc8d2d5e7515416bb72624584c11b9d +README.md: 23388e10b9c64071a666e9842a669e37d6b9545d +README.zh.md: 2af96f8621745fd6393f03905e4c6a978dbaf400 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 8697b0c2c1..23388e10b9 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then choose `Download session log` from the Session Header's more-actions menu or type `/export`; the browser downloads `dsh-session-.zip`. +When `ui-message-feedback` is mounted, the same menu also offers `Feedback`, which opens its existing Session feedback dialog. Opening or dismissing that form does not export the Session or submit feedback. The feedback row follows the feedback plugin’s availability; export remains available independently. + ### When to choose it Choose it for a Web deployment that needs user-facing session export with a visible download dialog. Avoid it when a programmatic or Host-side export is needed: this package produces a browser download, not a Host path write. The logs are serialized from persistence read handles, so any mounted backend is supported. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index fe60c6ab8c..2af96f8621 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后在 Session Header 的更多操作菜单中选择 `下载 Session 日志` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 +挂载 `ui-message-feedback` 时,同一菜单还提供“反馈”,打开已有的 Session 反馈弹窗。打开或关闭该弹窗不会导出 Session 或提交反馈。反馈入口随反馈插件的可用状态显示;导出功能保持独立可用。 + ### 何时选择 为需要带可见下载弹窗的面向用户的会话导出的 Web 部署选择它。需要程序化或 Host 侧导出时避免使用:本包产生的是浏览器下载,而非 Host 路径写入。日志从持久化读句柄序列化而来,因此任何已挂载后端都受支持。 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 75d743253d..5edbe45d8e 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -56,6 +56,7 @@ "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", diff --git a/packages/session-query/session-log-export/src/client/HeaderAction.tsx b/packages/session-query/session-log-export/src/client/HeaderAction.tsx index cb20cb9f9d..5e5b472659 100644 --- a/packages/session-query/session-log-export/src/client/HeaderAction.tsx +++ b/packages/session-query/session-log-export/src/client/HeaderAction.tsx @@ -1,16 +1,31 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import { IconDownloadOutline16, IconEllipsisOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { IconDownloadOutline16, IconEllipsisOutline16, IconPaperPlaneOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import { SessionLogDownloadDialog, type SessionLogDownloadDialogProps } from './Dialog.tsx' +import type { SessionLogDownloadDialogInjected } from './Dialog.tsx' import css from './HeaderAction.module.css' +/** The optional feedback plugin controls whether the Header offers its form. */ +export interface SessionLogDownloadHeaderInjected extends SessionLogDownloadDialogInjected { + hooks: SessionLogDownloadDialogInjected['hooks'] & { feedbackAvailable: ObservableSnapshot } + /** @param sessionId - Session whose existing feedback form to open. */ + openFeedback: (sessionId: SessionId) => void +} + +/** Session download props plus the optional feedback action. */ +export type SessionLogDownloadHeaderProps = SessionLogDownloadDialogProps & InjectFace + /** - * Render the Session Header more-actions icon button, its download menu, and the shared result dialog. + * Render the Session Header menu with download and optional feedback actions. * @param props - Session runtime, download controller, and localized copy. * @returns the persistent Header action and Session-scoped dialog. */ -export function SessionLogDownloadHeaderAction(props: SessionLogDownloadDialogProps): ReactNode { - const { sessionId, useSessionLogDownload, request, t } = props +export function SessionLogDownloadHeaderAction(props: SessionLogDownloadHeaderProps): ReactNode { + const { sessionId, useSessionLogDownload, useFeedbackAvailable, request, openFeedback, t } = props + const feedbackAvailable = useFeedbackAvailable(value => value) const entry = useSessionLogDownload(state => state.bySession[String(sessionId)]) const busy = entry?.status === 'downloading' const [open, setOpen] = useState(false) @@ -22,10 +37,14 @@ export function SessionLogDownloadHeaderAction(props: SessionLogDownloadDialogPr align="end" dense onClose={() => { setOpen(false) }} - items={[{ id: 'download', label: t('menu.download'), icon: , disabled: busy }]} - onSelect={() => { + items={[ + { id: 'download', label: t('menu.download'), icon: , disabled: busy }, + ...feedbackAvailable ? [{ id: 'feedback', label: t('menu.feedback'), icon: }] : [], + ]} + onSelect={(id) => { setOpen(false) - void request(sessionId) + if (id === 'feedback') openFeedback(sessionId) + else void request(sessionId) }} anchor={(
    diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index bad2e8a847..a253d2257a 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -35,6 +35,8 @@ kind: "package-reference" 回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 +没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。 + ### 失败 准入失败(`matched: false`、业务错误、传输故障)以内联错误呈现,徽章保持显示直至投影确认退出。 @@ -49,7 +51,7 @@ 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)说明审批与文档为何保持独立生命周期。 +计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。
    diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 1fc5b2f465..1a77508ca4 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -70,7 +70,8 @@ "@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-tools": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index 25e61f2356..74e65cdefe 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -1,4 +1,4 @@ -/** Persistent transcript card and pending-review action use the same plan resource opener. */ +/** 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' @@ -10,12 +10,18 @@ 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 injected into both plan entry points. */ +/** 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. @@ -47,17 +53,16 @@ export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversa /** * 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 when the asker supplied a logged invocation identity. + * @returns an opener for either logged or temporary plan text. */ -export function PlanReviewOpen({ review, openPlan, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { - const callId = review.callId - const opened = useStore(state => callId !== undefined && state.opened[callId] === true) +export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace & PropsLocale<'plan'> & PropsStore>) { + const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}` + const opened = useStore(state => state.opened[identity] === true) useEffect(() => { - if (callId === undefined || opened) return - actions.markOpened(callId) - openPlan(callId) - }, [callId, opened, openPlan, actions]) - if (callId === undefined) return null + if (opened) return + openReview(review, requestKey) + actions.markOpened(identity) + }, [identity, opened, openReview, review, requestKey, actions]) return + onClick={() => { openReview(review, requestKey) }}>{t('preview.full')} } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index ff0ad857b8..30e09030ed 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -1,9 +1,11 @@ -/** Read-only Markdown viewer for an exact plan resource. */ +/** Read-only Markdown viewer for logged plans and temporary review documents. */ import { useMemo } from 'react' import { IconCopyOutline16, 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'> @@ -16,6 +18,9 @@ type PlanPreviewProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsLocale<'pl 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'), sourceLabel: t('markdown.code.source'), @@ -23,15 +28,15 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { }, footnotes: t('markdown.footnotes'), }), [t]) - const plan = resource.value if (plan === undefined) return (
    - {resource.status === 'failed' ? t('preview.failed') : t('preview.loading')} - {resource.failure !== undefined &&

    {resource.failure.message}

    } + {temporary ? t('preview.expired') : resource.status === 'none' ? t('preview.unavailable') + : resource.status === 'failed' ? t('preview.failed') : t('preview.loading')} + {!temporary && resource.failure !== undefined &&

    {planFailureLine(t, resource.failure)}

    }
    ) return ( -
    +
    @@ -47,5 +52,8 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.right.pane.tab.title'>) { const tab = useTabInfo() const resource = useResource<'plan'>(tab.tab.navigation.address) - return {resource.value?.title ?? tab.tab.title} + const params = tab.tab.navigation.params + const plan = isReviewPreviewAddress(tab.tab.navigation.address) + ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value + return {plan?.title ?? tab.tab.title} } diff --git a/packages/client/ui-plan/src/client/failure-line.ts b/packages/client/ui-plan/src/client/failure-line.ts new file mode 100644 index 0000000000..f0dd9f74a7 --- /dev/null +++ b/packages/client/ui-plan/src/client/failure-line.ts @@ -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 + } +} diff --git a/packages/client/ui-plan/src/client/index.ts b/packages/client/ui-plan/src/client/index.ts index 5b7998e794..4a69be9469 100644 --- a/packages/client/ui-plan/src/client/index.ts +++ b/packages/client/ui-plan/src/client/index.ts @@ -15,11 +15,14 @@ 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 { PlanCards, PlanReviewOpen, type PlanOpenInjected } from './PlanCard.tsx' +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' @@ -46,7 +49,7 @@ export interface PlanChipInjected { } /** Services for plan controls, Conversation projection, and resource navigation. */ -export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'] +export const inject = ['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs'] /** * Register plan controls, permanent Chat cards, and sidebar document reading. @@ -60,19 +63,32 @@ export function apply(ctx: ClientContext): void { 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/**'], priority: 'builtin', - canOpen: address => parsePlanAddress(address) !== undefined, + 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) => { ctx.sidebarRight.openResourceIn(sessionId, planAddress({ sessionId, callId })) }, + 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, inject: open, store: reviewStore, + 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, diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index bf5fa70b18..96c15a0237 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -11,6 +11,11 @@ export const zh = { '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 已关闭,按下开启', @@ -32,6 +37,11 @@ export const en = { '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', diff --git a/packages/client/ui-plan/src/client/plan-resource.ts b/packages/client/ui-plan/src/client/plan-resource.ts index 467170ddef..95ec5dff30 100644 --- a/packages/client/ui-plan/src/client/plan-resource.ts +++ b/packages/client/ui-plan/src/client/plan-resource.ts @@ -45,7 +45,7 @@ export function planResourceProvider(remote: Pick | undefined diff --git a/packages/client/ui-plan/src/client/plan.ts b/packages/client/ui-plan/src/client/plan.ts index d83c5c23ce..e3dff30e29 100644 --- a/packages/client/ui-plan/src/client/plan.ts +++ b/packages/client/ui-plan/src/client/plan.ts @@ -1,17 +1,22 @@ /** 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' -/** One submitted plan, identified by its originating tool invocation. */ -export interface SubmittedPlan { - readonly callId: ToolCallId +/** 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 sessionId: SessionId + readonly session: SessionAddress readonly callId: ToolCallId } @@ -48,7 +53,11 @@ export function submittedPlan(event: { readonly type: string; readonly data: unk * @returns the plan resource address. */ export function planAddress(target: PlanAddress): string { - return `dsh-resource://plan/${encodeURIComponent(target.sessionId)}/${encodeURIComponent(target.callId)}` + 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('/')}` } /** @@ -57,10 +66,19 @@ export function planAddress(target: PlanAddress): string { * @returns the decoded identity, or undefined for an unsupported address. */ export function parsePlanAddress(address: string): PlanAddress | undefined { - const match = /^dsh-resource:\/\/plan\/([^/?#]+)\/([^/?#]+)$/.exec(address) + const match = /^dsh-resource:\/\/plan\/([^?#]+)$/.exec(address) if (match === null) return undefined try { - return { sessionId: decodeURIComponent(match[1] as string) as SessionId, callId: decodeURIComponent(match[2] as string) as ToolCallId } + 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 diff --git a/packages/client/ui-plan/src/client/review-preview.ts b/packages/client/ui-plan/src/client/review-preview.ts new file mode 100644 index 0000000000..19307c8c7b --- /dev/null +++ b/packages/client/ui-plan/src/client/review-preview.ts @@ -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) +} diff --git a/packages/client/ui-plan/src/client/review-store.ts b/packages/client/ui-plan/src/client/review-store.ts index d3a4676534..b35b5daa39 100644 --- a/packages/client/ui-plan/src/client/review-store.ts +++ b/packages/client/ui-plan/src/client/review-store.ts @@ -1,13 +1,12 @@ /** Session-owned memory of pending plans already opened automatically. */ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store' -import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' interface PlanReviewState { - opened: Partial> + opened: Partial> } type PlanReviewActions = { - markOpened: (draft: PlanReviewState, callId: ToolCallId) => void + markOpened: (draft: PlanReviewState, reviewKey: string) => void } /** @@ -18,7 +17,7 @@ export function createPlanReviewStore(): EngineStoreHandle ({ opened: {} }), actions: { - markOpened: (draft, callId) => { draft.opened[callId] = true }, + markOpened: (draft, reviewKey) => { draft.opened[reviewKey] = true }, }, }) } diff --git a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts index bd83cb2e87..bbc55871ed 100644 --- a/packages/client/ui-plan/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.client.spec.ts @@ -13,7 +13,7 @@ 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 } from '../src/client/PlanCard.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' @@ -26,12 +26,14 @@ function providePreview(ctx: Context) { const removeResources = vi.fn() const removeType = vi.fn() const registerType = vi.fn((_definition: Parameters[0]) => removeType) - const openResourceIn = vi.fn() + const openResourceIn = vi.fn() + const subagentAddress = vi.fn(() => 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 } + return { events, removeResources, removeType, registerType, openResourceIn, subagentAddress } } const SID = 's-plan' as SessionId @@ -62,7 +64,7 @@ async function bench() { describe('ui-plan browser apply', () => { it('declares every service it binds', () => { - expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']) + expect(inject).toEqual(['slots', 'remote', 'remote.commands', 'remote.session', 'sessions', 'locale', 'uiConversation', 'resources', 'sidebarRight', 'sidebarRightTabs']) }) it('node-half apply is an intentional no-op', () => { @@ -130,13 +132,33 @@ describe('ui-plan browser apply', () => { 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"}' } })! - for (const [slot, component] of [['conversation.chat.turnTail', PlanCards], ['conversation.plan-review.actions', PlanReviewOpen]] as const) { - const entry = b.slots.entries(slot)[0]! - expect(entry.component).toBe(component) - const injected = (entry.inject as unknown as (sessionId: SessionId) => PlanOpenInjected)(SID) - injected.openPlan(plan.callId) - expect(b.openResourceIn).toHaveBeenLastCalledWith(SID, address) - } + 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() diff --git a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx index c907f83ef9..22b2fb006e 100644 --- a/packages/client/ui-plan/tests/plan-preview.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-preview.client.spec.tsx @@ -12,6 +12,7 @@ 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' @@ -19,7 +20,7 @@ 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 = { sessionId: 'session / 中文' as SessionId, callId: plan.callId } +const target = { session: { kind: 'session' as const, sessionId: 'session / 中文' as SessionId }, callId: plan.callId } const t = makeTranslate(en, commonEn) function planNode(data: typeof plan) { @@ -52,7 +53,12 @@ describe('submitted plan identity', () => { }) it('round-trips opaque identifiers and refuses malformed addresses', () => { expect(parsePlanAddress(planAddress(target))).toEqual(target) - 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']) { + 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() } }) @@ -154,44 +160,56 @@ describe('plan entry points and document', () => { view.rerender() expect(view.container.innerHTML).toBe('') }) - it('opens the review without approving or cancelling it', () => { - const openPlan = vi.fn() + 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 stateProps = { - actions: store.actions, - useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), - } - const props = { review: { callId: plan.callId }, t, openPlan, ...stateProps } as unknown as Parameters[0] - const view = render() - expect(openPlan).toHaveBeenCalledExactlyOnceWith(plan.callId) - view.rerender() - expect(openPlan).toHaveBeenCalledTimes(1) - fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' })) - expect(openPlan).toHaveBeenCalledTimes(2) - view.rerender([0]} />) - expect(screen.queryByRole('button')).toBeNull() - }) - it('preserves manual closure across remounts and opens a new submission', () => { - const store = createPlanReviewStore().create() - const openPlan = vi.fn() - const props = { review: { callId: plan.callId }, t, openPlan, actions: store.actions, + const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) } + const props = { review, requestKey: 'question:1', t, openReview, actions: store.actions, useStore: (select: (state: ReturnType) => unknown) => select(store.getSnapshot()), } as unknown as Parameters[0] const first = render() + expect(openReview).toHaveBeenCalledExactlyOnceWith(review, 'question:1') + first.rerender() first.unmount() const second = render() - expect(openPlan).toHaveBeenCalledTimes(1) - const revised = submittedPlan({ ...call, data: { ...call.data, callId: 'call:2' } })! - const nextProps = { ...props, review: { callId: revised.callId } } as unknown as Parameters[0] - second.rerender() - expect(openPlan).toHaveBeenLastCalledWith(revised.callId) - expect(openPlan).toHaveBeenCalledTimes(2) + 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([0]} />) + expect(openReview).toHaveBeenLastCalledWith(revised, 'question:2') + expect(openReview).toHaveBeenCalledTimes(3) second.unmount() const other = createPlanReviewStore().create() render() => unknown) => select(other.getSnapshot()), } as unknown as Parameters[0]} />) - expect(openPlan).toHaveBeenCalledTimes(3) + 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([0]} />) + expect(screen.getByRole('heading', { name: plan.title })).toBeTruthy() + expect(screen.getByText('Implement')).toBeTruthy() + view.rerender([0]} />) + expect(screen.getByText(plan.title)).toBeTruthy() + view.rerender( ({ tab: { title: 'Plan', navigation: { address } } }), + } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(en['preview.expired']) + view.rerender( ({ tab: { title: 'Plan', navigation: { address } } }), + } as unknown as Parameters[0]} />) + expect(screen.getByText('Plan')).toBeTruthy() + view.rerender( ({ tab: { title: 'Plan', navigation: { address, params: { line: 2 } } } }), + } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(en['preview.expired']) }) it('shows restored Markdown and its heading as the tab title', () => { const props = { @@ -222,12 +240,23 @@ describe('plan entry points and document', () => { else Object.defineProperty(navigator, 'clipboard', clipboard) } }) - it('shows loading and failed reads without an empty sidebar', () => { - const props = { t, useTabInfo: () => ({ tab: { title: 'Plan', navigation: { address: planAddress(target) } } }) } + 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( ({ status: 'loading' }) } as unknown as Parameters[0]} />) - expect(screen.getByRole('status').textContent).toBe('Loading plan…') - view.rerender( ({ status: 'failed', failure: { message: 'gone' } }) } as unknown as Parameters[0]} />) - expect(screen.getByRole('status').textContent).toContain('Could not load plan') - expect(screen.getByText('gone')).toBeTruthy() + expect(screen.getByRole('status').textContent).toBe(dictionary['preview.loading']) + view.rerender( ({ status: 'none' }) } as unknown as Parameters[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( ({ status: 'failed', failure: { code, message: 'connection lost' } }) } as unknown as Parameters[0]} />) + expect(screen.getByRole('status').textContent).toBe(dictionary['preview.failed'] + message) + } }) }) diff --git a/packages/client/ui-plan/tests/plan-resource.client.spec.ts b/packages/client/ui-plan/tests/plan-resource.client.spec.ts index 700339f74f..a27b03c185 100644 --- a/packages/client/ui-plan/tests/plan-resource.client.spec.ts +++ b/packages/client/ui-plan/tests/plan-resource.client.spec.ts @@ -36,6 +36,15 @@ describe('plan history resource', () => { 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' } }]) diff --git a/packages/client/ui-plan/tsconfig.json b/packages/client/ui-plan/tsconfig.json index d3381e24b2..9ab7d2fed4 100644 --- a/packages/client/ui-plan/tsconfig.json +++ b/packages/client/ui-plan/tsconfig.json @@ -61,6 +61,9 @@ }, { "path": "../../core/tools" + }, + { + "path": "../../util/crypto" } ] } diff --git a/packages/client/ui-user-questions/README.i18n.yaml b/packages/client/ui-user-questions/README.i18n.yaml index a6985f3998..ae9408ee1d 100644 --- a/packages/client/ui-user-questions/README.i18n.yaml +++ b/packages/client/ui-user-questions/README.i18n.yaml @@ -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: a734be6a2c9464d5dc6c8696f5d7287eaf6d9d0d -README.zh.md: 38c62a969cc4440a690969a12400511489c0495a +README.md: abbf855706b478857540b12712c5999082b0ab22 +README.zh.md: 995035cbb08ff6c64e2b91b61ab49b2bd473b9b7 diff --git a/packages/client/ui-user-questions/README.md b/packages/client/ui-user-questions/README.md index a734be6a2c..abbf855706 100644 --- a/packages/client/ui-user-questions/README.md +++ b/packages/client/ui-user-questions/README.md @@ -57,7 +57,7 @@ The card accepts one question declaring the intent, carrying the plan as `detail 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`; the plan plugin contributes a sidebar opener when the review intent identifies its logged invocation. Opening a document does not answer or dismiss the review. +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. diff --git a/packages/client/ui-user-questions/README.zh.md b/packages/client/ui-user-questions/README.zh.md index 38c62a969c..995035cbb0 100644 --- a/packages/client/ui-user-questions/README.zh.md +++ b/packages/client/ui-user-questions/README.zh.md @@ -57,7 +57,7 @@ kind: "package-reference" 编辑器外框文案(翻页器、按钮、占位符、校验提示)是双语的:插件在 `dsh-client-locale` 的 `question` 命名空间下注册 zh/en 词典,并通过 inject face 把绑定的翻译函数和 locale 快照源交给该条目,因此切换语言会重新渲染已挂载的编辑器。问题与选项文本来自模型并原样渲染;载体失败消息也不经翻译直接显示。 -计划审批提供 `conversation.plan-review.actions` 插槽;当审批意图包含已记录调用的标识时,计划插件在其中提供侧边栏入口。打开文档不会回答或关闭审批。 +计划审批通过 `conversation.plan-review.actions` 插槽提供请求键、完整正文和可选的调用标识。计划插件从历史中打开已记录计划,并把没有调用标识的审批作为临时侧边栏预览打开。打开文档不会回答或关闭审批。 diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx index 9ae298a0e7..388a3d4eaa 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.tsx @@ -53,7 +53,7 @@ export function PlanReviewPanel({ pending, review, t, renderSlot }: PlanReviewPa {t('plan.header')}
    - {renderSlot('conversation.plan-review.actions', { review })} + {renderSlot('conversation.plan-review.actions', { review, requestKey: pending.key })}
    diff --git a/packages/client/ui-user-questions/src/client/contract/slots.ts b/packages/client/ui-user-questions/src/client/contract/slots.ts index 0fcdd8b713..813ec4a0be 100644 --- a/packages/client/ui-user-questions/src/client/contract/slots.ts +++ b/packages/client/ui-user-questions/src/client/contract/slots.ts @@ -18,7 +18,7 @@ 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 } } + 'conversation.plan-review.actions': { kind: 'list'; scope: 'session'; owner: { review: PlanReview; requestKey: PendingQuestion['key'] } } } } diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 0d429d084d..094b056780 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -216,6 +216,23 @@ describe('planReviewOf', () => { }) describe('PlanReviewPanel', () => { + 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() + expect(rendered).toHaveBeenLastCalledWith('conversation.plan-review.actions', { + review: planReviewOf(first.carrier.questions), requestKey: first.carrier.key, + }) + view.rerender() + 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() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 015982d3a9..b059d190d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3701,6 +3701,9 @@ importers: '@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 From 03f93dc2a56b2a4d12f8350f5226dcf5b00d3e49 Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 16:52:41 +0800 Subject: [PATCH 057/225] revert(web): remove raw disclosure reset on record selection Refs #4356 --- .../src/client/TrajectoryTable.tsx | 1 - .../ui-trajectory/tests/table.client.spec.tsx | 55 ------------------- 2 files changed, 56 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index e1918d62cd..5308e04d76 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1533,7 +1533,6 @@ function MarkdownRecordContent({ if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { return ( { }, ) - it('resets raw attachment disclosures on record selection but keeps them through history updates', () => { - const content = [ - { - type: 'image' as const, - attachment: { - attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), - mediaType: 'image/png' as const, - width: 40, - height: 80, - bytes: 68, - name: 'shared.png', - }, - }, - { - type: 'file' as const, - attachment: { - attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), - name: 'shared.txt', - bytes: 1, - }, - }, - ] - const nodes = ['First message', 'Second message'].map((text, index) => ({ - kind: 'user' as const, - seq: index + 2, - time: (index + 2) * 1000, - source: { kind: 'user' as const }, - content: [{ type: 'text' as const, text }, ...content], - })) - const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }, t) - const view = render() - fireEvent.click(screen.getByRole('row', { name: /First message/ })) - fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) - const disclosures = () => [...screen.getByRole('tabpanel').querySelectorAll('details')] - expect(disclosures()).toHaveLength(2) - expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) - for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) - expect(disclosures().every(disclosure => disclosure.open)).toBe(true) - - const older = { ...nodes[0]!, seq: 1, time: 1000, content: [{ type: 'text' as const, text: 'Older message' }] } - const expandedTurns = deriveTrajectoryLayout({ nodes: [older, ...nodes], partial: null, runningCalls: [] }, t) - view.rerender() - expect(within(screen.getByRole('tabpanel')).getByText('First message', { exact: true })).toBeTruthy() - expect(disclosures().every(disclosure => disclosure.open)).toBe(true) - - fireEvent.click(screen.getByRole('row', { name: /Second message/ })) - expect(screen.getByRole('tab', { name: 'Raw' }).getAttribute('aria-selected')).toBe('true') - expect(within(screen.getByRole('tabpanel')).getByText('Second message', { exact: true })).toBeTruthy() - expect(disclosures()).toHaveLength(2) - expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) - for (const disclosure of disclosures()) fireEvent.click(disclosure.querySelector('summary')!) - fireEvent.click(screen.getByRole('row', { name: /First message/ })) - expect(disclosures().every(disclosure => !disclosure.open)).toBe(true) - }) - it('renders a tool-result image through the shared gallery in the Result tab', () => { const attachment = { attachmentId: `sha256:${'b'.repeat(64)}`, From 70ba425ba2cdaac6b411de94a3587973b6282b0e Mon Sep 17 00:00:00 2001 From: sjw1231 Date: Thu, 17 Sep 2026 16:53:50 +0800 Subject: [PATCH 058/225] docs(web): remove raw disclosure reset guarantees Refs #4356 --- .../2026-09-15-trajectory-attachment-presentation.i18n.yaml | 4 ++-- .../bug-fix/2026-09-15-trajectory-attachment-presentation.md | 4 ++-- .../2026-09-15-trajectory-attachment-presentation.zh.md | 4 ++-- packages/client/ui-trajectory/README.i18n.yaml | 4 ++-- packages/client/ui-trajectory/README.md | 2 -- packages/client/ui-trajectory/README.zh.md | 2 -- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml index 0a3a425300..cc6eb7ff26 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md -2026-09-15-trajectory-attachment-presentation.md: fdf22a74ed2794cdea08a9c98a0eb384f992c447 -2026-09-15-trajectory-attachment-presentation.zh.md: d7088cd80dcf3eccde2abcccfff3eaaf89393ac2 +2026-09-15-trajectory-attachment-presentation.md: 03d737d67e626497bbcb957911b1578e0dadd5fb +2026-09-15-trajectory-attachment-presentation.zh.md: 55c63bf3d97de2e3e102e92730ce4d2637547d6b diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md index fdf22a74ed..03d737d67e 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.md @@ -42,13 +42,13 @@ The [generic file upload decision](../feature/2026-08-26-generic-file-upload.md) ## Consequences -The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry. Selecting another record resets Raw attachment disclosures; updates to the selected record, including loading older history, preserve their state. +The shared attachment renderer keeps names, order, metadata, and image actions consistent between Summary and Preview. Grouping attachments after message text makes the complete set easy to scan; Raw preserves interleaving when readers need the original block sequence. Repeated references remain separate rows even when image loading shares a cache entry. Compact thumbnails require opening the viewer to read detailed diagrams. Missing metadata stays absent, and image-read failures retain a retry control without hiding ordinary files. Changes to the shared image slot also affect Chat and tool results, so their gallery behavior and cache reuse remain regression requirements. ## Verification -[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, complete Raw fields, and disclosure state across record selection and history updates. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer. +[Projection tests](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx) cover mixed, attachment-only, empty-text, and repeated occurrences. [Inspector tests](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx) cover both locales, unnamed images, long filenames, zero-byte files, ordered lists, and complete Raw fields. [Image tests](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx) cover thumbnail labels, loading, retry, and the existing viewer. The recorded [mixed-upload browser scenario](../../../../apps/web/tests/file-upload-round.e2e.ts) compares Summary and Preview, verifies keyboard activation and focus restoration, checks collapsed and expanded Raw content, and repeats inspection after reload. Its [Trajectory expectation](../../../../snapshots/web/file-upload-round/trajectory.expected.md) accompanies the unchanged Session fixture. The [assembled image-cache test](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts) also checks reuse between Chat and Trajectory. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md index d7088cd80d..55c63bf3d9 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-15-trajectory-attachment-presentation.zh.md @@ -42,13 +42,13 @@ Trajectory 展示每次附件出现的记录,并分别提供视觉预览与已 ## 影响 -共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。选择另一条记录时,原始内容中的附件折叠区恢复收起状态;当前记录更新时,包括加载更早历史时,折叠状态保持不变。 +共享附件渲染器使概述与预览中的名称、顺序、元数据和图片操作保持一致。将附件集中在消息正文之后便于浏览完整集合;需要原始块顺序时,原始内容保留交错关系。即使图片加载共享缓存项,重复引用仍各占一行。 紧凑缩略图需要打开查看器才能阅读详细图表。缺失的元数据保持缺失,图片读取失败保留重试控件,并且不隐藏普通文件。共享图片 slot 的修改也会影响 Chat 和工具结果,因此其图库行为和缓存复用仍需回归验证。 ## 验证 -[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表、完整的原始字段,以及记录切换和历史更新时的折叠状态。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。 +[投影测试](../../../../packages/client/ui-trajectory/tests/layout.client.spec.tsx)覆盖混合附件、纯附件、空文本和重复引用。[详情面板测试](../../../../packages/client/ui-trajectory/tests/table.client.spec.tsx)覆盖两种语言、无名称图片、长文件名、零字节文件、有序列表和完整的原始字段。[图片测试](../../../../packages/client/ui-attachment/tests/message-image.client.spec.tsx)覆盖缩略图标签、加载、重试和现有查看器。 已录制的[混合上传浏览器场景](../../../../apps/web/tests/file-upload-round.e2e.ts)比较概述与预览,验证键盘激活和焦点恢复,检查原始内容收起与展开状态,并在刷新后重复检查。其 [Trajectory 预期输出](../../../../snapshots/web/file-upload-round/trajectory.expected.md)配合未改动的会话 fixture(测试前置数据)使用。[组装应用图片缓存测试](../../../../apps/web/tests/trajectory-image-display.expected.e2e.ts)也检查 Chat 与 Trajectory 之间的复用。 diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index e8cac5bdb5..8ab91c8e9f 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -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-trajectory/README.md -README.md: 14a227fbab69032ba25aa6773e8663d12404b965 -README.zh.md: 492b37983489740d163a500a493c2c76528683cd +README.md: b58466f0c38efef89ff3aa2b5d6da49b6c85ac86 +README.zh.md: ab9ecbfd26478c30c9078a25f92bd121245b7bf9 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 14a227fbab..b58466f0c3 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -35,8 +35,6 @@ Selection, timeline navigation, folding, and search cover the React-visible wind Summary and Preview share one ordered attachment list after the message text, preserving repeated references. Each row shows a contained image thumbnail or file-type icon, the recorded filename (a localized numbered label for unnamed images), and recorded size, type, and image dimensions where available. Zero-byte files retain their size, and truncated filenames expose the full name in a tooltip. Images open the existing lightbox. Raw keeps content-block order and unrendered text, with images and files in initially collapsed disclosures containing their complete recorded fields. -Selecting another record resets its Raw attachment disclosures to collapsed. Updates to the selected record, including loading older history, preserve its disclosure state. - Thinking in the inspector uses compact Markdown at the inspector's fixed 13px size and 20px line height, independent of the content-size setting. Headings add bold weight without increasing size or line height. Assistant output keeps its regular Markdown typography. ### The timing overview diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 492b379834..ab9ecbfd26 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -35,8 +35,6 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 概述与预览共用正文之后的有序附件列表,保留重复引用。每行展示完整缩放的图片缩略图或文件类型图标、已记录的文件名(未命名图片使用本地化的编号名称),以及可用的已记录大小、类型和图片尺寸。零字节文件保留大小显示,截断的文件名通过提示显示完整名称。图片打开现有灯箱。原始内容保留内容块顺序和未渲染文本,图片与文件使用默认折叠的展开项,包含完整的已记录字段。 -选择另一条记录时,其原始内容中的附件折叠区恢复收起状态。当前记录更新时,包括加载更早历史时,折叠状态保持不变。 - 检查器中的思考内容使用紧凑 Markdown,沿用检查器固定的 13px 字号和 20px 行高,不受内容字号设置影响。标题只加粗,不放大字号或行高。助手输出保留常规 Markdown 排版。 ### 时间概览 From 65648082a37a93bb2bd1daff314f09b23ccb0fca Mon Sep 17 00:00:00 2001 From: Turtle 2099 Date: Thu, 17 Sep 2026 17:19:29 +0800 Subject: [PATCH 059/225] feat(web): reveal a clipped sidebar session title on hover (#4423) * feat(web): reveal a clipped sidebar session title on hover A session title wider than its sidebar row is clipped with an ellipsis, so a long parent title hides the `(1)` that distinguishes its fork. Hovering the row now scrolls the title to its far edge and returns it to the start when the pointer leaves. The title stays an `overflow: hidden` scrollport, so nothing reflows and no scrollbar appears; `scroll-behavior: smooth` supplies the motion (reduced motion keeps the jump), and the hovered row clips instead of ellipsizing because a scrolled box still paints the ellipsis over the revealed characters. Covered by the row component spec, the stylesheet contract spec, and a new keyless Chromium scenario that hovers a seeded clipped row in the assembled application and asserts the reached scroll extent. * fix(web): return the revealed session title in one step Review follow-up on the hover reveal: - Return the title to its start with `scrollTo({ behavior: 'instant' })` instead of a smooth scroll. A smooth return carried the resting ellipsis and the narrowing cell over the travelling text for its whole duration. - Scope `scroll-behavior: smooth` to `.sessionRow .title` so Workspace rows are untouched, and fold the reduced-motion reset into the file's existing block. - Guard the hover-only `text-overflow: clip` with `@media (hover: hover)`: a touch tap latches `:hover` after the title has already returned, which would otherwise strand that row without its ellipsis. - Pin the contract in the browser scenario: computed `scroll-behavior` is `smooth` by default and `auto` under reduced motion, and the return lands inside one step. Add the unit case for the explicit instant return. --- ...debar-session-title-hover-scroll.i18n.yaml | 6 ++ ...-web-sidebar-session-title-hover-scroll.md | 41 +++++++++ ...b-sidebar-session-title-hover-scroll.zh.md | 41 +++++++++ .../tests/sidebar-title-hover-scroll.e2e.ts | 86 +++++++++++++++++++ apps/web/tsconfig.json | 1 + packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + .../src/client/rows/Rows.module.css | 20 +++++ .../ui-workspace/src/client/rows/Rows.tsx | 36 +++++++- .../tests/browser-styles.client.spec.ts | 9 ++ .../ui-workspace/tests/rows.client.spec.tsx | 50 +++++++++++ tsconfig.host.json | 1 + 13 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md create mode 100644 .agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md create mode 100644 apps/web/tests/sidebar-title-hover-scroll.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml new file mode 100644 index 0000000000..896278e3d6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md +2026-09-17-web-sidebar-session-title-hover-scroll.md: 07f802f2c64aa40bb13ba679cda2007112b58d69 +2026-09-17-web-sidebar-session-title-hover-scroll.zh.md: aa08bac5018304a0e25e93062f7cc45ca415799e diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md new file mode 100644 index 0000000000..07f802f2c6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.md @@ -0,0 +1,41 @@ +# Agent Note: Web sidebar Session title hover scroll + +Status: implemented + +English | [中文](2026-09-17-web-sidebar-session-title-hover-scroll.zh.md) + +## Problem + +A Session title wider than its sidebar row is clipped with an ellipsis, so the part that distinguishes two related Sessions is the part that disappears. Fork makes it visible: the child's persisted title is the parent's plus an increment, so a long parent title leaves the `(1)` off the row's right edge. Opening the hover card shows the full title, but the card needs a dwell, covers neighboring rows, and is not the comparison the user is trying to make. + +## Decision + +A Session row reveals its clipped title in place while the pointer rests on the row. `SessionNodeItem` writes `title.scrollLeft = scrollWidth - clientWidth` on pointer enter and returns it with `scrollTo({ left: 0, behavior: 'instant' })` on pointer leave; the title element keeps `overflow: hidden`, so nothing reflows, no scrollbar appears, and the page never scrolls. Entering glides because the title carries `scroll-behavior: smooth` (the file's reduced-motion block restores `auto`), so the handler owns no timer or animation loop. Returning is deliberately one step: a smooth return would travel under the resting ellipsis and the row's narrowing cell for its whole duration. + +While the pointer is on the row, `text-overflow` switches from `ellipsis` to `clip`: a scrolled `overflow: hidden` box still paints the ellipsis at its right edge, which would cover exactly the characters the scroll just revealed (verified in Chromium before the rule was written). That rule sits behind `@media (hover: hover)`, because a touch tap latches `:hover` on the row after the title has already returned, and an unguarded rule would leave the tapped row hard-clipped at its start. + +The reveal belongs to the Session row, not to a single cell: entering anywhere on the row starts it, and it ends only when the pointer leaves the row. Workspace rows and search results keep their ellipsis behavior — their titles distinguish nothing by a trailing token — and only the Session title carries the scroll behavior. + +The hovered row is also the wider cell: its trailing relative-time label yields to the row menu, so the hovered `clientWidth` is larger than the resting one and the hovered scroll maximum is smaller. A handler that read the resting width would compute a larger delta that the browser clamps to the hovered maximum, reaching the same far edge; the browser scenario therefore polls the live extent instead of the value the handler read. + +## Alternatives considered + +**A moving inner span or a duplicated-copy marquee.** Rejected: `text-overflow: ellipsis` ellipsizes text directly inside the clipping block, so moving the text requires a nested element whose own clipping (or the duplicate copy) moves with it; the resting ellipsis, the existing DOM, and the accessible text would all change. Scrolling the existing element keeps every one of them. + +**A rAF or CSS-loop marquee.** Rejected: a continuous animation keeps moving while the user reads and needs per-row lifetime management. One scroll to the far edge and one back is the whole reveal. + +**`scrollTo({ behavior: 'smooth' })` for both moves.** Rejected: it needs a `matchMedia` probe for reduced motion plus a capability branch, and it would keep the return animating under the resting ellipsis. The stylesheet supplies the entering glide, and the leaving move asks for `instant` explicitly. + +**A handler-owned clip state cleared on `scrollend`.** Considered for the return transient; rejected because returning in one step removes the transient without a second state, an event listener, or a browser capability the jsdom lane lacks. + +**`direction: rtl` on hover.** Rejected: it jumps instead of scrolling and reorders punctuation inside mixed-direction titles. + +**Leaving the reveal to the hover card.** Rejected: the card opens only after its dwell and covers neighboring rows, so it cannot support comparing two adjacent rows. + +## Consequences + +A long title still occupies exactly one row line; the scroll range is measured from the element's own box, so a width change (the sidebar resizing, or the hovered row's own cell change) is picked up on the next pointer enter, and the instant return leaves the resting ellipsis and the text consistent whenever the pointer leaves. Revealing depends on a pointer resting on the row, so touch and keyboard users still rely on the hover card and the Rename dialog for the full title; removing the reveal leaves an ordinary ellipsized row. + +## Testing + +`packages/client/ui-workspace/tests/rows.client.spec.tsx` pins the scroll position written on pointer enter, the explicit instant return, and that a title that fits stays at its start. `packages/client/ui-workspace/tests/browser-styles.client.spec.ts` pins the stylesheet contract for the hovered row. `apps/web/tests/sidebar-title-hover-scroll.e2e.ts` hovers a seeded clipped row in the assembled application and asserts that the title really overflows, reaches its own live extent under the pointer, stops ellipsizing, returns to its start within one step, and that the computed scroll behavior is `smooth` in the default media state and `auto` under reduced motion. diff --git a/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md new file mode 100644 index 0000000000..aa08bac501 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-web-sidebar-session-title-hover-scroll.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Web 侧栏 Session 标题悬停滚动 + +Status: implemented + +[English](2026-09-17-web-sidebar-session-title-hover-scroll.md) | 中文 + +## 问题 + +标题宽于侧栏行时会被省略号裁切,于是区分两个相关 Session 的那一部分恰好消失。Fork 会让这件事暴露出来:子会话持久化标题是父标题加一个递增序号,父标题一长,`(1)` 就落到行的右边界之外。悬停卡片能显示完整标题,但它需要停留等待、会盖住相邻行,而且用户想做的比较并不是看卡片。 + +## 决策 + +指针停在 Session 行上时,该行就地显示被裁切的标题。`SessionNodeItem` 在指针进入时写入 `title.scrollLeft = scrollWidth - clientWidth`,在指针离开时用 `scrollTo({ left: 0, behavior: 'instant' })` 一次性回到起点;标题元素保持 `overflow: hidden`,因此不会重排、不会出现滚动条,页面本身也不滚动。进入时之所以平滑,是因为标题带有 `scroll-behavior: smooth`(本文件的减少动效块恢复为 `auto`),处理函数因此不持有定时器或动画循环。返回被有意做成一步到位:平滑返回会让静止态省略号和收窄后的格子在整个返回过程中一直覆盖着文字。 + +指针停留在行上期间,`text-overflow` 由 `ellipsis` 切换为 `clip`:已滚动的 `overflow: hidden` 盒子仍会在右边界绘制省略号,而那正是刚刚被滚动显示出来的字符(写这条规则前已在 Chromium 中验证)。这条规则位于 `@media (hover: hover)` 之内,因为触屏点按会在标题已经返回之后仍让该行的 `:hover` 保持钉住,不设保护就会让被点按的行停在起点且硬裁切、失去省略号。 + +该行为属于 Session 行,而不是某一格:指针进入行的任意位置都会开始,只有离开该行才结束。Workspace 行与搜索结果保留省略号行为——它们的标题没有靠尾部记号区分的含义——只有 Session 标题带有滚动行为。 + +悬停中的行同时也是更宽的格子:行尾的相对时间标签会让位给行操作菜单,因此悬停时的 `clientWidth` 比静止时更大,可滚动上限更小。若处理函数读到的是静止宽度,它会算出一个更大的位移,浏览器会把它夹到悬停状态的上限,最终同样到达远端;因此浏览器场景断言的是实时到达范围,而不是处理函数读到的数值。 + +## 备选方案 + +**移动的内层元素或复制副本的跑马灯。** 否决:`text-overflow: ellipsis` 只对裁切块内直接包含的文本生效,因此移动文本需要嵌套元素,而它自身的裁切(或复制副本)会随之移动;静止态的省略号、现有 DOM 和可访问文本都会改变。滚动现有元素则三者都保留。 + +**rAF 或 CSS 循环跑马灯。** 否决:持续动画会在用户阅读时继续移动,并且需要逐行管理生命周期。滚到远端再滚回一次,就是这个揭示所需的全部动作。 + +**两个方向都用 `scrollTo({ behavior: 'smooth' })`。** 否决:它需要一个 `matchMedia` 探测来处理减少动效,还需要一个能力分支,并且会让返回过程在静止态省略号之下持续动画。进入的平滑由样式表提供,离开则显式要求 `instant`。 + +**由处理函数持有裁切状态并在 `scrollend` 时清除。** 曾用于消除返回过程的瞬态;否决,因为一步返回直接消除该瞬态,无需第二个状态、事件监听或 jsdom 车道缺少的浏览器能力。 + +**悬停时使用 `direction: rtl`。** 否决:它是跳变而非滚动,并且会重排混合方向标题中的标点。 + +**把揭示交给悬停卡片。** 否决:卡片要等停留时间结束才出现,而且会盖住相邻行,无法支持并排比较两行。 + +## 后果 + +长标题仍然只占一行;滚动范围按元素自身盒宽测量,因此宽度变化(侧栏缩放,或悬停行自身的格子变化)会在下一次指针进入时被重新读取,而一步返回保证指针离开时静止态省略号与文字始终一致。揭示依赖指针停在行上,所以触屏与键盘用户仍需通过悬停卡片和重命名对话框获取完整标题;去掉该行为后,这一行只是普通的省略号行。 + +## 测试 + +`packages/client/ui-workspace/tests/rows.client.spec.tsx` 钉住指针进入时写入的滚动位置、显式的一步返回,以及能放下的标题停留在起点。`packages/client/ui-workspace/tests/browser-styles.client.spec.ts` 钉住悬停行的样式表契约。`apps/web/tests/sidebar-title-hover-scroll.e2e.ts` 在组装后的应用中悬停一条被裁切的种子会话行,断言标题确实溢出、到达指针下的实时范围、停止省略、一步回到起点,并断言默认媒体状态下计算出的滚动行为为 `smooth`、减少动效下为 `auto`。 diff --git a/apps/web/tests/sidebar-title-hover-scroll.e2e.ts b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts new file mode 100644 index 0000000000..2a0fe8caf4 --- /dev/null +++ b/apps/web/tests/sidebar-title-hover-scroll.e2e.ts @@ -0,0 +1,86 @@ +// Web e2e scenario: hovering a sidebar session row reveals a title wider than +// its cell. The jsdom lane pins only the scroll position the handler writes; +// the assembled browser is where the title really clips, really reaches its own +// hovered extent (the trailing relative-time cell yields to the row menu while +// the pointer rests on the row, so the hovered cell is wider than the resting +// one), and really returns to its start when the pointer leaves. +// +// Zero model calls: seeding one renamed session and hovering its row touches no +// provider. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import type { Browser, Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) +/** Wider than the sidebar cell, under the 80-byte title limit, with a far-edge suffix the reveal must reach. */ +const TITLE = 'Forked session clipped before its suffix (1)' + +describe('web e2e: hovering a clipped session title scrolls it to its far edge', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-title-hover-scroll') + await workspace.attachSession(id) + await scaffold.ctx.sessionController.rename({ sessionId: id, title: TITLE }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('reveals the clipped title under the pointer and restores its start after', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-title-hover-scroll')) + const row = page.getByRole('treeitem').filter({ has: page.getByText(TITLE, { exact: true }) }) + await row.waitFor({ timeout: 20_000 }) + const title = row.getByText(TITLE, { exact: true }) + + // At rest the row clips its title with an ellipsis and holds its start. + expect(await title.evaluate(el => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0) + expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') + expect(await title.evaluate(el => el.scrollLeft)).toBe(0) + expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('smooth') + + await row.hover() + // Poll against the live extent: the reveal must reach the far edge of the + // hovered cell (the wider one — the relative-time label yields to the row + // menu), whatever width the handler itself read. + await expect.poll( + async () => title.evaluate(el => el.scrollLeft >= el.scrollWidth - el.clientWidth - 1), + { timeout: 10_000 }, + ).toBe(true) + // Scrolled to its end, the title must not paint the ellipsis over the + // characters the reveal reached. + expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('clip') + + // Leaving returns in one step: a smooth return would still be travelling + // inside this window, and it would carry the resting ellipsis back with it. + await page.mouse.move(0, 0) + await expect.poll( + async () => title.evaluate(el => el.scrollLeft), + { timeout: 150, interval: 20 }, + ).toBe(0) + expect(await title.evaluate(el => getComputedStyle(el).textOverflow)).toBe('ellipsis') + + // The reduce override reaches the real stylesheet, so the reveal jumps + // instead of gliding. + await page.emulateMedia({ reducedMotion: 'reduce' }) + expect(await title.evaluate(el => getComputedStyle(el).scrollBehavior)).toBe('auto') + await page.emulateMedia({ reducedMotion: null }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 135115dcad..817eef7946 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -75,6 +75,7 @@ "tests/seeded-history.e2e.ts", "tests/stats-paged-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", + "tests/sidebar-title-hover-scroll.e2e.ts", "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", "tests/ptc-round.e2e.ts", diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 9431d47206..0bdecf2b08 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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-workspace/README.md -README.md: d57b7af4767a5a0206aec4ee9d9ed4fbbe37a21c -README.zh.md: 465150610de2f4f6e89ae3f414b277a8d2f3b10b +README.md: 3fbbf52fd5fabca3416b7fe566f579f4a74dd3f2 +README.zh.md: 04b0565f8fc17f183fd9c1b1b48a7edbd1ba32ba diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d57b7af476..3fbbf52fd5 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -45,6 +45,8 @@ Collapsed search is one header action beside the view and add actions: activatin The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Rename uses a temporary `workspaceOperation` reference, while fork-title assignment uses a temporary `controllerOperation` reference inside Session Controller; both await the reference's initial history opening. Archive commits without a confirmation dialog and the row disappears from every grouping surface when the archive-set echo lands. Fork forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. +A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start. + ### Pending interactions Session rows render the runtime's live `pendingInteraction` classification: approvals report **Waiting for approval**, plan reviews report **Plan awaiting review**, and ordinary questions report **Waiting for answer**. Every pending interaction uses an amber warning dot that takes precedence over the running indicator. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 465150610d..04b0565f8f 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -45,6 +45,8 @@ kind: "package-reference" Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Rename 使用临时 `workspaceOperation` reference,fork 标题设置则由 Session Controller 使用临时 `controllerOperation` reference;两者都等待该 reference 的首次历史打开。Archive 不经确认对话框直接提交,归档集合回声落地后,该行从所有分组视图中消失。Fork 在源会话最后一个已完成轮次处 fork,在客户端递增继承的持久化标题后再打开子会话。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。 +标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。 + ### 待处理交互 Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**等待审批**,计划审阅显示**计划待审**,普通问题显示**等待回答**。每个待处理交互都使用一枚琥珀色警告点,优先级高于运行指示器。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 9925fd4ecc..a222d05da9 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -189,8 +189,23 @@ outline: none; } +/* Hovering a session row reveals a title wider than its cell: Rows.tsx scrolls + it to its end while the pointer rests on the row — smoothly, through the + scroll-behavior here — and returns it to the start in one step when the + pointer leaves. The unclipped state drops the ellipsis, which would otherwise + cover the characters the reveal reached. */ .sessionRow .title { flex: 1; + scroll-behavior: smooth; +} + +/* Guarded because a touch tap latches `:hover` on the row after the title has + already returned: an unguarded rule would strand that row without its + ellipsis. */ +@media (hover: hover) { + .sessionRow:hover .title { + text-overflow: clip; + } } .meta { @@ -366,4 +381,9 @@ animation: none; transition: none; } + + /* The title reveal jumps to the far edge instead of gliding there. */ + .sessionRow .title { + scroll-behavior: auto; + } } diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index f7e6dd01a5..3d015f3c57 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -1,9 +1,10 @@ /** * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — * all data and callbacks arrive via props. Hover swaps (folder->chevron, - * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only - * except workspace Rename/Delete and session Rename/Fork/Archive; the session - * and workspace hover cards are suppressed while a menu is open. + * time->ellipsis, action buttons) are CSS-only, and a session row's clipped + * title is scrolled programmatically while the row is hovered. Row ... menus are + * visual-only except workspace Rename/Delete and session Rename/Fork/Archive; the + * session and workspace hover cards are suppressed while a menu is open. */ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -27,6 +28,30 @@ function displayTitle(node: SessionNode, t: RowTranslate): string { return node.blank ? t('session.new') : node.title } +/** + * Reveal a title wider than its one-line cell while its row is hovered: the + * title clips its own text, so the far edge (a fork's incremented title, for + * example) is reachable by scrolling the element to its end. Leaving returns it + * to the start in one step, because the resting ellipsis and the narrowed cell + * would otherwise meet the text while it travelled back. A title that fits has + * no scroll range to move, and the stylesheet decides whether either move + * glides or jumps. + * @param title - the row's clipping title element. + * @param revealed - whether the pointer is on the row. + */ +function revealClippedTitle(title: HTMLSpanElement | null, revealed: boolean): void { + /* v8 ignore next -- defensive: the title span renders unconditionally. */ + if (title === null) return + if (revealed) { + title.scrollLeft = title.scrollWidth - title.clientWidth + return + } + // jsdom implements no scrollTo; the lane's direct assignment is instant there + // anyway, so both paths land on the same resting position. + if (typeof title.scrollTo === 'function') title.scrollTo({ left: 0, behavior: 'instant' }) + else title.scrollLeft = 0 +} + /** Localized compact relative time ("刚刚"/"5分钟" in zh, "now"/"5min" in en). */ function timeLabel(updatedAt: number, now: number, t: RowTranslate): string { const { unit, n } = relativeTime(updatedAt, now) @@ -408,6 +433,7 @@ export function SessionNodeItem({ const draggable = drag !== undefined && !row.blank const [menuOpen, setMenuOpen] = useState(false) const rowRef = useRef(null) + const titleRef = useRef(null) useEffect(() => { if (onReveal === undefined) return rowRef.current?.scrollIntoView({ block: 'nearest' }) @@ -434,6 +460,8 @@ export function SessionNodeItem({ role="treeitem" aria-selected={selected} onClick={() => { onOpen(node.id) }} + onPointerEnter={() => { revealClippedTitle(titleRef.current, true) }} + onPointerLeave={() => { revealClippedTitle(titleRef.current, false) }} draggable={draggable} onDragStart={drag === undefined || row.blank ? undefined @@ -467,7 +495,7 @@ export function SessionNodeItem({ {showStatus && } )} - {title} + {title} {row.hasActiveSchedule && } {/* A blank New Session row is a provisional placeholder: nothing has happened in it yet, so a "now" timestamp and the row verbs diff --git a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts index 3d0f31b318..3b25db0644 100644 --- a/packages/client/ui-workspace/tests/browser-styles.client.spec.ts +++ b/packages/client/ui-workspace/tests/browser-styles.client.spec.ts @@ -108,6 +108,15 @@ describe('WorkspaceBrowser.module.css list', () => { .toBe('var(--dsw-alias-interactive-bg-hover)') }) + it('reveals a clipped session title by scrolling it on row hover', () => { + // Smooth versus reduced motion is pinned as a computed style in + // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges + // same-selector rules across media queries, so the reduce override would + // mask the smooth declaration here. + expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1') + expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip') + }) + it('pins both rail controls to the shared left anchor during the column slide', () => { expect(declarations('.rail .sectionHeader')?.get('justify-content')).toBe('flex-start') expect(declarations('.rail .iconButton')?.get('width')).toBe('36px') diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index 7865c31c22..644cb50a8f 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -169,6 +169,56 @@ describe('workspace browser rows', () => { expect(onOpen).toHaveBeenCalledWith(node.id) }) + it('reveals a clipped session title by scrolling it while the row is hovered', () => { + const node: SessionNode = { + id: sid('clipped'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + // jsdom lays out nothing: the title's clipped geometry is stated outright. + const geometry = (scrollWidth: number, clientWidth: number): void => { + Object.defineProperty(title, 'scrollWidth', { value: scrollWidth, configurable: true }) + Object.defineProperty(title, 'clientWidth', { value: clientWidth, configurable: true }) + } + + geometry(320, 180) + fireEvent.pointerEnter(row) + expect(title.scrollLeft).toBe(140) + fireEvent.pointerLeave(row) + expect(title.scrollLeft).toBe(0) + + // A title that fits has no scroll range: hovering leaves it at its start. + geometry(180, 180) + fireEvent.pointerEnter(row) + expect(title.scrollLeft).toBe(0) + }) + + it('returns a revealed title to its start in one step', () => { + const node: SessionNode = { + id: sid('instant-return'), title: 'A Session Title Long Enough To Be Clipped (1)', blank: false, + running: false, runningSubagentCount: 0, completed: false, hasActiveSchedule: false, updatedAt: 0, + } + render( + , + ) + const row = screen.getByRole('treeitem') + const title = screen.getByText(node.title) + const scrollTo = vi.fn() + Object.defineProperty(title, 'scrollTo', { value: scrollTo, configurable: true }) + + fireEvent.pointerEnter(row) + fireEvent.pointerLeave(row) + // Browsers receive the explicit instant behavior that the stylesheet's + // smooth scroll-behavior would otherwise override. + expect(scrollTo).toHaveBeenCalledWith({ left: 0, behavior: 'instant' }) + }) + it('keeps the active-Schedule marker between the title and time in grouped and flat rows', () => { const onOpen = vi.fn() const node: SessionNode = { diff --git a/tsconfig.host.json b/tsconfig.host.json index 87734e2bf1..2b98af6f58 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -67,6 +67,7 @@ "apps/web/tests/cold-blank-session.e2e.ts", "apps/web/tests/stats-paged-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", + "apps/web/tests/sidebar-title-hover-scroll.e2e.ts", "apps/web/tests/rail-search-expand.e2e.ts", "apps/web/tests/conversation-column-overflow.e2e.ts", "apps/web/tests/ptc-round.e2e.ts", From 38bfc29876d274da804146b3075422716d16535f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 17:22:28 +0800 Subject: [PATCH 060/225] fix(web): restore the plain plan review status dot --- .../ui-user-questions/src/client/PlanReviewPanel.module.css | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css index 225496c2d5..cb4c4808e3 100644 --- a/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css +++ b/packages/client/ui-user-questions/src/client/PlanReviewPanel.module.css @@ -44,7 +44,6 @@ border-radius: 50%; corner-shape: round; background: var(--dsw-alias-state-warn-primary); - box-shadow: 0 0 0 3px var(--dsw-alias-state-warn-secondary); } .footer { From 3fb29e08030c2cddf50b738f9992c36294d34d9a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 17:31:34 +0800 Subject: [PATCH 061/225] fix(web): identify plan preview tabs with a plan icon --- packages/client/ui-plan/README.i18n.yaml | 4 ++-- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- packages/client/ui-plan/src/client/PlanPreview.module.css | 1 + packages/client/ui-plan/src/client/PlanPreview.tsx | 8 ++++---- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index d9c7c928f3..0023f8b06c 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -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: c081d12d40377baf777d293582104b33e657e7fe -README.zh.md: a253d2257a694c3466f376665b4dbe33e950020a +README.md: f7e7aede2c76717228d101153e4d3d2f8d3f88c4 +README.zh.md: 9ab37a25dd7cb406b42bf5c531445357d6725e2e diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index c081d12d40..f7e7aede2c 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -33,7 +33,7 @@ While the effective target is plan mode, the seat renders the warn-colored "Plan ### 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. Different submissions retain separate tabs; the review buttons alone decide whether implementation may begin. +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. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index a253d2257a..9ab37a25dd 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 查看已提交的计划 -回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 +回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。计划标签页显示文档加笔的图标。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。 diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index de4a4660a4..0fcac5cd7b 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -37,4 +37,5 @@ .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; } } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index bea4918f02..b417507c77 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -1,6 +1,6 @@ /** Read-only Markdown viewer for logged plans and temporary review documents. */ import { useMemo } from 'react' -import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' +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' @@ -42,9 +42,9 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { } /** - * Display the plan heading in its tab after resource recovery. + * Display a plan icon and the heading in its tab after resource recovery. * @param props - Framework-bound tab identity and resource reader. - * @returns the recovered plan title or the tab's initial localized label. + * @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() @@ -52,5 +52,5 @@ export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.rig const params = tab.tab.navigation.params const plan = isReviewPreviewAddress(tab.tab.navigation.address) ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value - return {plan?.title ?? tab.tab.title} + return <>{plan?.title ?? tab.tab.title} } From 0495ad9846a52e7e694e7e1b827c74e02e930e9d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 17 Sep 2026 17:50:32 +0800 Subject: [PATCH 062/225] fix(web): use the plain text file icon for plan tabs --- packages/client/ui-plan/README.i18n.yaml | 4 ++-- packages/client/ui-plan/README.md | 2 +- packages/client/ui-plan/README.zh.md | 2 +- packages/client/ui-plan/src/client/PlanPreview.module.css | 2 +- packages/client/ui-plan/src/client/PlanPreview.tsx | 8 ++++---- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 0023f8b06c..17181b1dde 100644 --- a/packages/client/ui-plan/README.i18n.yaml +++ b/packages/client/ui-plan/README.i18n.yaml @@ -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: f7e7aede2c76717228d101153e4d3d2f8d3f88c4 -README.zh.md: 9ab37a25dd7cb406b42bf5c531445357d6725e2e +README.md: e269ad270ed6a0d72e4cb7424a86328d3eb6cfb3 +README.zh.md: 202a6737986630f18ffdf7d010174c98be732ba5 diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index f7e7aede2c..e269ad270e 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -33,7 +33,7 @@ While the effective target is plan mode, the seat renders the warn-colored "Plan ### 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. +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 plain text file 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. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 9ab37a25dd..202a673798 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" ### 查看已提交的计划 -回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。计划标签页显示文档加笔的图标。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 +回合结束后,每次提交的计划都会显示在该回合末尾的产物区域,并采用文件产物卡片的样式,包含 Markdown 图标、标题和“打开”操作。在当前浏览器会话中,每份待审计划自动打开一次;关闭后,重新挂载审批组件不会再次展开,新提交会打开其对应计划。历史卡片仅在点击后打开。通过卡片或审批条带上的“查看全文”链接可阅读和复制完整 Markdown。计划标签页显示纯文本文件图标。不同提交保留独立标签页;是否开始实施仍由审批按钮决定。 没有已记录调用标识的审批也会自动打开。完整正文只保存在标签页的导航内存中,待处理审批卡片可以重新打开它。刷新页面会丢失该正文;失效的预览会提示用户返回待处理审批。 diff --git a/packages/client/ui-plan/src/client/PlanPreview.module.css b/packages/client/ui-plan/src/client/PlanPreview.module.css index 0fcac5cd7b..c0f2761be0 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.module.css +++ b/packages/client/ui-plan/src/client/PlanPreview.module.css @@ -37,5 +37,5 @@ .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; } +.titleIcon { flex: none; } @media (max-width: 767px) { .preview { padding: 16px 18px 32px; } } diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index b417507c77..2f0c05508a 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -1,6 +1,6 @@ /** 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 { FileTypeIcon, IconCopyOutline16, 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' @@ -42,9 +42,9 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { } /** - * Display a plan icon and the heading in its tab after resource recovery. + * Display a plain file 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. + * @returns a decorative file icon followed by the recovered title or initial localized label. */ export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.right.pane.tab.title'>) { const tab = useTabInfo() @@ -52,5 +52,5 @@ export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.rig const params = tab.tab.navigation.params const plan = isReviewPreviewAddress(tab.tab.navigation.address) ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value - return <>{plan?.title ?? tab.tab.title} + return <>{plan?.title ?? tab.tab.title} } From e62587c163befc43cf6f8584ef77c498e85501b4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:03:36 +0800 Subject: [PATCH 063/225] feat(client): open subagent chats in sidebar --- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 2 + ...026-07-27-web-subagent-conversations.zh.md | 2 + apps/web/tests/subagent-conversation.e2e.ts | 25 ++- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 15 +- docs/subsystems/session-reference.zh.md | 15 +- .../src/client/sessions/manager.ts | 10 + .../src/client/sessions/service.ts | 15 +- .../tests/sessions-service.client.spec.ts | 22 +- packages/client/ui-chat/package.json | 3 + packages/client/ui-chat/src/client/apply.ts | 4 +- packages/client/ui-chat/src/client/index.ts | 7 + .../sidebar-chat/SidebarChat.module.css | 7 + .../ui-chat/src/client/sidebar-chat/index.tsx | 197 +++++++++++++++++ .../tests/apply-inject.client.spec.tsx | 2 + .../ui-chat/tests/chat-apply.client.spec.tsx | 2 + .../tests/sidebar-chat.client.spec.tsx | 205 ++++++++++++++++++ packages/client/ui-chat/tsconfig.json | 6 + .../ui-conversation/src/client/apply.ts | 5 +- .../src/client/contract/slots.ts | 26 ++- .../ui-conversation/src/client/index.ts | 1 + .../client/skeleton/ConversationContent.tsx | 151 ++----------- .../client/skeleton/ConversationMainPanel.tsx | 98 +-------- .../skeleton/ConversationRoot.module.css | 57 +++-- .../skeleton/ConversationWidthControls.tsx | 175 +++++++++++++++ .../skeleton/DefaultConversationViews.tsx | 7 +- .../tests/skeleton.client.spec.tsx | 34 +-- packages/client/ui-reference/README.i18n.yaml | 4 +- packages/client/ui-reference/README.md | 2 +- packages/client/ui-reference/README.zh.md | 2 +- packages/client/ui-reference/package.json | 1 + .../client/ui-reference/src/client/index.ts | 33 ++- .../client/ui-reference/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 64 +++++- packages/client/ui-reference/tsconfig.json | 3 + .../src/client/contract/slots.ts | 2 + .../ui-sidebar-right/src/client/service.ts | 19 +- .../ui-sidebar-right/src/client/stores.ts | 38 +++- .../ui-sidebar-right/src/client/tab-domain.ts | 1 + .../tests/service.client.spec.ts | 33 +++ packages/client/ui-subagent/package.json | 4 + .../client/SubagentHeaderLineage.module.css | 22 ++ .../src/client/SubagentHeaderLineage.tsx | 33 ++- .../client/ui-subagent/src/client/index.ts | 16 +- .../client/ui-subagent/src/client/locales.ts | 2 + .../tests/browser-plugin.client.spec.ts | 15 +- .../tests/conversation-ui.client.spec.tsx | 9 + packages/client/ui-subagent/tsconfig.json | 3 + .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- .../context/session-reference/package.json | 5 + .../context/session-reference/src/index.ts | 49 +++-- .../context/session-reference/src/types.ts | 4 +- .../tests/session-reference.spec.ts | 70 ++++-- .../context/session-reference/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 80 +++++-- .../extensions/tool-cordis/src/api-catalog.ts | 8 +- pnpm-lock.yaml | 18 ++ .../branchless.expected.md | 5 +- .../subagent-conversation/tree.expected.md | 12 +- 62 files changed, 1257 insertions(+), 414 deletions(-) create mode 100644 packages/client/ui-chat/src/client/sidebar-chat/SidebarChat.module.css create mode 100644 packages/client/ui-chat/src/client/sidebar-chat/index.tsx create mode 100644 packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx create mode 100644 packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index cd51630220..1ef5c7c433 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: d0713731de86df639a426ee56c39857c50af6bc7 -2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc +2026-07-27-web-subagent-conversations.md: fd2ff912a83f2535e33472e2b5755b9d059ba674 +2026-07-27-web-subagent-conversations.zh.md: 0124ce4143d65fe79dc0f5c66a42058984f5182e diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index d0713731de..fd2ff912a8 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -16,6 +16,8 @@ The UI must also preserve the membership, modes, and diagnostics of the [durable The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation. +The same header row can open a child in the right Sidebar as `dsh-resource://chat/session/?parent=&mode=`. The open prefers a separate pane and falls back to the current pane when no split is available. The Sidebar tab renders the shared Conversation Component Factory with its width controls omitted, so main and embedded conversations share one assembly without sharing layout chrome. + Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt carries Queue or Steer delivery through `subagent.prompt` and succeeds at inbox acceptance with `{ messageId }`; it does not expose an Activation, wait for completion, or return an outcome. Adjacent-Agent model messages use the separately owned fixed-Steer operation. The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0f4f646748..0124ce4143 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -16,6 +16,8 @@ UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-su Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。 +同一个页头行还可以把 child 作为 `dsh-resource://chat/session/?parent=&mode=` 在右侧 Sidebar 打开。打开时优先使用独立分栏;无法分栏时回退到当前分栏。Sidebar tab 使用共享 Conversation Component Factory 渲染并省略宽度控制,因此主对话与嵌入式对话共用一套组装,而不共享布局界面。 + 每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词通过 `subagent.prompt` 携带 Queue 或 Steer 投递,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会公开 Activation、等待完成或返回结果。相邻 Agent 的模型消息使用单独拥有的固定 Steer 操作。 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index e4a93fce77..47999bb1cb 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -17,7 +17,7 @@ import { launchWebScaffold, readPersistedEvents, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' const BASE_FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)) const AVAILABLE_CHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/ui.expected.md', import.meta.url)) @@ -34,6 +34,7 @@ const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' const ONE_SHOT_LABEL = 'event-sourcing reviewer' const NESTED_LABEL = 'example editor' +const CHILD_TITLE = 'Explain event sourcing in one' const PARENT_PROMPT = 'Ask a research subagent to explain event sourcing.' const INITIAL_PROMPT = 'Explain event sourcing in one sentence.' /** The grandchild's own first message; its arrival is what says its history finished loading. */ @@ -97,6 +98,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = let childId: SessionId let oneShotId: SessionId let grandchildId: SessionId + let liveReferenceOptions: string[] let tripwire: ReturnType const apiCalls: string[] = [] @@ -145,6 +147,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }) childId = started.childId await waitForAgentToSettle(scaffold, childId) + const liveInput = page.locator('[data-composer-input][contenteditable="true"]').first() + const liveMenu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await writeComposerDraft(page, liveInput, '@') + await liveMenu.getByText('Subagents', { exact: true }).waitFor({ timeout: 15_000 }) + liveReferenceOptions = await liveMenu.getByRole('option').allTextContents() + await page.keyboard.press('Escape') + await writeComposerDraft(page, liveInput, '') oneShotId = sessionId('recorded-one-shot') const oneShotDurationMs = 192 * 24 * 60 * 60 * 1_000 const oneShotAt = Date.now() - oneShotDurationMs @@ -295,6 +304,20 @@ describe('web e2e: persisted subagent conversation and human continuation', () = if (failures.length > 1) throw new AggregateError(failures, 'subagent Web teardown failed') }) + it('lists direct subagents by label in the reference menu', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-reference-title')) + expect(liveReferenceOptions.some(text => text.includes(LABEL))).toBe(true) + expect(liveReferenceOptions.some(text => text.includes(CHILD_TITLE))).toBe(false) + const input = page.locator('[data-composer-input][contenteditable="true"]').first() + const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) + await writeComposerDraft(page, input, '@') + await menu.getByText('Subagents', { exact: true }).waitFor({ timeout: 15_000 }) + await menu.getByRole('option', { name: new RegExp(LABEL) }).waitFor({ timeout: 15_000 }) + expect(await menu.getByRole('option', { name: new RegExp(CHILD_TITLE) }).count()).toBe(0) + await page.keyboard.press('Escape') + await writeComposerDraft(page, input, '') + }) + it('keeps known descendants reachable across a stale empty catalog response', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-stale-catalog')) const pattern = '**/api/subagents/list' diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index 147a780972..33df78e02a 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.i18n.yaml @@ -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/session-reference.md -session-reference.md: 1f44e1b96e31f446ed7f3fe28b625db0958da231 -session-reference.zh.md: 4f5cccf32b0f987f47923fb104e4c66396f89ba9 +session-reference.md: 0d3cd061f4d787772312e97e365d9a24ca6dd492 +session-reference.zh.md: 9537096eec7453d7bdbcd6e39458fac99e61bf8c diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 1f44e1b96e..0d3cd061f4 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -34,15 +34,17 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` is host-facing discovery output. Its label uses the latest session title when present, and filtering searches that label alongside session id and cwd, never transcript text. +`SessionReferenceCandidate` is host-facing discovery output. Its mention label uses the latest Session title when present, while its optional display text prefers a subagent's durable creation label; filtering searches both alongside Session id and cwd, never transcript text. ```ts type-equiv /** One host-facing candidate from exact session metadata. */ interface SessionReferenceCandidate { /** Opaque source session identity. */ sessionId: SessionId - /** Latest log-backed title, falling back to the opaque session id. */ + /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */ label: string + /** Display text, preferring a subagent's durable creation label over {@link label}. */ + displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string /** @@ -180,14 +182,13 @@ Exact-read consumer that prepares immutable cross-session message context. /** * List reference candidates, ranked by working-directory affinity. * - * Discovery runs at keystroke rate, so a title only ever comes from a - * projection read: see {@link SessionReferenceResolver.projectedTitle} for - * which sessions can answer one and which fall back to their id. + * Discovery runs at keystroke rate, so titles and subagent labels only ever + * come from projection reads; sessions without either fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd/title substring. + * @param query - optional case-insensitive session-id/cwd/title/display-title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. - * @returns candidates labeled by latest title or, when absent, session id. + * @returns candidates with canonical mention labels and presentation titles. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 4f5cccf32b..9537096eec 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -34,15 +34,17 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新会话标题时,它的 label 使用该标题;筛选搜索该 label 以及 session id 和 cwd,绝不搜索 transcript(文本记录)。 +`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新 Session 标题时,它的 mention label 使用该标题;可选显示文本则优先使用 subagent 的持久创建 label。筛选会同时搜索两者、Session id 与 cwd,绝不搜索 transcript(文本记录)。 ```ts type-equiv /** One host-facing candidate from exact session metadata. */ interface SessionReferenceCandidate { /** Opaque source session identity. */ sessionId: SessionId - /** Latest log-backed title, falling back to the opaque session id. */ + /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */ label: string + /** Display text, preferring a subagent's durable creation label over {@link label}. */ + displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string /** @@ -180,14 +182,13 @@ Exact-read consumer that prepares immutable cross-session message context. /** * List reference candidates, ranked by working-directory affinity. * - * Discovery runs at keystroke rate, so a title only ever comes from a - * projection read: see {@link SessionReferenceResolver.projectedTitle} for - * which sessions can answer one and which fall back to their id. + * Discovery runs at keystroke rate, so titles and subagent labels only ever + * come from projection reads; sessions without either fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd/title substring. + * @param query - optional case-insensitive session-id/cwd/title/display-title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. - * @returns candidates labeled by latest title or, when absent, session id. + * @returns candidates with canonical mention labels and presentation titles. */ async listCandidates( agent: Agent, query: string = '', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index a3f414c6f0..79474ae2cf 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -2,6 +2,7 @@ import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq, type SessionId, type SessionSeqCursor } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import type { SessionControlBaseline, @@ -584,6 +585,15 @@ export class SessionManager { return this.listSnapshotCache } + /** + * Read cached projection values for a Session that may exist only in a loaded subagent catalog. + * @param sessionId - Session whose control or history baseline supplied projections. + * @returns current values, or undefined before any projection store exists. + */ + projectionValues(sessionId: SessionId): Readonly> | undefined { + return this.projectionStores.get(sessionId)?.values() + } + // ---- Live control and Host-event sinks ---- /** diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 2f54a86447..8d0dd75fc7 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -655,16 +655,25 @@ export class ClientSessions implements ISessions { for (const child of catalog.entries) { if (child.kind !== 'child') continue const childId = child.id - const displayTitle = child.label ?? childId const summary = byId[childId] + const projectionValues = summary?.projectionValues ?? this.manager.projectionValues(childId) + const projectedTitle = projectionValues?.title + const title = typeof projectedTitle === 'string' && projectedTitle !== '' ? projectedTitle : undefined + const displayTitle = title ?? child.label ?? childId if (summary === undefined) { byId[childId] = { id: childId, displayTitle, parentId: parentId as SessionId, origin: 'subagent', running: child.activity === 'running', blank: false, updatedAt: 0, retainedBy: this.retentionSnapshot(childId).retainedBy, + ...(projectionValues === undefined ? {} : { projectionValues }), + ...(title === undefined ? {} : { title }), + } + } else if (summary.displayTitle !== displayTitle || summary.projectionValues !== projectionValues) { + byId[childId] = { + ...summary, + displayTitle, + ...(projectionValues === undefined ? {} : { projectionValues }), } - } else if (summary.displayTitle !== displayTitle) { - byId[childId] = { ...summary, displayTitle } } } } diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index 730951d78b..ee3298fbf8 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -703,6 +703,9 @@ describe('borrow-only bindings', () => { describe('catalog-addressed navigation', () => { it('opens an explicit catalog without retaining it and keeps one-shot labels optional', async ({ bench }) => { const b = bench() + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('one-shot'), key: 'title', value: 'Investigate startup', seq: 2, + }) b.mock.remote.subagents.list.mockResolvedValue(ok({ entries: [ { kind: 'child', id: sid('one-shot'), mode: 'one-shot', activity: 'inactive', hasChildren: false }, @@ -714,13 +717,17 @@ describe('catalog-addressed navigation', () => { await b.svc.refreshSubagents(sid('root')) b.svc.setSubagentCatalogOpen(sid('root'), false) - expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe('one-shot') + expect(b.svc.list.getSnapshot().byId[sid('one-shot')]).toMatchObject({ + title: 'Investigate startup', + displayTitle: 'Investigate startup', + projectionValues: { title: 'Investigate startup' }, + }) expect(b.svc.list.getSnapshot().byId[sid('missing')]).toBeUndefined() expect(b.svc.binding(sid('one-shot'))).toBeUndefined() expect(b.mock.remote.subagents.list).toHaveBeenCalledOnce() }) - it('uses catalog labels for a listed addressed route', async ({ bench }) => { + it('keeps projected titles in standard list rows for an addressed route', async ({ bench }) => { const b = bench() b.mock.remote.subagents.list.mockImplementation((payload) => { const parentSessionId = payload @@ -746,7 +753,10 @@ describe('catalog-addressed navigation', () => { }) await feedList(b, [ { id: 'root' }, - { id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' }, + { + id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent', + projections: { title: 'Child session title' }, + }, { id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' }, ]) await b.svc.refreshSubagents(sid('root')) @@ -756,7 +766,11 @@ describe('catalog-addressed navigation', () => { }, { source: 'controllerOperation' }) await _reference.ready - expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child') + expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ + title: 'Child session title', + displayTitle: 'Child session title', + projectionValues: { title: 'Child session title' }, + }) expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild') }) diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 3741c90845..1cfc927f72 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -31,6 +31,7 @@ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-resources", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-client-ui-layout", @@ -60,6 +61,7 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -82,6 +84,7 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-session-turn-outline": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index c9822804ab..c6d53dd2b2 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -36,6 +36,7 @@ import { createChatStore } from './stores.ts' import { TranscriptViewPolicy } from './transcript-view.ts' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts' import { useTurnDataValue } from './chat/use-turn-data.ts' +import { registerSidebarChat } from './sidebar-chat/index.tsx' const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { hooks: { @@ -48,7 +49,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ 'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale', - 'settingsScope', 'remote', 'remote.session', 'sidebarRight', + 'settingsScope', 'remote', 'remote.session', 'resources', 'sidebarRight', 'sidebarRightTabs', ] /** @@ -78,6 +79,7 @@ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-chat: dictionaries') const t = ctx.locale.bind(NS) + registerSidebarChat(ctx, t) const chatStore = createChatStore() const chatScrollPositions = new Map() const transcriptView = new TranscriptViewPolicy( diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 9c2fe3a5d0..0744be6edc 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -1,5 +1,12 @@ /** Browser Chat target plugin. */ export { apply, inject } from './apply.ts' +export { + ConversationSlotPanel, FixedChatConversationView, parseSidebarChatAddress, + sidebarChatAddress, SIDEBAR_CHAT_ADDRESS, SIDEBAR_CHAT_ID, +} from './sidebar-chat/index.tsx' +export type { + ConversationSlotPanelProps, SidebarChatResource, SidebarChatTabProps, +} from './sidebar-chat/index.tsx' export type {} from './conversation-nodes/assistant.ts' export type {} from './conversation-nodes/command.ts' export type {} from './conversation-nodes/compaction.ts' diff --git a/packages/client/ui-chat/src/client/sidebar-chat/SidebarChat.module.css b/packages/client/ui-chat/src/client/sidebar-chat/SidebarChat.module.css new file mode 100644 index 0000000000..267787e4e5 --- /dev/null +++ b/packages/client/ui-chat/src/client/sidebar-chat/SidebarChat.module.css @@ -0,0 +1,7 @@ +.root { + display: flex; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; +} diff --git a/packages/client/ui-chat/src/client/sidebar-chat/index.tsx b/packages/client/ui-chat/src/client/sidebar-chat/index.tsx new file mode 100644 index 0000000000..735251016b --- /dev/null +++ b/packages/client/ui-chat/src/client/sidebar-chat/index.tsx @@ -0,0 +1,197 @@ +/** Right-Sidebar presentation of an existing subagent Conversation. */ +import type { Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionReference, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' +import type { ConversationViewsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { + PropsRenderFactories, PropsRenderSlots, PropsRuntime, TranslateNS, +} from '@deepseek-ai/dsh-client-ui-slots' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { NS } from '../locale.ts' +import css from './SidebarChat.module.css' + +/** Stable implementation identity for the Sidebar tab body. */ +export const SIDEBAR_CHAT_ID = '@deepseek-ai/dsh-client-ui-chat' + +/** Resource-address prefix for an embedded Session chat. */ +export const SIDEBAR_CHAT_ADDRESS = 'dsh-resource://chat/session/' + +/** Value retained by one live chat resource occurrence. */ +export interface SidebarChatResource { + readonly address: SubagentAddress + readonly reference: SessionReference +} + +declare module '@deepseek-ai/dsh-api-session-controller/client' { + interface SessionReferenceSourceMap { + sidebarChat: unknown + } +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface ResourceProtocolMap { + chat: SidebarChatResource + } + + interface SlotMap { + /** Session-scoped Conversation occurrence hosted by one Sidebar chat tab. */ + 'sidebar.chat.conversation': { kind: 'single'; scope: 'session' } + } +} + +/** + * Address one subagent Session together with the routing facts needed to restore it. + * @param address - durable direct-parent subagent address. + * @returns canonical Sidebar resource address. + */ +export function sidebarChatAddress(address: SubagentAddress): string { + const query = new URLSearchParams({ + parent: address.parentSessionId, + mode: address.mode, + }) + return `${SIDEBAR_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query}` +} + +/** + * Parse one canonical Sidebar chat resource address. + * @param value - possible chat resource address. + * @returns the encoded direct-parent address, or undefined for another or malformed resource. + */ +export function parseSidebarChatAddress(value: string): SubagentAddress | undefined { + let url: URL + try { + url = new URL(value) + } catch (_invalidUrl) { + return undefined + } + if (url.protocol !== 'dsh-resource:' || url.hostname.toLowerCase() !== 'chat') return undefined + const parts = url.pathname.split('/').filter(Boolean) + if (parts.length !== 2 || parts[0] !== 'session') return undefined + const parentSessionId = url.searchParams.get('parent') + const mode = url.searchParams.get('mode') + if (parentSessionId === null || parentSessionId === '' || (mode !== 'one-shot' && mode !== 'continuable')) { + return undefined + } + try { + const childSessionId = decodeURIComponent(parts[1] as string) + return { + parentSessionId: parentSessionId as SessionId, + childSessionId: childSessionId as SessionId, + mode, + } + } catch (_invalidEncoding) { + return undefined + } +} + +function waitForAbort(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +function chatResourceProvider(sessions: ISessions): ResourceProvider<'chat'> { + return { + protocol: 'chat', + async *open(resourceAddress, { signal }) { + const address = parseSidebarChatAddress(resourceAddress) + if (address === undefined) throw new Error(`ui-chat: invalid chat resource address "${resourceAddress}"`) + const reference = sessions.retain(address, { source: 'sidebarChat', signal }) + try { + yield { ok: true, value: { address, reference } } + await waitForAbort(signal) + } finally { + reference.release() + } + }, + } +} + +/** Fixed Chat selection used by an embedded Conversation occurrence. */ +export function FixedChatConversationView(props: ConversationViewsProps) { + return <>{props.renderSlot('conversation.session', { view: 'chat' })} +} + +/** Props supplied to the child-Session Conversation host. */ +export type ConversationSlotPanelProps = PropsRuntime<'sidebar.chat.conversation'> & PropsRenderFactories + +/** Render the shared Conversation content for one explicitly provided child Session. */ +export function ConversationSlotPanel({ + sessionId, useSession, useConversation, useSessions, renderFactorySlot, +}: ConversationSlotPanelProps) { + const session = useSession(value => value) + const conversation = useConversation(value => value) + const active = conversation.activeTargets.size > 0 + || (!session.blank && !session.awaitingFirstTurn) + || session.running + const shellPhase = active ? 'active' : session.promptAttempted ? 'engaging' : 'blank' + const summaryBlank = useSessions(state => state.byId[sessionId]?.blank) + const parentAvailabilityPending = session.subagent?.address.mode === 'continuable' + && session.subagent.parentAvailable === undefined + const settling = (shellPhase === 'blank' && session.openState === 'loading' && summaryBlank !== true) + || parentAvailabilityPending + const hero = shellPhase === 'blank' && (session.openState === 'open' || summaryBlank === true) + const phase = settling ? 'settling' : hero ? 'hero' : 'active' + return renderFactorySlot('conversation.content', { variant: 'embedded', phase, hero }, { + slots: { views: FixedChatConversationView }, + }) +} + +/** Props supplied to the parent-Session Sidebar tab body. */ +export type SidebarChatTabProps = + PropsRuntime<'sidebar.right.pane.tab'> & PropsRenderSlots<'sidebar.chat.conversation'> + +/** Bind a chat resource's child reference around its Conversation slot. */ +export function SidebarChatTab({ useResource, useTabInfo, SessionProvider, renderSlot }: SidebarChatTabProps) { + const { tab } = useTabInfo() + const resource = useResource<'chat'>(tab.contentId) + return ( +
    + {resource.value === undefined + ? null + : ( + + {renderSlot('sidebar.chat.conversation', {})} + + )} +
    + ) +} + +/** + * Register the chat resource owner and its right-Sidebar presentation. + * @param ctx - Client root carrying Sessions, resources, Slots, and Sidebar registries. + * @param t - Chat namespace translator used for fallback tab titles. + */ +export function registerSidebarChat(ctx: Context, t: TranslateNS): void { + ctx.effect( + () => ctx.resources.register(chatResourceProvider(ctx.sessions)), + 'ui-chat: Sidebar chat resources', + ) + ctx.effect(() => ctx.sidebarRightTabs.register({ + id: SIDEBAR_CHAT_ID, + kind: 'chat', + patterns: [`${SIDEBAR_CHAT_ADDRESS}**`], + priority: 'builtin', + canOpen: address => parseSidebarChatAddress(address) !== undefined, + title: (address) => { + const child = parseSidebarChatAddress(address)?.childSessionId + return child === undefined + ? t('view.chat') + : ctx.sessions.list.getSnapshot().byId[child]?.displayTitle ?? child + }, + } satisfies SidebarRightTabDefinition), 'ui-chat: Sidebar chat type') + ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ + name: 'sidebar.right.pane.tab', + key: SIDEBAR_CHAT_ID, + children: { 'sidebar.chat.conversation': { kind: 'single', scope: 'session' } }, + }, SidebarChatTab)), 'ui-chat: Sidebar chat body') + ctx.effect(() => ctx.slots.inject('sidebar.chat.conversation', () => ctx.slots.register({ + name: 'sidebar.chat.conversation', + }, ConversationSlotPanel)), 'ui-chat: Sidebar Conversation') +} diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 5c71d37729..eab2fdf96e 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -58,8 +58,10 @@ async function bench() { runtime.ctx.provide('sidebarRight', sidebarRight as never) const sidebarRightTabs = { get: vi.fn<(kind: string) => object | undefined>(() => ({})), + register: vi.fn(() => () => {}), } runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never) + runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openWorkspacePath = vi.fn( () => Promise.resolve({ ok: true, value: { opened: true } }), ) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index a761e4eaaa..83fe28642b 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -44,6 +44,8 @@ async function bench() { } as never) runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never) runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never) + runtime.ctx.provide('sidebarRightTabs', { register: vi.fn(() => () => {}) } as never) + runtime.ctx.provide('resources', { register: vi.fn(() => () => {}) } as never) const openSession = vi.fn<(id: SessionId) => void>() runtime.ctx.provide('uiWorkspace', { openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => { diff --git a/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx new file mode 100644 index 0000000000..15a14cfd6f --- /dev/null +++ b/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx @@ -0,0 +1,205 @@ +// @vitest-environment jsdom +import type { ReactNode } from 'react' +import { cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import type { + ISessions, SessionListState, SessionReference, SessionSnapshot, +} from '@deepseek-ai/dsh-api-session-controller/client' +import type { ResourceProvider } from '@deepseek-ai/dsh-client-resources/client' +import { sessionSnapshot } from '@deepseek-ai/dsh-client-test-runtime' +import type { ConversationViewsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import { + ConversationSlotPanel, FixedChatConversationView, parseSidebarChatAddress, + registerSidebarChat, sidebarChatAddress, SIDEBAR_CHAT_ID, type SidebarChatTabProps, +} from '../src/client/sidebar-chat/index.tsx' +import { SidebarChatTab } from '../src/client/sidebar-chat/index.tsx' + +const PARENT = 'parent/a' as SessionId +const CHILD = 'child#1' as SessionId +const ADDRESS: SubagentAddress = { + parentSessionId: PARENT, + childSessionId: CHILD, + mode: 'continuable', +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('Sidebar chat address', () => { + it('round-trips the child identity and direct-parent routing metadata', () => { + const resource = sidebarChatAddress(ADDRESS) + expect(resource).toBe('dsh-resource://chat/session/child%231?parent=parent%2Fa&mode=continuable') + expect(parseSidebarChatAddress(resource)).toEqual(ADDRESS) + expect(parseSidebarChatAddress(sidebarChatAddress({ ...ADDRESS, mode: 'one-shot' }))) + .toEqual({ ...ADDRESS, mode: 'one-shot' }) + }) + + it.each([ + 'not an address', + 'https://chat/session/child?parent=parent&mode=continuable', + 'dsh-resource://other/session/child?parent=parent&mode=continuable', + 'dsh-resource://chat/other/child?parent=parent&mode=continuable', + 'dsh-resource://chat/session/child?mode=continuable', + 'dsh-resource://chat/session/child?parent=&mode=continuable', + 'dsh-resource://chat/session/child?parent=parent&mode=unknown', + 'dsh-resource://chat/session/%?parent=parent&mode=continuable', + ])('rejects %s', (address) => { + expect(parseSidebarChatAddress(address)).toBeUndefined() + }) +}) + +describe('Sidebar chat registration', () => { + it('registers the resource, tab, and child slot and retains the child for the resource lifetime', async () => { + const release = vi.fn() + const reference = { + sessionId: CHILD, + ready: Promise.resolve(undefined), + release, + [Symbol.dispose]: release, + } as unknown as SessionReference + const retain = vi.fn(() => reference) + const list = { + getSnapshot: () => ({ + ids: [], + byId: { [CHILD]: { displayTitle: 'Worker' } }, + phase: 'ready', + subagentsByParent: {}, + jobsBySession: {}, + } as unknown as SessionListState), + subscribe: () => () => {}, + } + let provider: ResourceProvider<'chat'> | undefined + let definition: SidebarRightTabDefinition | undefined + const registrations: { options: Record; component: unknown }[] = [] + const ctx = { + sessions: { retain, list } as unknown as ISessions, + resources: { register: (value: ResourceProvider<'chat'>) => { provider = value; return () => {} } }, + sidebarRightTabs: { register: (value: SidebarRightTabDefinition) => { definition = value; return () => {} } }, + slots: { + inject: (_name: string, install: () => () => void) => install(), + register: (options: Record, component: unknown) => { + registrations.push({ options, component }) + return () => {} + }, + }, + effect: (install: () => unknown) => { install(); return () => {} }, + } as unknown as Context + + registerSidebarChat(ctx, (key: string) => key === 'view.chat' ? 'Chat' : key) + + expect(definition?.id).toBe(SIDEBAR_CHAT_ID) + expect(definition?.canOpen?.(sidebarChatAddress(ADDRESS))).toBe(true) + expect(definition?.canOpen?.('dsh-resource://chat/invalid')).toBe(false) + expect(definition?.title(sidebarChatAddress(ADDRESS))).toBe('Worker') + expect(definition?.title(sidebarChatAddress({ ...ADDRESS, childSessionId: 'unknown' as SessionId }))).toBe('unknown') + expect(definition?.title('invalid')).toBe('Chat') + expect(registrations.map(entry => entry.options.name)).toEqual([ + 'sidebar.right.pane.tab', + 'sidebar.chat.conversation', + ]) + + const controller = new AbortController() + const stream = provider!.open(sidebarChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]() + expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } }) + expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal }) + const completion = stream.next() + await Promise.resolve() + controller.abort() + expect(await completion).toEqual({ done: true, value: undefined }) + expect(release).toHaveBeenCalledOnce() + + const alreadyAborted = new AbortController() + alreadyAborted.abort() + const stopped = provider!.open(sidebarChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]() + expect((await stopped.next()).done).toBe(false) + expect(await stopped.next()).toEqual({ done: true, value: undefined }) + expect(release).toHaveBeenCalledTimes(2) + + const invalid = provider!.open('invalid', { signal: new AbortController().signal })[Symbol.asyncIterator]() + await expect(invalid.next()).rejects.toThrow('invalid chat resource address') + }) +}) + +describe('Sidebar chat components', () => { + it('binds a live resource reference around the child Session slot', () => { + const reference = { sessionId: CHILD } as SessionReference + let snapshot = { status: 'loading', value: undefined, failure: undefined } as const + const SessionProvider = vi.fn(({ children }: { children: ReactNode }) => <>{children}) + const renderSlot = vi.fn(() => child conversation) + const props = { + useTabInfo: () => ({ tab: { contentId: sidebarChatAddress(ADDRESS) } }), + useResource: () => snapshot, + SessionProvider, + renderSlot, + } as unknown as SidebarChatTabProps + const view = render() + expect(view.container.textContent).toBe('') + + snapshot = { status: 'live', value: { address: ADDRESS, reference }, failure: undefined } as never + view.rerender() + expect(view.getByText('child conversation')).toBeTruthy() + expect(SessionProvider).toHaveBeenCalledWith(expect.objectContaining({ session: reference }), {}) + expect(renderSlot).toHaveBeenCalledWith('sidebar.chat.conversation', {}) + }) + + it.each([ + { + name: 'active', + session: { blank: false, awaitingFirstTurn: false, running: false }, + summaryBlank: false, + expected: { phase: 'active', hero: false }, + }, + { + name: 'hero', + session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'open' }, + summaryBlank: true, + expected: { phase: 'hero', hero: true }, + }, + { + name: 'loading', + session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'loading' }, + summaryBlank: false, + expected: { phase: 'settling', hero: false }, + }, + { + name: 'continuable parent discovery', + session: { + blank: false, + awaitingFirstTurn: false, + running: false, + subagent: { address: ADDRESS, parentAvailable: undefined }, + }, + summaryBlank: false, + expected: { phase: 'settling', hero: false }, + }, + ])('derives the $name embedded phase and fixes the local view to Chat', ({ session: patch, summaryBlank, expected }) => { + const snapshot = { ...sessionSnapshot(CHILD), ...patch } as SessionSnapshot + const renderFactorySlot = vi.fn(() => null) + render( unknown) => select(snapshot), + useConversation: (select: (value: { activeTargets: ReadonlySet }) => unknown) => select({ activeTargets: new Set() }), + useSessions: (select: (value: SessionListState) => unknown) => select({ + ids: [], byId: { [CHILD]: { blank: summaryBlank } }, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, + } as unknown as SessionListState), + renderFactorySlot, + } as unknown as Parameters[0])} />) + expect(renderFactorySlot).toHaveBeenCalledWith( + 'conversation.content', + { variant: 'embedded', ...expected }, + { slots: { views: FixedChatConversationView } }, + ) + }) + + it('routes the fixed local view through the ordinary Conversation Session slot', () => { + const renderSlot = vi.fn(() => null) + render() + expect(renderSlot).toHaveBeenCalledWith('conversation.session', { view: 'chat' }) + }) +}) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index ece071b757..b52426335d 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -59,12 +59,18 @@ { "path": "../../session/session-turn-outline" }, + { + "path": "../../subagent/subagent" + }, { "path": "../../settings/settings" }, { "path": "../locale" }, + { + "path": "../resources" + }, { "path": "../store" }, diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 9f26d51868..79ffd97359 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -255,7 +255,10 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'conversation.hero.workspace': { kind: 'single', scope: 'root' }, 'conversation.hero.agentPreset': { kind: 'single', scope: 'session-maybe' }, }, - slots: { views: { scope: 'session' } }, + slots: { + views: { scope: 'session' }, + widthControls: { scope: 'root' }, + }, inject: (sessionId: SessionId | undefined): ConversationInjected => ({ hooks: { composerBlock: sessionId === undefined ? ABSENT_BLOCK : composerBlocks.storeFor(sessionId), diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 3c85e5e1ef..d37387ec4d 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -126,7 +126,11 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Conversation shell beneath its root-scoped main-panel entry. */ 'main.conversation': { kind: 'single'; scope: 'session-maybe' } /** Strict per-Session Conversation body. */ - 'conversation.session': { kind: 'single'; scope: 'session' } + 'conversation.session': { + kind: 'single' + scope: 'session' + owner: { view?: string } + } /** Strict per-Session title, actions, and View navigation. */ 'conversation.session.header': { kind: 'single'; scope: 'session' } /** Optional replacement for one Session breadcrumb title. */ @@ -224,6 +228,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { locale: 'conversation' slots: { views: { scope: 'session' } + widthControls: { scope: 'root'; props: ConversationWidthControlsInputProps } } } } @@ -417,14 +422,19 @@ export type ConversationSlotProps = & PropsRenderSlots<'conversation.session.header'> & PropsRenderFactories -/** Main-host inputs for one reusable Conversation content occurrence. */ +/** Inputs shared by main and embedded Conversation content occurrences. */ export interface ConversationContentInputProps { + variant: 'main' | 'embedded' phase: 'settling' | 'hero' | 'active' hero: boolean - onHandleStart: () => number - onHandleDrag: (width: number) => void - onHandleCommit: (width: number) => void - onHandleEnd: () => void +} + +/** Values passed from shared content to its occurrence-selected width controls. */ +export interface ConversationWidthControlsInputProps { + /** Mounted Conversation body measured and styled by the selected controls. */ + container: HTMLDivElement | null + /** Current body phase; handles render only for an active transcript. */ + phase: ConversationContentInputProps['phase'] } /** Full props of the reusable Conversation Factory definition. */ @@ -436,6 +446,10 @@ export type ConversationStore = ReturnType /** Full props of the Factory's caller-selectable Conversation View position. */ export type ConversationViewsProps = FactoryLocalComponentPropsOf<'conversation.content', 'views'> +/** Full props of the Factory's caller-selected width-control position. */ +export type ConversationWidthControlsProps = + FactoryLocalComponentPropsOf<'conversation.content', 'widthControls'> + /** Full props of the strict Session body. */ export type ConversationSessionSlotProps = PropsRuntime<'conversation.session'> diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index a1ccf07408..73ddc6d7d3 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -57,6 +57,7 @@ export type { ConversationContentInputProps, ConversationContentProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionHeaderSlotProps, ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationViewsProps, + ConversationWidthControlsInputProps, ConversationWidthControlsProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, MessageImageLoader, MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index 0118749e8c..c44abab01b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -5,134 +5,29 @@ import type { ConversationContentProps, ConversationViewsProps, InputZone } from import { HeroShell, WorkspaceChip, workspaceLabel } from './EmptyHero.tsx' import css from './ConversationRoot.module.css' -const WHEEL_DELTA_LINE = 1 -const WHEEL_DELTA_PAGE = 2 -const FALLBACK_WHEEL_LINE_PX = 16 - function ConversationSessionView({ renderSlot }: ConversationViewsProps) { return renderSlot('conversation.session', {}) } -/** Convert a wheel event's vertical delta to scrollport pixels. */ -function wheelDeltaY(event: React.WheelEvent, scrollport: HTMLElement): number { - if (event.deltaMode === WHEEL_DELTA_LINE) { - const lineHeight = Number.parseFloat(getComputedStyle(scrollport).lineHeight) - return event.deltaY * (Number.isFinite(lineHeight) ? lineHeight : FALLBACK_WHEEL_LINE_PX) - } - if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * scrollport.clientHeight - return event.deltaY -} - -/** One transcript width handle: pointer capture + rAF-throttled symmetric - * resize (both sides write the one centered width, so outward travel widens - * by 2× the pointer distance). pointermove publishes the pointer's Y as a CSS - * variable so the glow indicator rides it. Mirrors ui-layout AppFrame's - * DragHandle capture model. */ -function WidthHandle(props: { - side: 'left' | 'right' - onStart: () => number - onDrag: (width: number) => void - onCommit: (width: number) => void - onEnd: () => void -}) { - const [dragging, setDragging] = useState(false) - const base = useRef(0) - const origin = useRef(0) - const latest = useRef(0) - const frame = useRef(null) - const callbacks = useRef(props) - callbacks.current = props - - const outwardWidth = () => { - const dx = latest.current - origin.current - const outward = callbacks.current.side === 'right' ? dx : -dx - return base.current + outward * 2 - } - const cancelFrame = () => { - if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } - } - const onPointerDown = useCallback((e: React.PointerEvent) => { - if (e.button !== 0) return - e.preventDefault() - e.currentTarget.setPointerCapture(e.pointerId) - origin.current = e.clientX - latest.current = e.clientX - base.current = callbacks.current.onStart() - setDragging(true) - }, []) - const onPointerMove = useCallback((e: React.PointerEvent) => { - const box = e.currentTarget.getBoundingClientRect() - e.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${e.clientY - box.top}px`) - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return - latest.current = e.clientX - frame.current ??= requestAnimationFrame(() => { - frame.current = null - callbacks.current.onDrag(outwardWidth()) - }) - }, []) - const onPointerUp = useCallback((e: React.PointerEvent) => { - if (!e.currentTarget.hasPointerCapture(e.pointerId)) return - e.currentTarget.releasePointerCapture(e.pointerId) - cancelFrame() - latest.current = e.clientX - // Only a gesture with actual travel commits: a press-and-release on a - // window-clamped width must not overwrite the wider stored preference - // with the clamped display value. - if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth()) - setDragging(false) - callbacks.current.onEnd() - }, []) - // Releasing the button outside the window delivers pointercancel (or drops - // the capture silently) instead of pointerup; without this the glow's - // data-dragging state sticks on. The gesture is abandoned uncommitted — - // onEnd republishes the stored preference. releasePointerCapture inside - // onPointerUp also fires lostpointercapture, so this runs (idempotently) - // after every normal drag end too; keep both paths. - const onPointerCancel = useCallback(() => { - cancelFrame() - setDragging(false) - callbacks.current.onEnd() - }, []) - const onWheel = useCallback((e: React.WheelEvent) => { - const body = e.currentTarget.parentElement - /* v8 ignore next -- a width handle renders only inside the Conversation body. */ - if (body === null) return - const scrollport = body.querySelector(':scope > [data-conversation-scroll]') - /* v8 ignore next -- the Conversation body always contains its direct scroll element. */ - if (scrollport === null) return - if (e.ctrlKey || e.deltaY === 0) return - scrollport.scrollBy({ top: wheelDeltaY(e, scrollport) }) - }, []) - - return ( -
    - ) +function NoConversationWidthControls() { + return null } /** - * Render the existing Conversation body, Composer, and width handles. - * @param props - original Conversation seats plus MainPanel-derived phase and width callbacks. - * @returns the unchanged Conversation body subtree. + * Render the shared Conversation body and its occurrence-selected local Components. + * @param props - Factory input, standard Session sources, and Conversation seats. + * @returns the Conversation view, Composer, and optional width controls. */ -export function ConversationContent({ - sessionId, phase, hero, useSession, useSessions, useSessionStatus, - useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, - selectWorkspace, t, useFactorySlot, - onHandleStart, onHandleDrag, onHandleCommit, onHandleEnd, -}: ConversationContentProps) { +export function ConversationContent(props: ConversationContentProps) { + const { + sessionId, phase, hero, useSession, useSessions, useSessionStatus, + useWorkspaces, useInput, useComposerBlock, renderSlot, renderSlotChain, + selectWorkspace, t, useFactorySlot, + } = props const session = useSession(snapshot => snapshot) const Views = useFactorySlot('views', ConversationSessionView) + const WidthControls = useFactorySlot('widthControls', NoConversationWidthControls) + const [body, setBody] = useState(null) const pendingInteraction = useSessionStatus(snapshot => sessionId === undefined ? undefined : snapshot.get(sessionId)?.pendingInteraction) const inputState = useInput(s => s) @@ -285,23 +180,17 @@ export function ConversationContent({ ) return ( -
    +
    {sessionId === undefined ? null : } {composerSeat}
    - {/* Width handles only while a transcript is on screen; the hero has no - content column to size. */} - {phase === 'active' && (['left', 'right'] as const).map(side => ( - - ))} +
    ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx index 9f48eaa494..a46d1b28c1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationMainPanel.tsx @@ -1,38 +1,8 @@ -import { useCallback, useRef } from 'react' import type { ConversationSlotProps } from '../contract/slots.ts' import { conversationPhase } from '../contract/snapshot.ts' +import { ConversationWidthControls } from './ConversationWidthControls.tsx' import css from './ConversationRoot.module.css' -/** localStorage key for the dragged transcript width preference (px). */ -const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth' -/** Floor for a dragged content width; matches the layout center-column minimum. */ -const CONTENT_MIN = 640 -/** Column budget the content must leave free: 88px per side keeps the width - * handles fully placeable (24px inset + 40px strip + 24px safe zone) — a - * larger dragged width would push its own handles off the column and leave no - * way to drag back. */ -const CONTENT_EDGE_BUDGET = 176 - -/** Reads the persisted width preference; durable-storage boundary, so a - * missing or corrupt value resolves to "no preference". - * @returns the stored width in px, or null when unset or invalid. */ -function readWidthPreference(): number | null { - const raw = localStorage.getItem(WIDTH_PREF_KEY) - if (raw === null) return null - const value = Number(raw) - return Number.isFinite(value) && value > 0 ? value : null -} - -/** Resolves the content width the CSS axis would show for a column width. - * @param columnWidth - the conversation column's rendered width in px. - * @param preference - the dragged preference, or null for the adaptive clamp. - * @returns the resolved content width in px (mirrors the CSS clamp). */ -function resolveContentWidth(columnWidth: number, preference: number | null): number { - const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET) - if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max) - return Math.max(680, Math.min(columnWidth * 0.64, 920)) -} - /** * Render the existing main Conversation frame around the extracted content. * @param props - the original `main.conversation` Slot props. @@ -48,63 +18,6 @@ export function ConversationMainPanel(props: ConversationSlotProps) { const openState = session?.openState const summaryBlank = useSessions(s => sessionId === undefined ? undefined : s.byId[sessionId]?.blank) - // Publishes the column's live width as --dsh-conversation-column-width so - // the shared width axis can adapt (see the .root CSS), and re-clamps a - // dragged preference against the shrunken column WITHOUT rewriting the - // stored preference — widening the window restores it (the AppFrame - // sidebar-drag rule). Same callback-ref pattern as the seat observer. - const rootEl = useRef(null) - const rootObserver = useRef(null) - const publishWidths = useCallback((root: HTMLDivElement): void => { - const column = root.offsetWidth - root.style.setProperty('--dsh-conversation-column-width', `${column}px`) - const preference = readWidthPreference() - if (preference === null) { - root.style.removeProperty('--dsh-chat-user-width') - } else { - root.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) - } - }, []) - const rootResizeRef = useCallback((root: HTMLDivElement | null): void => { - rootObserver.current?.disconnect() - rootObserver.current = null - rootEl.current = root - if (root === null) return - rootObserver.current = new ResizeObserver(() => { publishWidths(root) }) - rootObserver.current.observe(root) - publishWidths(root) - }, [publishWidths]) - - // Drag plumbing for the two width handles: onStart snapshots the resolved - // width (grabbing a clamped column must not jump back to the raw stored - // preference), onDrag publishes only the live clamped style, onCommit - // persists the width of a gesture that actually travelled, and onEnd - // republishes from storage — an uncommitted press leaves the stored - // preference untouched. - const onHandleStart = useCallback((): number => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return 680 - return resolveContentWidth(root.offsetWidth, readWidthPreference()) - }, []) - const onHandleDrag = useCallback((width: number): void => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return - const clamped = resolveContentWidth(root.offsetWidth, width) - root.style.setProperty('--dsh-chat-user-width', `${clamped}px`) - }, []) - const onHandleCommit = useCallback((width: number): void => { - const root = rootEl.current - /* v8 ignore next -- handles render inside the root, so the ref is always attached. */ - if (root === null) return - localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(root.offsetWidth, width)}`) - }, []) - const onHandleEnd = useCallback((): void => { - const root = rootEl.current - if (root !== null) publishWidths(root) - }, [publishWidths]) - // While a session is still replaying (loading + blank) the hero/docked // choice is unknowable — render the composer hidden instead of flashing // the centered hero and snapping to the docked bar (or vice versa). @@ -128,15 +41,14 @@ export function ConversationMainPanel(props: ConversationSlotProps) { const phase = settling ? 'settling' : hero ? 'hero' : 'active' return ( -
    +
    {sessionId === undefined ? null : renderSlot('conversation.session.header', {})} {renderFactorySlot('conversation.content', { + variant: 'main', phase, hero, - onHandleStart, - onHandleDrag, - onHandleCommit, - onHandleEnd, + }, { + slots: { widthControls: ConversationWidthControls }, })}
    ) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index a57c5462d3..bd6fef3eb1 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -5,32 +5,6 @@ height: 100%; min-width: 0; background: var(--dsw-alias-bg-base); - - /* Shared width axis for the whole column: one content width W - (--dsh-chat-content-width) for the transcript, the dock cards - (todo/goal/queue: card minus four insets, 4 x 8 = 32), and business-owned - takeover cards; the input card alone is W + 32px. - The relation also holds when a narrow viewport shrinks everything: the - chat scroller and the takeover frames pad clearance + 16px per side while - the input card clears the bare clearance, so the input card stays exactly - content + 32px at every width. Declared on the root because the - transcript and the composer seat are sibling subtrees. - - W adapts to the column, not the viewport (a collapsed sidebar widens the - column without resizing the window): the component publishes the column's - live width as --dsh-conversation-column-width (px, via ResizeObserver) - and the clamp keeps a 680px floor (a step under the figma - 748px — the full-width reading felt wide on every screen), takes 64% of - wider columns, and caps at 920px for line-length - readability. A dragged user preference (--dsh-chat-user-width, published - by the width handles) replaces the adaptive term wholesale. */ - --dsh-chat-content-width: var( - --dsh-chat-user-width, - clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px) - ); - --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); - --dsh-composer-side-clearance: 16px; - --dsh-composer-dock-inset: 8px; } /* 76px with its rule: the height of the Sidebar's tab strip and header row @@ -377,6 +351,25 @@ flex: 1; flex-direction: column; min-height: 0; + + /* Shared width axis for the transcript, dock cards, composer, and takeover + cards. Main width controls publish the live body width and optional user + preference; embedded occurrences override this adaptive axis below. */ + --dsh-chat-content-width: var( + --dsh-chat-user-width, + clamp(680px, calc(var(--dsh-conversation-column-width, 0px) * 0.64), 920px) + ); + --dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px); + --dsh-composer-side-clearance: 16px; + --dsh-composer-dock-inset: 8px; +} + +.embeddedBody { + overflow: hidden; + --dsh-chat-content-width: min(calc(100% - 32px), 920px); + --dsh-composer-card-max-width: min(calc(100% - 16px), 952px); + --dsh-composer-side-clearance: 8px; + --dsh-composer-dock-inset: 8px; } .scrollBody { @@ -402,12 +395,14 @@ margin: 2px; } -.root[data-phase='active'] .viewArea { +.root[data-phase='active'] .viewArea, +.embeddedBody[data-phase='active'] .viewArea { flex: 1 0 auto; min-height: auto; } -.root[data-phase='active'] .composerSeat { +.root[data-phase='active'] .composerSeat, +.embeddedBody[data-phase='active'] .composerSeat { position: sticky; bottom: 0; /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never @@ -510,13 +505,15 @@ /* Hero: the composer sits inside the session scroll body; center there so the tree seat matches active (sticky footer) without a Root remount. */ -.root[data-phase='hero'] .scrollBody { +.root[data-phase='hero'] .scrollBody, +.embeddedBody[data-phase='hero'] .scrollBody { justify-content: center; overflow-y: auto; } /* Settling (session replaying, hero/docked unknown): keep the composer seat mounted but invisible so no wrong layout flashes before the phase lands. */ -.root[data-phase='settling'] .composerSeat { +.root[data-phase='settling'] .composerSeat, +.embeddedBody[data-phase='settling'] .composerSeat { visibility: hidden; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx new file mode 100644 index 0000000000..be6927d42d --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx @@ -0,0 +1,175 @@ +import { useCallback, useLayoutEffect, useRef, useState } from 'react' +import type { ConversationWidthControlsProps } from '../contract/slots.ts' +import css from './ConversationRoot.module.css' + +/** localStorage key for the dragged transcript width preference (px). */ +const WIDTH_PREF_KEY = 'dsh.conversation.contentWidth' +/** Floor for a dragged content width; matches the layout center-column minimum. */ +const CONTENT_MIN = 640 +/** Horizontal room reserved for both handles and their safe edge zones. */ +const CONTENT_EDGE_BUDGET = 176 +const WHEEL_DELTA_LINE = 1 +const WHEEL_DELTA_PAGE = 2 +const FALLBACK_WHEEL_LINE_PX = 16 + +/** Read a valid persisted width preference, or null when absent or corrupt. */ +function readWidthPreference(): number | null { + const raw = localStorage.getItem(WIDTH_PREF_KEY) + if (raw === null) return null + const value = Number(raw) + return Number.isFinite(value) && value > 0 ? value : null +} + +/** Resolve the width displayed for one measured Conversation column. */ +function resolveContentWidth(columnWidth: number, preference: number | null): number { + const max = Math.max(CONTENT_MIN, columnWidth - CONTENT_EDGE_BUDGET) + if (preference !== null) return Math.min(Math.max(preference, CONTENT_MIN), max) + return Math.max(680, Math.min(columnWidth * 0.64, 920)) +} + +/** Convert a wheel event's vertical delta to scrollport pixels. */ +function wheelDeltaY(event: React.WheelEvent, scrollport: HTMLElement): number { + if (event.deltaMode === WHEEL_DELTA_LINE) { + const lineHeight = Number.parseFloat(getComputedStyle(scrollport).lineHeight) + return event.deltaY * (Number.isFinite(lineHeight) ? lineHeight : FALLBACK_WHEEL_LINE_PX) + } + if (event.deltaMode === WHEEL_DELTA_PAGE) return event.deltaY * scrollport.clientHeight + return event.deltaY +} + +/** One pointer-captured transcript width handle. */ +function WidthHandle(props: { + side: 'left' | 'right' + onStart: () => number + onDrag: (width: number) => void + onCommit: (width: number) => void + onEnd: () => void +}) { + const [dragging, setDragging] = useState(false) + const base = useRef(0) + const origin = useRef(0) + const latest = useRef(0) + const frame = useRef(null) + const callbacks = useRef(props) + callbacks.current = props + + const outwardWidth = () => { + const dx = latest.current - origin.current + const outward = callbacks.current.side === 'right' ? dx : -dx + return base.current + outward * 2 + } + const cancelFrame = () => { + if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null } + } + const onPointerDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return + event.preventDefault() + event.currentTarget.setPointerCapture(event.pointerId) + origin.current = event.clientX + latest.current = event.clientX + base.current = callbacks.current.onStart() + setDragging(true) + }, []) + const onPointerMove = useCallback((event: React.PointerEvent) => { + const box = event.currentTarget.getBoundingClientRect() + event.currentTarget.style.setProperty('--dsh-width-handle-pointer-y', `${event.clientY - box.top}px`) + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + latest.current = event.clientX + frame.current ??= requestAnimationFrame(() => { + frame.current = null + callbacks.current.onDrag(outwardWidth()) + }) + }, []) + const onPointerUp = useCallback((event: React.PointerEvent) => { + if (!event.currentTarget.hasPointerCapture(event.pointerId)) return + event.currentTarget.releasePointerCapture(event.pointerId) + cancelFrame() + latest.current = event.clientX + // A press-only gesture must not overwrite a wider preference with its window-clamped display value. + if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth()) + setDragging(false) + callbacks.current.onEnd() + }, []) + const onPointerCancel = useCallback(() => { + // Cancellation abandons persistence and restores the saved width through onEnd. + cancelFrame() + setDragging(false) + callbacks.current.onEnd() + }, []) + const onWheel = useCallback((event: React.WheelEvent) => { + const body = event.currentTarget.parentElement + /* v8 ignore next -- a width handle renders only inside the Conversation body. */ + if (body === null) return + const scrollport = body.querySelector(':scope > [data-conversation-scroll]') + /* v8 ignore next -- the Conversation body always contains its direct scroll element. */ + if (scrollport === null) return + if (event.ctrlKey || event.deltaY === 0) return + scrollport.scrollBy({ top: wheelDeltaY(event, scrollport) }) + }, []) + + return ( +
    + ) +} + +/** + * Install the main Conversation width axis and render its drag handles. + * @param props - Mounted Conversation body and current presentation phase. + * @returns two active-phase width handles, or no controls outside the active phase. + */ +export function ConversationWidthControls({ container, phase }: ConversationWidthControlsProps) { + const publishWidths = useCallback((container: HTMLDivElement): void => { + const column = container.offsetWidth + container.style.setProperty('--dsh-conversation-column-width', `${column}px`) + const preference = readWidthPreference() + if (preference === null) container.style.removeProperty('--dsh-chat-user-width') + else container.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) + }, []) + + useLayoutEffect(() => { + if (container === null) return + const observer = new ResizeObserver(() => { publishWidths(container) }) + observer.observe(container) + publishWidths(container) + return () => { observer.disconnect() } + }, [container, publishWidths]) + + const onStart = useCallback((): number => { + if (container === null) return 680 + return resolveContentWidth(container.offsetWidth, readWidthPreference()) + }, [container]) + const onDrag = useCallback((width: number): void => { + if (container === null) return + container.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(container.offsetWidth, width)}px`) + }, [container]) + const onCommit = useCallback((width: number): void => { + if (container === null) return + localStorage.setItem(WIDTH_PREF_KEY, `${resolveContentWidth(container.offsetWidth, width)}`) + }, [container]) + const onEnd = useCallback((): void => { + if (container !== null) publishWidths(container) + }, [container, publishWidths]) + + if (container === null || phase !== 'active') return null + return (['left', 'right'] as const).map(side => ( + + )) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx index 640ca316f1..16d1aedc47 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DefaultConversationViews.tsx @@ -11,7 +11,7 @@ import css from './ConversationRoot.module.css' * @returns the active view area, or null while the Session remains blank. */ export function DefaultConversationViews({ - useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, + view, useSession, useConversation, useConversationViews, useInput, inputActions, useStore, actions, renderSlot, bindDraftMirror, openView, }: ConversationSessionSlotProps) { const tabs = useConversationViews(value => value) @@ -32,13 +32,14 @@ export function DefaultConversationViews({ }, [inputActions]) if (session.blank && conversationPhase(session, conversation) === 'blank') return null + const viewId = view ?? active?.id return (
    - {active !== undefined && renderSlot('conversation.view', { + {viewId !== undefined && renderSlot('conversation.view', { viewRequest, openView, completeViewRequest: actions.completeViewRequest, - }, { only: active.id })} + }, { only: viewId })}
    ) } diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index c30130972d..3f756cfee0 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -309,7 +309,9 @@ function mount( : (opts?.fallback ?? null) )) as ConversationContentProps['renderSlotChain'] const SessionProvider: ConversationContentProps['SessionProvider'] = ({ children }) => children - const renderFactorySlot = ((_name: string, input: ConversationContentInputProps) => { + const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: { + slots?: Record ReactNode> + }) => { const common: ConversationViewsProps = { sessionId: SID, SessionProvider, @@ -333,7 +335,9 @@ function mount( selectWorkspace: retargetWorkspace, t, } - const useFactorySlot = (() => StableConversationViews) as ConversationContentProps['useFactorySlot'] + const useFactorySlot = ((name: string, fallback: (props: never) => ReactNode) => ( + name === 'views' ? StableConversationViews : factoryOptions?.slots?.[name] ?? fallback + )) as ConversationContentProps['useFactorySlot'] return ( @@ -667,22 +671,22 @@ describe('ConversationRoot resident composer', () => { it('publishes the column width as a px variable for the shared width axis', () => { const b = mount(sessionSnapshotOf()) - const root = b.view.container.querySelector('[data-phase]') as HTMLElement + const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement // jsdom offsetWidth is 0 until faked: the observer publishes whatever the // layout reports, and the CSS clamp() floors the axis at 680px either way. - Object.defineProperty(root, 'offsetWidth', { value: 1200, configurable: true }) - act(() => { fireResize(root) }) - expect(root.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px') + Object.defineProperty(content, 'offsetWidth', { value: 1200, configurable: true }) + act(() => { fireResize(content) }) + expect(content.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px') // No dragged preference: the user-width override stays absent so the // adaptive clamp term applies. - expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('') + expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('') }) it('drag → persist → window clamp round-trip on a width handle', () => { const b = mount(sessionSnapshotOf()) - const root = b.view.container.querySelector('[data-phase]') as HTMLElement - Object.defineProperty(root, 'offsetWidth', { value: 1600, configurable: true }) - act(() => { fireResize(root) }) + const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement + Object.defineProperty(content, 'offsetWidth', { value: 1600, configurable: true }) + act(() => { fireResize(content) }) const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement expect(handle).not.toBeNull() // jsdom lacks pointer capture: emulate per-element so hasPointerCapture @@ -701,13 +705,13 @@ describe('ConversationRoot resident composer', () => { // inside both bounds (max = 1600 − 176 = 1424 keeps the handles on-column). fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 }) fireEvent.pointerUp(handle, { pointerId: 1, clientX: 825, clientY: 300 }) - expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px') + expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px') expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') // Window shrinks: the displayed width re-clamps (900 − 176 = 724) but the // preference stays. - Object.defineProperty(root, 'offsetWidth', { value: 900, configurable: true }) - act(() => { fireResize(root) }) - expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') + Object.defineProperty(content, 'offsetWidth', { value: 900, configurable: true }) + act(() => { fireResize(content) }) + expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') // A press without travel (a real double-click delivers two such // press/release rounds) must not commit the clamped display value over @@ -715,7 +719,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 }) fireEvent.pointerUp(handle, { pointerId: 1, clientX: 800, clientY: 300 }) expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') - expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') + expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') // No reset affordance on the handle: double-click leaves the preference alone. fireEvent.doubleClick(handle) expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 9c62c0c737..dbd1bdcb5c 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/README.i18n.yaml @@ -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-reference/README.md -README.md: 1a79a22425fff1c7323a3bcb55afd324decbe5e8 -README.zh.md: 7cde5e46e69f008e9fcfffeb3920184d90bddb60 +README.md: 6b5046f1c6300f5ec02d3737617019874762d47a +README.zh.md: 99ba6fda18a73f5686f5e02403e48d5e556f9c3c diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 1a79a22425..6b5046f1c6 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -51,7 +51,7 @@ The source keeps candidate encoding internal to the registration effect: the `/c ### Candidate flow -For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. +For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files, direct subagents of the current Session, and other Sessions. Rows use the resolver's display title, which prefers a subagent's creation label while ordinary Sessions retain their projected title, and render under locale-owned group headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the current directory; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. ### Serialization diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 7cde5e46e6..99ba6fda18 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -51,7 +51,7 @@ Web 用户需要从同一个 `@` 补全菜单提及文件、文件夹或会话 ### 候选流程 -对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 +对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再按文件、当前 Session 的直接 subagent、其他 Session 的确定性顺序排列。各行使用 resolver 的显示标题;subagent 优先使用创建标签,普通 Session 保持投影标题,并渲染在 locale 管理的分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 ### 序列化 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 4eb09811d9..2155604024 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 49c8922b1a..bf2104a1b3 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -61,15 +61,26 @@ export function apply(ctx: ClientContext): void { const now = Date.now() const home = ctx.remote.$host.home const listed = sessions.list.getSnapshot().byId + const sessionRows = sessionItems.map((candidate) => { + const summary = listed[candidate.sessionId] + const child = summary?.origin === 'subagent' && summary.parentId === session.sessionId + return { + child, + row: sessionCandidate( + candidate, + candidate.displayTitle ?? candidate.label, + summary?.updatedAt ?? candidate.createdAt, + now, + home, + t(child ? 'section.subagents' : 'section.sessions'), + t, + ), + } + }) return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), - ...sessionItems.map(candidate => sessionCandidate( - candidate, - listed[candidate.sessionId]?.updatedAt ?? candidate.createdAt, - now, - home, - t, - )), + ...sessionRows.filter(item => item.child).map(item => item.row), + ...sessionRows.filter(item => !item.child).map(item => item.row), ] }, header(_session: ClientSessionContext, req) { @@ -210,9 +221,11 @@ function fileCandidate( function sessionCandidate( candidate: SessionReferenceMentionCandidate, + label: string, updatedAt: number, now: number, home: string | undefined, + section: string, t: Translate, ) { const { unit, n } = relativeTime(updatedAt, now) @@ -224,14 +237,14 @@ function sessionCandidate( : candidate.cwd === undefined ? t('candidate.noCwd') : abbreviateHomePath(candidate.cwd, home) const value: ReferenceCandidateValue = { kind: 'session', - label: candidate.label, + label, mention: candidate.mention, } return { - name: candidate.label, + name: label, description: location === undefined ? age : `${location} · ${age}`, icon: 'session' as const, - section: t('section.sessions'), + section, value: JSON.stringify(value), } } diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index ed5f67a61d..7d185550cc 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -14,6 +14,7 @@ export const NS = 'reference' */ export const zh = { 'section.files': '文件与文件夹', + 'section.subagents': '子智能体', 'section.sessions': '对话', 'candidate.noCwd': '(无工作目录)', 'crumb.root': '工作区', @@ -38,6 +39,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** English dictionary, checked complete against the zh key set. */ export const en = { 'section.files': 'Files & folders', + 'section.subagents': 'Subagents', 'section.sessions': 'Sessions', 'candidate.noCwd': '(no cwd)', 'crumb.root': 'Workspace', diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index e1a533f760..597f579dae 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -74,7 +74,12 @@ async function bench( mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })), - listed: Record = {}, + listed: Record = {}, ): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { const ctx = new Context() ctx.provide('sidebarRight', { openResource: vi.fn() }) @@ -330,6 +335,63 @@ describe('candidates', () => { ]) }) + it('uses current Session titles and groups direct subagents above ordinary Sessions', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [ + { + sessionId: sid('ordinary'), + label: 'Ordinary title', + displayTitle: 'Ordinary title', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[Ordinary title](dsh-session:Im9yZGluYXJ5Ig)', + }, + { + sessionId: sid('worker'), + label: 'Investigate startup', + displayTitle: 'researcher', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[Investigate startup](dsh-session:IndvcmtlciI)', + }, + ], + })) + const { source } = await bench(files, sessions, { + worker: { + updatedAt: UPDATED_AT, + origin: 'subagent', parentId: session.sessionId, + projectionValues: { title: 'Investigate startup' }, + }, + ordinary: { updatedAt: UPDATED_AT, projectionValues: { title: 'Ordinary title' } }, + }) + const candidates = await source.candidates(session, request('worker')) + expect(candidates.map(candidate => ({ name: candidate.name, section: candidate.section }))).toEqual([ + { name: 'researcher', section: 'Subagents' }, + { name: 'Ordinary title', section: 'Sessions' }, + ]) + const [candidate] = candidates + expect(source.onPick({ + candidate: candidate!, + session, + position: 'inline', + via: 'menu', + action: 'pick', + span: { start: 0, end: 7, draftRev: 1 }, + })).toEqual({ + insert: { + source: 'reference', + ref: '@[Investigate startup](dsh-session:IndvcmtlciI)', + label: 'researcher', + appearance: 'session', + clipboardText: '@[Investigate startup](dsh-session:IndvcmtlciI)', + }, + }) + }) + it('reads a session opened moments ago as the present, not a zero distance', async () => { const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) const sessions = vi.fn(() => Promise.resolve({ diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index f96e40610f..c50f5d14c0 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../context/session-reference" }, + { + "path": "../../session/session-title" + }, { "path": "../../typert/protocol" }, diff --git a/packages/client/ui-sidebar-right/src/client/contract/slots.ts b/packages/client/ui-sidebar-right/src/client/contract/slots.ts index da421aa39f..6095752b27 100644 --- a/packages/client/ui-sidebar-right/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar-right/src/client/contract/slots.ts @@ -113,6 +113,8 @@ export interface SidebarRightTabNavigation { export interface SidebarRightTabPlacement { /** Land a new tab in this pane instead. */ readonly paneId?: PaneId + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean /** Resource tabs reveal existing content by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ readonly revealIfOpened?: boolean /** `true` opens in this tab's place — its pane and strip slot — and closes this tab in the same step. */ diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index 682b81c69b..9fb5aaf413 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -115,6 +115,8 @@ export interface SidebarRightBinding { export interface SidebarRightPlacement { /** Land a new tab in this pane instead of the active docked one. */ readonly paneId?: PaneId + /** Prefer a new pane for new content; use the target pane when splitting is unavailable. */ + readonly preferNewPane?: boolean /** Take this tab's place — its pane and its strip slot — and close it in the same step. */ readonly replaceTab?: TabId /** @@ -378,15 +380,30 @@ export class SidebarRightController implements ISidebarRight { placement: SidebarRightPlacement, params: SidebarRightNavigationParams, ): void { + const surface = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId] + ?? (this.binding?.sessionId === sessionId ? this.binding.surfaces[sessionId] : undefined) + const targetPane = surface === undefined ? undefined : placement.paneId ?? activeDockPaneId(surface.layout) + const target = targetPane === undefined ? undefined : surface?.layout.nodes[targetPane] + const preferNewPane = placement.preferNewPane === true + && placement.replaceTab === undefined + && surface !== undefined + && target?.kind === 'pane' + && target.host === 'dock' + && target.tabs.length > 0 + && canSplit(surface.layout) + && dockPaneIds(surface.layout).length < 2 + && this.binding?.sessionId === sessionId + && this.binding.canSplitPane(target.id) const commit = (): void => { actions.openContent(sessionId, { kind: claim.kind, contentId: claim.contentId, title: claim.title, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...preferNewPane ? { preferNewPane: true } : {}, ...placement.replaceTab === undefined ? {} : { replaceTab: placement.replaceTab }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, }, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) } - const layout = this.adopted.get(sessionId)?.store.getSnapshot().bySession[sessionId]?.layout + const layout = surface?.layout const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab] const revealed = layout === undefined || placement.revealIfOpened === false ? undefined : findContentTab(layout, claim.contentId, claim.kind) diff --git a/packages/client/ui-sidebar-right/src/client/stores.ts b/packages/client/ui-sidebar-right/src/client/stores.ts index 6778152500..9478980de6 100644 --- a/packages/client/ui-sidebar-right/src/client/stores.ts +++ b/packages/client/ui-sidebar-right/src/client/stores.ts @@ -32,7 +32,7 @@ import type { DockMode, DockZone, FloatRect, History, LayoutOp, LayoutState, Mint, PaneId, SplitId, TabId, TabRecord, } from '@deepseek-ai/dsh-client-ui-dockkit' import { - activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findPaneContentTab, findTabPane, getPane, + activeDockPaneId, createInitialState, dockPaneIds, EMPTY_HISTORY, findContentTab, findPaneContentTab, findTabPane, getPane, planDropTab, planDuplicateTab, planFloatTab, planOpenContent, planPlaceTab, planResizeSplit, planSetExpanded, planSetMode, planSettle, planSplitPane, planUnfloatPane, record, replay, stepBack, stepForward, } from '@deepseek-ai/dsh-client-ui-dockkit' @@ -87,6 +87,8 @@ export interface OpenContentIntent { readonly title: string /** Land a new tab in this pane. */ readonly paneId?: PaneId + /** Split the target pane and put new content alone in the new pane. */ + readonly preferNewPane?: boolean /** Take this tab's pane and slot, and close it in the same entry. */ readonly replaceTab?: TabId /** Resource tabs reveal an existing identity by default; `false` permits duplicates. Pages always deduplicate within the target pane. */ @@ -319,18 +321,30 @@ export function createSidebarRightStore( // cross-pane reveal is for resources only. const page = contentId === pageAddress(kind) const held = page ? panePage(state, paneId ?? activeDockPaneId(state), kind) : undefined - const planned = held !== undefined - ? { ops: [{ type: 'focusTab' as const, tabId: held }], tabId: held } - : planOpenContent(state, mint, { - kind, - contentId, - title, - ...paneId === undefined ? {} : { paneId }, - ...index === undefined ? {} : { index }, - ...page - ? { revealIfOpened: false } - : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, + const revealed = page + ? held + : intent.revealIfOpened === false ? undefined : findContentTab(state, contentId, kind) + let openedInNewPane: TabId | undefined + const split = intent.preferNewPane === true && replace === undefined && revealed === undefined + ? planSplitPane(state, mint, paneId, (id) => { + openedInNewPane = id + return { id, kind, contentId, title } }) + : [] + const planned = revealed !== undefined + ? { ops: [{ type: 'focusTab' as const, tabId: revealed }], tabId: revealed } + : openedInNewPane !== undefined + ? { ops: split, tabId: openedInNewPane } + : planOpenContent(state, mint, { + kind, + contentId, + title, + ...paneId === undefined ? {} : { paneId }, + ...index === undefined ? {} : { index }, + ...page + ? { revealIfOpened: false } + : intent.revealIfOpened === undefined ? {} : { revealIfOpened: intent.revealIfOpened }, + }) ops.push(...planned.ops) if (replace !== undefined && replace !== planned.tabId) ops.push({ type: 'closeTab', tabId: replace }) settled(planned.tabId) diff --git a/packages/client/ui-sidebar-right/src/client/tab-domain.ts b/packages/client/ui-sidebar-right/src/client/tab-domain.ts index 53b259584e..8dfe2b3ba0 100644 --- a/packages/client/ui-sidebar-right/src/client/tab-domain.ts +++ b/packages/client/ui-sidebar-right/src/client/tab-domain.ts @@ -158,6 +158,7 @@ export class TabDomain { ? { replaceTab: tabId } : held.paneId === undefined ? {} : { paneId: held.paneId }, ...placement.paneId === undefined ? {} : { paneId: placement.paneId }, + ...placement.preferNewPane === undefined ? {} : { preferNewPane: placement.preferNewPane }, ...placement.revealIfOpened === undefined ? {} : { revealIfOpened: placement.revealIfOpened }, }) const held: Held = { diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 35ffc47444..82dcaabca5 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -169,6 +169,39 @@ describe('SidebarRightController — opening', () => { expect(findTabPane(layout(), tabOf('a.txt')).id).toBe(left) }) + it('opens new content alone in a preferred pane and falls back to the current pane at the limit', () => { + const { controller, publish, layout, tabOf, entries, expand } = harness() + expand() + publish() + const before = entries() + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + const a = tabOf('a.txt') + const pane = findTabPane(layout(), a) + expect(dockPaneIds(layout())).toHaveLength(2) + expect(pane.tabs).toEqual([a]) + expect(entries()).toBe(before + 1) + + publish() + controller.openResource('dsh-resource://file/session/s-test/b.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(2) + expect(findTabPane(layout(), tabOf('b.txt')).id).toBe(pane.id) + }) + + it('falls back when the pane is too narrow and reveals existing content without splitting', () => { + const { controller, publish, layout, tabOf, room, expand } = harness() + expand() + publish() + room.allowed = false + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(1) + publish() + + room.allowed = true + controller.openResource('dsh-resource://file/session/s-test/a.txt', { preferNewPane: true }) + expect(dockPaneIds(layout())).toHaveLength(1) + expect(getPane(layout(), layout().activePaneId).activeTabId).toBe(tabOf('a.txt')) + }) + it('takes the replaced tab\'s pane and slot, closes it, and records one entry', () => { const { controller, publish, layout, tabOf, entries } = harness() publish() diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 038b909503..89862a1a19 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -30,8 +30,10 @@ "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", + "@deepseek-ai/dsh-client-ui-sidebar-right", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-client-ui-workspace" ], @@ -49,6 +51,7 @@ "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", @@ -64,6 +67,7 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^" }, diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 5b8582c295..5dcad20181 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -250,6 +250,28 @@ grid-row: 2; } +.sidebarButton { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + margin: 4px 0; + padding: 6px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.sidebarButton:hover, +.sidebarButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + .children { position: relative; margin-left: 18px; diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 1b25072f35..27fda1d281 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -9,7 +9,8 @@ import { import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, StateDot, + IconChevronDownOutline14, IconChevronRightOutline14, IconRefreshOutline14, + StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' @@ -24,6 +25,7 @@ type Catalogs = SessionListState['subagentsByParent'] /** Business actions supplied by the slot registration. */ export interface SubagentCatalogInjected { openChild: (address: SubagentAddress) => void + openChildAside: (address: SubagentAddress) => void refresh: (parentSessionId: SessionId) => void setCatalogOpen: (parentSessionId: SessionId, open: boolean) => void } @@ -42,6 +44,7 @@ interface CatalogRowsProps { level: number now: number openChild: (address: SubagentAddress) => void + openChildAside: (address: SubagentAddress) => void refresh: (parentSessionId: SessionId) => void toggleBranch: (childSessionId: SessionId) => void closeCatalog: () => void @@ -238,7 +241,7 @@ function CatalogLoadingRows({ /** Render one catalog level and recurse only through explicitly expanded rows. */ function CatalogRows({ parentSessionId, currentSessionId, catalog, catalogs, summaries, expanded, level, now, - openChild, refresh, toggleBranch, closeCatalog, t, + openChild, openChildAside, refresh, toggleBranch, closeCatalog, t, }: CatalogRowsProps & { t: TranslateNS }) { const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 const reserveDisclosure = catalog.entries.some( @@ -327,6 +330,12 @@ function CatalogRows({ openChild({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) closeCatalog() } + const openAside = (event: MouseEvent): void => { + event.preventDefault() + event.stopPropagation() + openChildAside({ parentSessionId, childSessionId: entry.id, mode: entry.mode }) + closeCatalog() + } const handleKey = (event: KeyboardEvent): void => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() @@ -392,6 +401,18 @@ function CatalogRows({ )} )} + {!isCurrent && ( + + )}
    {isExpanded && !knownLeaf && ( @@ -420,6 +441,7 @@ function CatalogRows({ level={level + 1} now={now} openChild={openChild} + openChildAside={openChildAside} refresh={refresh} toggleBranch={toggleBranch} closeCatalog={closeCatalog} @@ -482,7 +504,7 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties { /** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */ function CatalogDropdown({ rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false, - useSessions, openChild, refresh, setCatalogOpen, t, + useSessions, openChild, openChildAside, refresh, setCatalogOpen, t, }: CatalogDropdownProps) { const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined const catalogs = useSessions(state => state.subagentsByParent) @@ -784,6 +806,7 @@ function CatalogDropdown({ level={1} now={now} openChild={openChild} + openChildAside={openChildAside} refresh={refresh} toggleBranch={toggleBranch} closeCatalog={() => { changeOpen(false) }} @@ -802,13 +825,13 @@ function CatalogDropdown({ */ export function SubagentHeaderLineage({ lineageSessionId, displayTitle, openTitle, - useSessions, openChild, refresh, setCatalogOpen, t, + useSessions, openChild, openChildAside, refresh, setCatalogOpen, t, }: SubagentHeaderLineageProps) { const parentId = useSessions((state) => { const summary = state.byId[lineageSessionId] return summary?.origin === 'subagent' ? summary.parentId : undefined }) - const shared = { useSessions, openChild, refresh, setCatalogOpen, t } + const shared = { useSessions, openChild, openChildAside, refresh, setCatalogOpen, t } if (parentId === undefined) { return ( = { 'count.running.other': '{count} subagents running', 'switcher.aria': 'Switch subagent: {title}', 'tree.aria': 'Subagent sessions', + 'open.sidebar': 'Open {label} in sidebar', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 68408e7b79..44452a448f 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -72,6 +72,11 @@ async function fullBench(sessions: SessionSummary[]) { face.actionCalls.push({ method: 'openSession', args: [address] }) }, } as never) + ctx.provide('sidebarRight', { + openResource: (address: string, options: unknown) => { + face.actionCalls.push({ method: 'openResource', args: [address, options] }) + }, + } as never) ctx.provide('remote', { $on: () => () => {} } as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await provideSlotFaces(ctx) @@ -92,7 +97,7 @@ const FAMILY: SessionSummary[] = [ describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale']) + expect(inject).toEqual(['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight']) }) it('registers catalog actions and selects read-only subagent composers from session facts', async () => { @@ -106,10 +111,18 @@ describe('apply', () => { mode: 'continuable', } actions.openChild(address) + actions.openChildAside(address) actions.refresh(sid('parent')) actions.setCatalogOpen(sid('parent'), true) expect(face.actionCalls).toEqual([ { method: 'openSession', args: [address] }, + { + method: 'openResource', + args: [ + 'dsh-resource://chat/session/c1?parent=parent&mode=continuable', + { kind: 'chat', preferNewPane: true }, + ], + }, { method: 'refreshSubagents', args: [sid('parent')] }, { method: 'setSubagentCatalogOpen', args: [sid('parent'), true] }, ]) diff --git a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx index 1474b4fe5a..4b828bca13 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx @@ -72,6 +72,7 @@ function props( sessionId: PARENT, useSessions, openChild: vi.fn(), + openChildAside: vi.fn(), refresh: vi.fn(), setCatalogOpen: vi.fn(), lineageSessionId: PARENT, @@ -164,6 +165,14 @@ describe('SubagentHeaderLineage', () => { expect(screen.queryByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeNull() expect(screen.getByRole('treeitem', { name: /reviewer/ }).children).toHaveLength(2) + const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' }) + fireEvent.keyDown(sidebarButton, { key: 'Enter' }) + fireEvent.click(sidebarButton) + expect(input.openChildAside).toHaveBeenCalledWith({ + parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', + }) + + hoverCatalog(trigger) fireEvent.click(screen.getByRole('treeitem', { name: /worker/ })) expect(input.openChild).toHaveBeenCalledWith({ parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index c7652afc94..c82fbadb34 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../ui-renderer" }, + { + "path": "../ui-sidebar-right" + }, { "path": "../ui-session" }, diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 7bd0b1d5f8..6d307eaed5 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.i18n.yaml @@ -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/context/session-reference/README.md -README.md: 533a1282da920b6daa47ef5be98e49c691a8cb84 -README.zh.md: e7b43c9a56cd5b4062770144ccea0bf125153b40 +README.md: 0aa444c6ab8318586e323fcbcf21165e5d5bc11d +README.zh.md: 5da631b846e979916c49b302f5debca8af528325 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 533a1282da..0aa444c6ab 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -39,7 +39,7 @@ For a truncated reference, an optional spill backend saves the full captured tex ### Finding sessions to reference -`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the projected title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. +`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, projected title, or display title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable. Its display title prefers a subagent's durable creation label over that mention label. The candidate also reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. ### Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index e7b43c9a56..5da631b846 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" ### 查找可引用的会话 -`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或投影标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 +`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录、投影标题或显示标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id。显示标题优先使用 subagent 的持久创建标签,再回退到 mention 标签。候选还会报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 ### 配置 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 3b44e78d1f..7deb84f1ab 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -61,6 +61,7 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, @@ -68,6 +69,9 @@ "@deepseek-ai/dsh-spill": { "optional": true }, + "@deepseek-ai/dsh-subagent": { + "optional": true + }, "@deepseek-ai/dsh-session-projection-cache": { "optional": true } @@ -88,6 +92,7 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 2ac9980a90..6655d45ce4 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -18,6 +18,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import type {} from '@deepseek-ai/dsh-session-title' +import type { SubagentIdentityProjection } from '@deepseek-ai/dsh-subagent/client' import type {} from '@deepseek-ai/dsh-system-prompt' import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { prepareReferenceOmission, REFERENCE_WARNING } from './spill.ts' @@ -179,14 +180,13 @@ export class SessionReferenceResolver extends TypertRemoteService { /** * List reference candidates, ranked by working-directory affinity. * - * Discovery runs at keystroke rate, so a title only ever comes from a - * projection read: see {@link SessionReferenceResolver.projectedTitle} for - * which sessions can answer one and which fall back to their id. + * Discovery runs at keystroke rate, so titles and subagent labels only ever + * come from projection reads; sessions without either fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. - * @param query - optional case-insensitive session-id/cwd/title substring. + * @param query - optional case-insensitive session-id/cwd/title/display-title substring. * @param limit - optional positive result cap. * @param signal - optional cancellation boundary for host autocomplete teardown. - * @returns candidates labeled by latest title or, when absent, session id. + * @returns candidates with canonical mention labels and presentation titles. */ async listCandidates( agent: Agent, @@ -203,22 +203,20 @@ export class SessionReferenceResolver extends TypertRemoteService { const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal)) .filter(record => record.header.id !== agent.id) .map((record, index) => ({ record, index })) - const labelled = records.map(({ record, index }) => ({ - record, - index, - label: this.projectedTitle(record) ?? record.header.id, - })) - return labelled.filter(({ record, label }) => { + const labelled = records.map(({ record, index }) => ({ record, index, ...this.projectedLabels(record) })) + return labelled.filter(({ record, label, displayTitle }) => { if (needle === '') return true return record.header.id.toLocaleLowerCase().includes(needle) || record.header.cwd?.toLocaleLowerCase().includes(needle) === true || label.toLocaleLowerCase().includes(needle) + || displayTitle.toLocaleLowerCase().includes(needle) }).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) || a.index - b.index) .slice(0, limit) - .map(({ record, label }) => ({ + .map(({ record, label, displayTitle }) => ({ sessionId: record.header.id, label, + displayTitle, ...record.header.cwd === undefined ? {} : { cwd: record.header.cwd }, sameWorkspace: record.header.cwd !== undefined && record.header.cwd === targetCwd, createdAt: record.header.createdAt, @@ -226,7 +224,7 @@ export class SessionReferenceResolver extends TypertRemoteService { } /** - * The title a session's projections can answer without reading its log. + * The mention label and display title a Session's projections can answer without reading its log. * * Attachment is decided by the store at read time, not by the listing: * a session that attached in between would otherwise be answered from a @@ -245,20 +243,23 @@ export class SessionReferenceResolver extends TypertRemoteService { * and cannot be found by its title until it is opened once, which * checkpoints it. * @param record - the listed session, live or cold. - * @returns the projected title, or undefined when no projection holds one. + * @returns the title-backed mention label and the subagent-label-first display title. */ - private projectedTitle(record: SessionRecord): string | undefined { + private projectedLabels(record: SessionRecord): { label: string; displayTitle: string } { const attached = this.ctx.get('sessions')?.get(record.header.id) const projections = this.ctx.get('sessionProjections') - if (attached !== undefined && projections !== undefined) { - return titleOf(projections.snapshot(attached, ['title'])) - } - if (record.header.isSeeded) return undefined - return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot( - record.header, - SessionLogOffset(0), - ['title'], - )) + const snapshot = attached !== undefined && projections !== undefined + ? projections.snapshot(attached, ['title', 'subagent']) + : record.header.isSeeded + ? undefined + : this.ctx.get('sessionProjectionCache')?.cachedSnapshot( + record.header, + SessionLogOffset(0), + ['title', 'subagent'], + ) + const label = titleOf(snapshot) ?? record.header.id + const subagent = snapshot?.values.subagent as SubagentIdentityProjection | null | undefined + return { label, displayTitle: subagent?.label ?? label } } /** diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 944ed6898d..800c1aa522 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -49,8 +49,10 @@ export interface SessionReferenceInput { export interface SessionReferenceCandidate { /** Opaque source session identity. */ sessionId: SessionId - /** Latest log-backed title, falling back to the opaque session id. */ + /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */ label: string + /** Display text, preferring a subagent's durable creation label over {@link label}. */ + displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string /** diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index dc2caa5067..9d912738c1 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -7,6 +7,7 @@ import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-s import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import SessionTitleService from '@deepseek-ai/dsh-session-title' +import type { SubagentIdentityProjection } from '@deepseek-ai/dsh-subagent/client' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SessionReferenceResolver, { decodeSessionReferenceUri, @@ -54,11 +55,22 @@ async function harness(config: Config = {}): Promise { * resolver reads `cachedSnapshot` alone, and the point under test is which * sessions still reach a log fold. */ -function withProjectionCache(ctx: Context, rows: Record): void { +function withProjectionCache( + ctx: Context, + rows: Record, +): void { ctx.provide('sessionProjectionCache', { - cachedSnapshot: (meta: { id: SessionId }) => ( - meta.id in rows ? { asOfSeq: SessionSeq(0), values: { title: rows[meta.id] } } : undefined - ), + cachedSnapshot: (meta: { id: SessionId }) => { + const row = rows[meta.id] + if (!(meta.id in rows)) return undefined + return { + asOfSeq: SessionSeq(0), + values: row !== null && typeof row === 'object' ? row : { title: row }, + } + }, }) } @@ -633,16 +645,16 @@ describe('session reference discovery and preparation', () => { }) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, - { sessionId: SessionId('same'), label: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 }, - { sessionId: SessionId('none'), label: 'none', sameWorkspace: false, createdAt: 30 }, - { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, + { sessionId: SessionId('same-later'), label: 'Latest title', displayTitle: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, + { sessionId: SessionId('same'), label: 'same', displayTitle: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 }, + { sessionId: SessionId('none'), label: 'none', displayTitle: 'none', sameWorkspace: false, createdAt: 30 }, + { sessionId: SessionId('other'), label: 'other', displayTitle: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([ - { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, + { sessionId: SessionId('other'), label: 'other', displayTitle: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([ - { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, + { sessionId: SessionId('same-later'), label: 'Latest title', displayTitle: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), '', 0)) .rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE')) @@ -675,7 +687,7 @@ describe('session reference discovery and preparation', () => { await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'renamed')) .resolves.toEqual([ - { sessionId: live.id, label: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt }, + { sessionId: live.id, label: 'Renamed mid turn', displayTitle: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'old title')).resolves.toEqual([]) expect(readTitles).not.toHaveBeenCalled() @@ -694,12 +706,39 @@ describe('session reference discovery and preparation', () => { await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'checkpoint')) .resolves.toEqual([ - { sessionId: cold.id, label: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 }, + { sessionId: cold.id, label: 'Cold checkpoint', displayTitle: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 }, ]) expect(readTitles).not.toHaveBeenCalled() vi.restoreAllMocks() }) + it('keeps the projected title as the mention label and prefers the subagent label for display', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const child = { id: SessionId('child'), createdAt: 10, cwd: '/same' } + withProjectionCache(ctx, { + child: { + title: 'Investigate startup', + subagent: { mode: 'continuable', label: 'researcher', seq: SessionSeq(2) }, + }, + }) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ + { header: child, live: false, persisted: true }, + ] as never) + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'researcher')) + .resolves.toEqual([{ + sessionId: child.id, + label: 'Investigate startup', + displayTitle: 'researcher', + cwd: '/same', + sameWorkspace: true, + createdAt: 10, + }]) + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'investigate')) + .resolves.toHaveLength(1) + }) + it('labels a session no projection answers for by its id, still without a log read', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) @@ -718,7 +757,7 @@ describe('session reference discovery and preparation', () => { const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: seeded.id, label: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 }, + { sessionId: seeded.id, label: seeded.id, displayTitle: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 }, ]) // Its own title cannot find it, and discovery still never opens the log. await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'anything')).resolves.toEqual([]) @@ -736,7 +775,7 @@ describe('session reference discovery and preparation', () => { other.append('session/title', { title: 'Unreadable', messageSeqs: [], source: { kind: 'fallback' } }) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: other.id, label: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt }, + { sessionId: other.id, label: other.id, displayTitle: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt }, ]) }) @@ -752,6 +791,7 @@ describe('session reference discovery and preparation', () => { expect(candidates).toEqual([{ sessionId: SessionId('source]'), label: 'source]', + displayTitle: 'source]', cwd: '/same', sameWorkspace: true, createdAt: 20, @@ -842,7 +882,7 @@ describe('session reference discovery and preparation', () => { const source = ctx.sessions.create(SessionId('source')) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([ - { sessionId: source.id, label: source.id, sameWorkspace: false, createdAt: source.header.createdAt }, + { sessionId: source.id, label: source.id, displayTitle: source.id, sameWorkspace: false, createdAt: source.header.createdAt }, ]) }) diff --git a/packages/context/session-reference/tsconfig.json b/packages/context/session-reference/tsconfig.json index 1f57f73147..590eb4122a 100644 --- a/packages/context/session-reference/tsconfig.json +++ b/packages/context/session-reference/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../session/session-title" + }, + { + "path": "../../subagent/subagent" } ] } diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index d6005d86c1..8d39bbebf7 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -406,7 +406,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:176', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180', }, { key: 'conversation.composer.bar', @@ -445,7 +445,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:194', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198', }, { key: 'conversation.composer.dock', @@ -501,7 +501,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:192', }, { key: 'conversation.hero.agentPreset', @@ -538,7 +538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186', }, { key: 'conversation.hero.brand.mark', @@ -567,7 +567,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:180', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184', }, { key: 'conversation.hero.workspace', @@ -600,7 +600,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:182', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -673,7 +673,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:200', }, { key: 'conversation.input.dock', @@ -736,7 +736,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:184', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:188', }, { key: 'conversation.input.left', @@ -790,7 +790,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:194', }, { key: 'conversation.input.model', @@ -829,7 +829,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:210', }, { key: 'conversation.input.overlay', @@ -887,7 +887,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:186', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:190', }, { key: 'conversation.input.permission', @@ -926,7 +926,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:208', }, { key: 'conversation.input.plan', @@ -965,7 +965,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:202', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206', }, { key: 'conversation.input.right', @@ -1019,7 +1019,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:192', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196', }, { key: 'conversation.message.images', @@ -1194,7 +1194,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:131', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:135', }, { key: 'conversation.session.header.actions', @@ -1256,7 +1256,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:139', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:143', }, { key: 'conversation.session.header.corner', @@ -1295,7 +1295,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:168', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:172', }, { key: 'conversation.session.header.leading', @@ -1334,7 +1334,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.leading\', () => ctx.slots.register(\n { name: \'conversation.session.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:157', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:161', }, { key: 'conversation.session.header.lineage', @@ -1375,7 +1375,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:133', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:137', }, { key: 'conversation.session.header.utilities', @@ -1434,7 +1434,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:145', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:149', }, { key: 'conversation.trajectory.images', @@ -1538,7 +1538,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:174', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:178', }, { key: 'main', @@ -2418,6 +2418,43 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.brand.name\', () => ctx.slots.register(\n { name: \'sidebar.brand.name\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', source: 'packages/client/ui-sidebar/src/client/contract/slots.ts:29', }, + { + key: 'sidebar.chat.conversation', + kind: 'single', + scope: 'session', + summary: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.', + doc: 'Session-scoped Conversation occurrence hosted by one Sidebar chat tab.', + registerOptions: [], + ownerProps: [], + ownerPropsReferences: [], + standardProps: [ + 'useResource: UseResource', + 'useWorkspaces: SnapshotSelectorHook', + 'usePanelInfo: UsePanelInfo', + 'useSessions: UseSessions', + 'useSessionStatus: UseSessionStatus', + 'useSessionRetainInfo: UseSessionRetainInfo', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-chat), so it exists while that entry is mounted', + occupants: [ + 'client-ui-chat ConversationSlotPanel', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.chat.conversation\', () => ctx.slots.register(\n { name: \'sidebar.chat.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-chat/src/client/sidebar-chat/index.tsx:42', + }, { key: 'sidebar.footer.action', kind: 'list', @@ -2556,6 +2593,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-chat SidebarChatTab', 'client-ui-deliverables ReviewTab', 'client-ui-plan PlanPreview', 'client-ui-sidebar-browser BrowserBody', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 2f9c0916e0..5e6d2a4bcf 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2065,9 +2065,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise', - description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so a title only ever comes from a projection read: see SessionReferenceResolver.projectedTitle for which sessions can answer one and which fall back to their id.', - parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], - returns: 'candidates labeled by latest title or, when absent, session id.', + description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so titles and subagent labels only ever come from projection reads; sessions without either fall back to their id.', + parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title/display-title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], + returns: 'candidates with canonical mention labels and presentation titles.', }, { signature: '@Remote(\'candidates\') async remoteExportCandidates( agent: Agent, query: string, signal: AbortSignal, ): Promise', @@ -5994,7 +5994,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionReferenceCandidate', - declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n cwd?: string;\n sameWorkspace: boolean;\n createdAt: number;\n}', + declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n displayTitle?: string;\n cwd?: string;\n sameWorkspace: boolean;\n createdAt: number;\n}', }, { name: 'SessionReferenceInput', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2eeac7f3f3..d0d3427667 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2742,6 +2742,9 @@ importers: '@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 @@ -2808,6 +2811,9 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter @@ -3887,6 +3893,9 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../context/session-reference + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session/session-title '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -4765,6 +4774,9 @@ importers: '@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 @@ -4780,6 +4792,9 @@ 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 @@ -5603,6 +5618,9 @@ importers: '@deepseek-ai/dsh-spill-local': specifier: workspace:^ version: link:../../spill/spill-local + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt diff --git a/snapshots/web/subagent-conversation/branchless.expected.md b/snapshots/web/subagent-conversation/branchless.expected.md index 1f51342ed6..e17c69be2d 100644 --- a/snapshots/web/subagent-conversation/branchless.expected.md +++ b/snapshots/web/subagent-conversation/branchless.expected.md @@ -1,2 +1,5 @@ - tree "Subagent sessions": - - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: example editor continuable · not running 0 tok {{duration}} + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=1]: + - text: example editor continuable · not running 0 tok {{duration}} + - button "Open example editor in sidebar": + - img diff --git a/snapshots/web/subagent-conversation/tree.expected.md b/snapshots/web/subagent-conversation/tree.expected.md index e71d768153..de09b64909 100644 --- a/snapshots/web/subagent-conversation/tree.expected.md +++ b/snapshots/web/subagent-conversation/tree.expected.md @@ -1,8 +1,16 @@ - tree "Subagent sessions": - - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d + - treeitem "event-sourcing reviewer one-shot · not running 0 tok · {{duration}}" [level=1]: + - text: event-sourcing reviewer one-shot · not running 0 tok ~6mo 12d + - button "Open event-sourcing reviewer in sidebar": + - img - treeitem "event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok · {{duration}}" [expanded] [level=1]: - button "Collapse event-sourcing researcher descendants": - img - text: event-sourcing researcher Explain event sourcing in one · continuable · not running 7.9K tok {{duration}} + - button "Open event-sourcing researcher in sidebar": + - img - group: - - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: example editor continuable · not running 0 tok {{duration}} + - treeitem "example editor continuable · not running 0 tok · {{duration}}" [level=2]: + - text: example editor continuable · not running 0 tok {{duration}} + - button "Open example editor in sidebar": + - img From da7dc34f7d20c1b8ed562463c37a7c3cc7c6f198 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:46:37 +0800 Subject: [PATCH 064/225] fix(client): align subagent sidebar references --- apps/web/tests/subagent-conversation.e2e.ts | 24 +++++++++++++ docs/subsystems/client-resources.i18n.yaml | 4 +-- docs/subsystems/client-resources.md | 3 +- docs/subsystems/client-resources.zh.md | 3 +- docs/subsystems/session-reference.md | 6 ++-- docs/subsystems/session-reference.zh.md | 6 ++-- docs/subsystems/sidebar-right.i18n.yaml | 4 +-- docs/subsystems/sidebar-right.md | 2 ++ docs/subsystems/sidebar-right.zh.md | 2 ++ packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 ++ packages/client/ui-chat/README.zh.md | 2 ++ packages/client/ui-chat/src/client/index.ts | 4 --- .../ui-chat/src/client/sidebar-chat/index.tsx | 5 ++- .../tests/sidebar-chat.client.spec.tsx | 15 +++++--- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 4 +-- packages/client/ui-conversation/README.zh.md | 4 +-- .../client/skeleton/ConversationContent.tsx | 2 +- .../skeleton/ConversationRoot.module.css | 8 ++--- .../skeleton/ConversationWidthControls.tsx | 10 +++--- .../tests/skeleton.client.spec.tsx | 12 ++++--- packages/client/ui-reference/package.json | 1 - .../tests/browser-plugin.client.spec.ts | 6 ++-- packages/client/ui-reference/tsconfig.json | 3 -- .../client/ui-sidebar-right/README.i18n.yaml | 4 +-- packages/client/ui-sidebar-right/README.md | 2 ++ packages/client/ui-sidebar-right/README.zh.md | 2 ++ packages/client/ui-subagent/README.i18n.yaml | 4 +-- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- packages/context/session-reference/README.md | 2 +- .../context/session-reference/README.zh.md | 2 +- .../context/session-reference/package.json | 3 -- .../context/session-reference/src/index.ts | 24 ++++++++----- .../context/session-reference/src/types.ts | 4 +-- .../tests/session-reference.spec.ts | 12 +++++-- pnpm-lock.yaml | 3 -- .../sidebar-chat.expected.md | 35 +++++++++++++++++++ 39 files changed, 165 insertions(+), 76 deletions(-) create mode 100644 snapshots/web/subagent-conversation/sidebar-chat.expected.md diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 47999bb1cb..efd3677f8e 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -28,6 +28,7 @@ const TREE_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-con const BRANCHLESS_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/branchless.expected.md', import.meta.url)) const STALE_CATALOG_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/stale-catalog.expected.md', import.meta.url)) const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar.expected.md', import.meta.url)) +const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -403,6 +404,29 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await page.getByRole('tree', { name: 'Subagent sessions' }).press('Escape') }) + it('opens child history in the right Sidebar and releases it when closed', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-sidebar-chat')) + await page.getByRole('button', { name: '3 subagents' }).hover() + await page.getByRole('button', { name: `Open ${LABEL} in sidebar` }).click() + const sidebarChat = page.locator('[data-sidebar-chat]') + await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 }) + await compareOrRefreshGolden( + SIDEBAR_CHAT_EXPECTED, + await captureStableAria(page, '[data-sidebar-chat]', scaffold.workspaceCwd), + MODE, + ) + await page.locator('[data-sidebar-right-panel] [data-dockkit-tab-close]').click() + await sidebarChat.waitFor({ state: 'detached' }) + + await page.getByRole('button', { name: '3 subagents' }).hover() + await page.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar` }).click() + await page.locator('[data-sidebar-chat]').getByText( + 'One-shot tasks do not accept follow-ups; review the full execution record here.', + ).waitFor({ timeout: 15_000 }) + await page.locator('[data-sidebar-right-panel] [data-dockkit-tab-close]').click() + await page.locator('[data-sidebar-chat]').waitFor({ state: 'detached' }) + }) + it('opens the completed child from persistence without activating it', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-open')) await page.getByRole('button', { name: '3 subagents' }).hover() diff --git a/docs/subsystems/client-resources.i18n.yaml b/docs/subsystems/client-resources.i18n.yaml index 9957789d79..06d415d06b 100644 --- a/docs/subsystems/client-resources.i18n.yaml +++ b/docs/subsystems/client-resources.i18n.yaml @@ -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/client-resources.md -client-resources.md: d89ba6af433cd41066835be85d348b8fdf2afef0 -client-resources.zh.md: 6bfbb3e4470f89c1a90853427ca6f3a684120b3e +client-resources.md: 77b54f26beecf3a9898657b61d4ea41d5e91e03b +client-resources.zh.md: ffb1c7422d67a4ca97d01a115d8fa52fc0604490 diff --git a/docs/subsystems/client-resources.md b/docs/subsystems/client-resources.md index d89ba6af43..77b54f26be 100644 --- a/docs/subsystems/client-resources.md +++ b/docs/subsystems/client-resources.md @@ -15,6 +15,7 @@ A resource address is a `dsh-resource:///…` URL. The host names the prot | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered | | `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed | | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits | +| `dsh-resource://chat/session/c1?parent=p1&mode=continuable` | `chat` | an addressed subagent Conversation whose value owns a `SessionReference` until the resource closes | | `sidebar://guide` | — | `none`: a navigation address | | `/home/me/notes.md` | — | `none`: not a URL | @@ -84,7 +85,7 @@ A consumer presents `failed` itself: the model keeps the last value beside the f A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)). -Streams carry metadata, not content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). +Most streams carry metadata rather than content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). The `chat` provider retains the addressed Session and yields its `SessionReference`, then releases that reference when the resource signal aborts. ## Limits diff --git a/docs/subsystems/client-resources.zh.md b/docs/subsystems/client-resources.zh.md index 6bfbb3e447..ffb1c7422d 100644 --- a/docs/subsystems/client-resources.zh.md +++ b/docs/subsystems/client-resources.zh.md @@ -15,6 +15,7 @@ | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) | | `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session | | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 | +| `dsh-resource://chat/session/c1?parent=p1&mode=continuable` | `chat` | 已寻址的 subagent Conversation;其值持有一个 `SessionReference`,直到资源关闭 | | `sidebar://guide` | — | `none`:导航地址 | | `/home/me/notes.md` | — | `none`:不是 URL | @@ -84,7 +85,7 @@ export function FileHeader({ useTabInfo, useResource, t }: Props) { 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 -流只推元数据不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。 +多数流只推元数据而不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。`chat` 提供方保留已寻址 Session 并产出其 `SessionReference`,在资源 signal 中止时释放该 reference。 ## 限制 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 0d3cd061f4..a2567a6cc3 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -34,16 +34,16 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` is host-facing discovery output. Its mention label uses the latest Session title when present, while its optional display text prefers a subagent's durable creation label; filtering searches both alongside Session id and cwd, never transcript text. +`SessionReferenceCandidate` is host-facing discovery output. Its `label` uses the latest Session title when present, while its optional display text prefers a subagent's durable creation label; filtering searches both alongside Session id and cwd, never transcript text. The Remote candidate's canonical mention uses the display text when present. ```ts type-equiv /** One host-facing candidate from exact session metadata. */ interface SessionReferenceCandidate { /** Opaque source session identity. */ sessionId: SessionId - /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */ + /** Latest log-backed title, falling back to the opaque session id. */ label: string - /** Display text, preferring a subagent's durable creation label over {@link label}. */ + /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */ displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 9537096eec..43cd567aaa 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -34,16 +34,16 @@ interface SessionReferenceInput { } ``` -`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新 Session 标题时,它的 mention label 使用该标题;可选显示文本则优先使用 subagent 的持久创建 label。筛选会同时搜索两者、Session id 与 cwd,绝不搜索 transcript(文本记录)。 +`SessionReferenceCandidate` 是面向宿主的发现输出。存在最新 Session 标题时,它的 `label` 使用该标题;可选显示文本则优先使用 subagent 的持久创建 label。筛选会同时搜索两者、Session id 与 cwd,绝不搜索 transcript(文本记录)。Remote 候选在 `displayTitle` 存在时用它标记规范 mention。 ```ts type-equiv /** One host-facing candidate from exact session metadata. */ interface SessionReferenceCandidate { /** Opaque source session identity. */ sessionId: SessionId - /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */ + /** Latest log-backed title, falling back to the opaque session id. */ label: string - /** Display text, preferring a subagent's durable creation label over {@link label}. */ + /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */ displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 420052828a..222e948967 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -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/sidebar-right.md -sidebar-right.md: 470b72118ee680f84489d13668e74c0eede652b0 -sidebar-right.zh.md: 62481c4024e6647a7e620a9c8e72ed6d0c969a60 +sidebar-right.md: 327bce99574d66c51bae4b93c30dff3fe8b0f17c +sidebar-right.zh.md: beebbdfe8ac82abe2354f330b11ba0b9407681d3 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 470b72118e..327bce9957 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -79,6 +79,7 @@ Two opens are the navigation controller, and every way into the column calls one | `paneId` | Land a new tab in this pane; default is the active docked pane (the first docked pane while a floating pane is active). | | `replaceTab` | Take this tab's pane and strip slot, closing it in the same step; a floating tab lends no place, so the new tab lands as if unplaced. | | `revealIfOpened` | Default `true`: a tab already showing the same `(kind, address)` is focused and handed `params`. `false` opens another tab regardless. | +| `preferNewPane` | Prefer a new docked pane under the normal pane budget and room rule; fall back to the target pane when it cannot split. Ignored with `replaceTab`. | | `kind` (`openResource` only) | Name the opening type instead of ranking claims; its implementation in force opens the address, and its `canOpen` still applies. | | `params` | Navigation parameters for the body, delivered as `navigation.params`. `openResource` types them by resource type through the merge-extensible `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); `openTab` types them by kind through `SidebarRightTabParamsMap`, `undefined` for a kind that declares none; a body reads `SidebarRightNavigationParams`, the union of both. Values are JSON-shaped by convention and not validated at run time. | @@ -136,6 +137,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). - **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). +- **`chat`** — `builtin`, `dsh-resource://chat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-chat/README.md)). ## Not built diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 62481c4024..beebbdfe8a 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -79,6 +79,7 @@ export function apply(ctx: Context): void { | `paneId` | 新 tab 落到这个 pane;缺省为活动的停靠 pane(活动的是浮窗时取第一个停靠 pane)。 | | `replaceTab` | 占用这个 tab 的 pane 与条上位置,并在同一步关闭它;浮窗里的 tab 让不出位置,新 tab 按未指定位置落位。 | | `revealIfOpened` | 缺省 `true`:已显示同一 `(kind, address)` 的 tab 被聚焦并收到 `params`。`false` 则无论如何再开一个。 | +| `preferNewPane` | 在普通格数预算与空间规则下优先新建停靠格;不能分栏时回退到目标格。与 `replaceTab` 一起使用时忽略。 | | `kind`(仅 `openResource`) | 点名打开类型而不排候选;该 kind 的生效实现打开地址,它的 `canOpen` 仍生效。 | | `params` | 给正文的导航参数,作为 `navigation.params` 送达。`openResource` 按资源类型经声明合并表 `SidebarRightResourceParamsMap` 定型(文本预览声明 `{ line?: number }`);`openTab` 按 kind 经 `SidebarRightTabParamsMap` 定型,未声明的 kind 为 `undefined`;正文读到的是二者联合 `SidebarRightNavigationParams`。值按约定为 JSON 形状,运行时不校验。 | @@ -136,6 +137,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 - **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 +- **`chat`**——`builtin`,`dsh-resource://chat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-chat/README.zh.md))。 ## 不做 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 1d6df37e4d..32765f7b6e 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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-chat/README.md -README.md: e7d3efe37e089f4b7bcbb7d954e6791899cebfc0 -README.zh.md: 8b49332ad39d373fcc2aae66af2e7c284bfee9bb +README.md: a4e67d1b6d7b377ed2353b9f3bc1e78c16798b6a +README.zh.md: 499c36ce49de85209a74fb35e3dc9d9b5d12467b diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index e7d3efe37e..a4e67d1b6d 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,6 +33,8 @@ Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. +Chat also registers the `dsh-resource://chat/session/?parent=&mode=` resource and builtin Sidebar tab type for an explicitly addressed subagent Conversation. The resource retains one `SessionReference`, refreshes the direct-parent catalog before opening, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. + ## System prompt row diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 8b49332ad3..499c36ce49 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -33,6 +33,8 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 +Chat 还为显式寻址的 subagent Conversation 注册 `dsh-resource://chat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源在打开前刷新直接 parent 目录并保留一个 `SessionReference`,在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 + ## 系统提示词行 diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index 0744be6edc..dea4e2668a 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -1,9 +1,5 @@ /** Browser Chat target plugin. */ export { apply, inject } from './apply.ts' -export { - ConversationSlotPanel, FixedChatConversationView, parseSidebarChatAddress, - sidebarChatAddress, SIDEBAR_CHAT_ADDRESS, SIDEBAR_CHAT_ID, -} from './sidebar-chat/index.tsx' export type { ConversationSlotPanelProps, SidebarChatResource, SidebarChatTabProps, } from './sidebar-chat/index.tsx' diff --git a/packages/client/ui-chat/src/client/sidebar-chat/index.tsx b/packages/client/ui-chat/src/client/sidebar-chat/index.tsx index 735251016b..6f4478ebef 100644 --- a/packages/client/ui-chat/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-chat/src/client/sidebar-chat/index.tsx @@ -101,6 +101,9 @@ function chatResourceProvider(sessions: ISessions): ResourceProvider<'chat'> { async *open(resourceAddress, { signal }) { const address = parseSidebarChatAddress(resourceAddress) if (address === undefined) throw new Error(`ui-chat: invalid chat resource address "${resourceAddress}"`) + if (signal.aborted) return + await sessions.refreshSubagents(address.parentSessionId) + if (signal.aborted) return const reference = sessions.retain(address, { source: 'sidebarChat', signal }) try { yield { ok: true, value: { address, reference } } @@ -183,7 +186,7 @@ export function registerSidebarChat(ctx: Context, t: TranslateNS): vo const child = parseSidebarChatAddress(address)?.childSessionId return child === undefined ? t('view.chat') - : ctx.sessions.list.getSnapshot().byId[child]?.displayTitle ?? child + : ctx.sessions.list.getSnapshot().byId[child]?.projectionValues?.subagent?.label ?? child }, } satisfies SidebarRightTabDefinition), 'ui-chat: Sidebar chat type') ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ diff --git a/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx index 15a14cfd6f..fcd313cba0 100644 --- a/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx @@ -64,10 +64,16 @@ describe('Sidebar chat registration', () => { [Symbol.dispose]: release, } as unknown as SessionReference const retain = vi.fn(() => reference) + const refreshSubagents = vi.fn(() => Promise.resolve()) const list = { getSnapshot: () => ({ ids: [], - byId: { [CHILD]: { displayTitle: 'Worker' } }, + byId: { + [CHILD]: { + displayTitle: 'Projected title', + projectionValues: { subagent: { mode: 'continuable', label: 'Worker', seq: 1 } }, + }, + }, phase: 'ready', subagentsByParent: {}, jobsBySession: {}, @@ -78,7 +84,7 @@ describe('Sidebar chat registration', () => { let definition: SidebarRightTabDefinition | undefined const registrations: { options: Record; component: unknown }[] = [] const ctx = { - sessions: { retain, list } as unknown as ISessions, + sessions: { retain, refreshSubagents, list } as unknown as ISessions, resources: { register: (value: ResourceProvider<'chat'>) => { provider = value; return () => {} } }, sidebarRightTabs: { register: (value: SidebarRightTabDefinition) => { definition = value; return () => {} } }, slots: { @@ -107,6 +113,7 @@ describe('Sidebar chat registration', () => { const controller = new AbortController() const stream = provider!.open(sidebarChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]() expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } }) + expect(refreshSubagents).toHaveBeenCalledWith(PARENT) expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal }) const completion = stream.next() await Promise.resolve() @@ -117,9 +124,9 @@ describe('Sidebar chat registration', () => { const alreadyAborted = new AbortController() alreadyAborted.abort() const stopped = provider!.open(sidebarChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]() - expect((await stopped.next()).done).toBe(false) expect(await stopped.next()).toEqual({ done: true, value: undefined }) - expect(release).toHaveBeenCalledTimes(2) + expect(retain).toHaveBeenCalledOnce() + expect(release).toHaveBeenCalledOnce() const invalid = provider!.open('invalid', { signal: new AbortController().signal })[Symbol.asyncIterator]() await expect(invalid.next()).rejects.toThrow('invalid chat resource address') diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0adc73f139..0bf28301d0 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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-conversation/README.md -README.md: c611304e5a359ef5556b232ea9c3236a95f56897 -README.zh.md: fad2daa506414d731936f3a60ed7278f92eaae94 +README.md: bc3b582f9d80e97c29e27dcaa680dc064ec30deb +README.zh.md: 21881e7933cdf46cf9e27a66f076fc40c48e4b1a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index c611304e5a..bc3b582f9d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -48,7 +48,7 @@ Claimed commands retain their identity and highlight when only their arguments a Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. -The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes one strict-Session local position, `views`; its default adapter renders the existing `conversation.session` entry, so that body and the Header retain one shared strict-Session store. Another occurrence can replace `views` without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. @@ -56,7 +56,7 @@ View selection is deterministic: a registered persisted selection wins, otherwis The shell reads the persisted View preference before rendering when a Session first binds or a cached Session becomes current, activates the registered preferred View or Chat fallback, and activates later tab or focus selections before committing them to the store. A blank Session still omits the `conversation.view` slot; no unselected target is activated. -Active transcripts expose content-width drag handles in their uncovered side gutters. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)). +Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.md)). The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index fad2daa506..21881e7933 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -48,7 +48,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 -本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开一个 strict-Session 局部位置 `views`;其默认 adapter 渲染现有 `conversation.session` entry,因此该 body 与 Header 继续共享一个 strict-Session store。其他 occurrence 可以替换 `views`,且不会渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 @@ -56,7 +56,7 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染前读取持久化 View 偏好,激活已注册的偏好 View 或 Chat fallback,并在后续 tab 或 focus 选择写入 store 前先激活对应 target。blank Session 仍不渲染 `conversation.view` slot;未选中的 target 不会激活。 -活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。 +主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([决策](../../../.agents/notes/implemented/bug-fix/2026-09-14-transcript-width-handle-layering.zh.md))。 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx index c44abab01b..adace1c113 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationContent.tsx @@ -184,7 +184,7 @@ export function ConversationContent(props: ConversationContentProps) { ref={setBody} className={clsx(css.body, props.variant === 'embedded' && css.embeddedBody)} data-conversation-content="" - data-phase={phase} + data-content-phase={phase} >
    {sessionId === undefined ? null : } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index bd6fef3eb1..62a1991e57 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -396,13 +396,13 @@ } .root[data-phase='active'] .viewArea, -.embeddedBody[data-phase='active'] .viewArea { +.embeddedBody[data-content-phase='active'] .viewArea { flex: 1 0 auto; min-height: auto; } .root[data-phase='active'] .composerSeat, -.embeddedBody[data-phase='active'] .composerSeat { +.embeddedBody[data-content-phase='active'] .composerSeat { position: sticky; bottom: 0; /* Above markdown CodeBlock sticky banners (z-index 6) so the footer never @@ -506,7 +506,7 @@ /* Hero: the composer sits inside the session scroll body; center there so the tree seat matches active (sticky footer) without a Root remount. */ .root[data-phase='hero'] .scrollBody, -.embeddedBody[data-phase='hero'] .scrollBody { +.embeddedBody[data-content-phase='hero'] .scrollBody { justify-content: center; overflow-y: auto; } @@ -514,6 +514,6 @@ /* Settling (session replaying, hero/docked unknown): keep the composer seat mounted but invisible so no wrong layout flashes before the phase lands. */ .root[data-phase='settling'] .composerSeat, -.embeddedBody[data-phase='settling'] .composerSeat { +.embeddedBody[data-content-phase='settling'] .composerSeat { visibility: hidden; } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx index be6927d42d..d2755fee9c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationWidthControls.tsx @@ -130,11 +130,12 @@ function WidthHandle(props: { */ export function ConversationWidthControls({ container, phase }: ConversationWidthControlsProps) { const publishWidths = useCallback((container: HTMLDivElement): void => { + const target = container.parentElement ?? container const column = container.offsetWidth - container.style.setProperty('--dsh-conversation-column-width', `${column}px`) + target.style.setProperty('--dsh-conversation-column-width', `${column}px`) const preference = readWidthPreference() - if (preference === null) container.style.removeProperty('--dsh-chat-user-width') - else container.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) + if (preference === null) target.style.removeProperty('--dsh-chat-user-width') + else target.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(column, preference)}px`) }, []) useLayoutEffect(() => { @@ -151,7 +152,8 @@ export function ConversationWidthControls({ container, phase }: ConversationWidt }, [container]) const onDrag = useCallback((width: number): void => { if (container === null) return - container.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(container.offsetWidth, width)}px`) + const target = container.parentElement ?? container + target.style.setProperty('--dsh-chat-user-width', `${resolveContentWidth(container.offsetWidth, width)}px`) }, [container]) const onCommit = useCallback((width: number): void => { if (container === null) return diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 3f756cfee0..e95808c1bc 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -672,19 +672,21 @@ describe('ConversationRoot resident composer', () => { it('publishes the column width as a px variable for the shared width axis', () => { const b = mount(sessionSnapshotOf()) const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement + const root = content.parentElement as HTMLElement // jsdom offsetWidth is 0 until faked: the observer publishes whatever the // layout reports, and the CSS clamp() floors the axis at 680px either way. Object.defineProperty(content, 'offsetWidth', { value: 1200, configurable: true }) act(() => { fireResize(content) }) - expect(content.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px') + expect(root.style.getPropertyValue('--dsh-conversation-column-width')).toBe('1200px') // No dragged preference: the user-width override stays absent so the // adaptive clamp term applies. - expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('') + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('') }) it('drag → persist → window clamp round-trip on a width handle', () => { const b = mount(sessionSnapshotOf()) const content = b.view.container.querySelector('[data-conversation-content]') as HTMLElement + const root = content.parentElement as HTMLElement Object.defineProperty(content, 'offsetWidth', { value: 1600, configurable: true }) act(() => { fireResize(content) }) const handle = b.view.container.querySelector('[data-width-handle="right"]') as HTMLElement @@ -705,13 +707,13 @@ describe('ConversationRoot resident composer', () => { // inside both bounds (max = 1600 − 176 = 1424 keeps the handles on-column). fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 }) fireEvent.pointerUp(handle, { pointerId: 1, clientX: 825, clientY: 300 }) - expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px') + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('970px') expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') // Window shrinks: the displayed width re-clamps (900 − 176 = 724) but the // preference stays. Object.defineProperty(content, 'offsetWidth', { value: 900, configurable: true }) act(() => { fireResize(content) }) - expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') // A press without travel (a real double-click delivers two such // press/release rounds) must not commit the clamped display value over @@ -719,7 +721,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.pointerDown(handle, { pointerId: 1, clientX: 800, clientY: 300 }) fireEvent.pointerUp(handle, { pointerId: 1, clientX: 800, clientY: 300 }) expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') - expect(content.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') + expect(root.style.getPropertyValue('--dsh-chat-user-width')).toBe('724px') // No reset affordance on the handle: double-click leaves the preference alone. fireEvent.doubleClick(handle) expect(localStorage.getItem('dsh.conversation.contentWidth')).toBe('970') diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 2155604024..4eb09811d9 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -57,7 +57,6 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 597f579dae..ff8ae73411 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -356,7 +356,7 @@ describe('candidates', () => { cwd: `${HOME}/project`, sameWorkspace: true, createdAt: CREATED_AT, - mention: '@[Investigate startup](dsh-session:IndvcmtlciI)', + mention: '@[researcher](dsh-session:IndvcmtlciI)', }, ], })) @@ -384,10 +384,10 @@ describe('candidates', () => { })).toEqual({ insert: { source: 'reference', - ref: '@[Investigate startup](dsh-session:IndvcmtlciI)', + ref: '@[researcher](dsh-session:IndvcmtlciI)', label: 'researcher', appearance: 'session', - clipboardText: '@[Investigate startup](dsh-session:IndvcmtlciI)', + clipboardText: '@[researcher](dsh-session:IndvcmtlciI)', }, }) }) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index c50f5d14c0..f96e40610f 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -23,9 +23,6 @@ { "path": "../../context/session-reference" }, - { - "path": "../../session/session-title" - }, { "path": "../../typert/protocol" }, diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index 7127827b55..4de8cd03f3 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -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-sidebar-right/README.md -README.md: e838f952530d71510c14cbb948d64c8fac319787 -README.zh.md: a529c31c8abb4c4927683e128cc0938a155e51c0 +README.md: dbe09becc8f5f322720c21f89e5042a1f423e6f0 +README.zh.md: 357f9366298dcc4313068d78f73105691d82c129 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index e838f95253..dbe09becc8 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -90,6 +90,8 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `openResource(address, options?)` and `openTab(kind, options?)` are the navigation controller, and every way into the column calls one of them: the conversation's file links and a tool row's line reference (`openResource(fileAddress, { params: { line } })`), the strip's add control and a guide entry box (`openTab`), a file tree's rows (`tab.actions.openResource`). A resource address is a `dsh-resource:///…` URI; without `options.kind` the registry claims it (globs and `canOpen`, best band wins), with it that kind's type in force opens it. A page is named by kind; the tab is recorded under an address this package composes and nobody else spells (`contract/seed.ts`). Both run the same steps as one history entry: a resource tab already showing the same (kind, contentId) is focused wherever it sits unless `revealIfOpened: false`; ordinary page tabs always deduplicate within the target pane, regardless of that option; otherwise a new tab lands in `options.replaceTab`'s pane and slot (closing that tab), else `options.paneId`, else the active docked pane; the panel expands, because content the user cannot see is not opened. Then the Tab domain records the navigation — `params` reach the body as `navigation.params`, with `revision` stepped — outside the layout history. `params` is typed by what is opened: a viewer for a resource type merges its entry into `SidebarRightResourceParamsMap` (the text preview declares `{ line?: number }`); a page type that takes parameters merges into `SidebarRightTabParamsMap` under its kind; values are JSON-shaped by convention, unchecked at run time. An address outside `dsh-resource://`, one no type claims, or a kind nothing registered throws: that is a wiring mistake, not a user error. +`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. + `close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index a529c31c8a..357f936629 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -90,6 +90,8 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `openResource(address, options?)` 与 `openTab(kind, options?)` 是导航控制器,进入该列的每条路都调用其中之一:会话区的文件链接与工具行的行号引用(`openResource(fileAddress, { params: { line } })`),tab 条的添加控件与引导入口框(`openTab`),文件树的行(`tab.actions.openResource`)。资源地址是 `dsh-resource:///…` URI;不带 `options.kind` 时由注册表认领(glob 与 `canOpen`,最高档胜出),带它时由该 kind 生效的类型打开。页按 kind 命名;tab 记录在本包拼出、别处无人书写的地址下(`contract/seed.ts`)。两者以同一组步骤作为一条历史运行:已展示同一 (kind, contentId) 的资源 tab 被聚焦,不限所在分栏,除非 `revealIfOpened: false`;页 tab 始终只在目标分栏内去重,不受该选项影响;否则新 tab 落到 `options.replaceTab` 所在的格与位置(并关掉那个 tab),再退而落到 `options.paneId`,再退而落到活跃停靠格;面板展开,因为用户看不到的内容不算打开。随后 Tab 域记录这次导航——`params` 以 `navigation.params` 抵达正文,`revision` 递增——不进布局历史。`params` 按所开之物定型:某资源类型的查看器把自己那项并入 `SidebarRightResourceParamsMap`(文本预览声明 `{ line?: number }`);接受参数的页类型按其 kind 并入 `SidebarRightTabParamsMap`;值约定为 JSON 形状,运行时不校验。`dsh-resource://` 之外的地址、无人认领的地址、或未注册的 kind 都会 throw:那是接线错误,不是用户错误。 +`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 + `close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。 diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index e9e00343e6..aa6f36e574 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -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-subagent/README.md -README.md: f4c6da14e5cf3788f2ea0c40902b2dc07558ec7c -README.zh.md: d96b6dfcaadd02514dc65d74a3a1f266a98f1f3c +README.md: 69ea7da7dea7ca9ee2fa5bc07210441bb984509c +README.zh.md: 131f26219acbcc6d4a86dfda0af3b36408546295 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index f4c6da14e5..69ea7da7de 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -25,7 +25,7 @@ Use this package to browse every subagent conversation beneath a parent session, ## Use this package -The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address. +The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. ### Browsing the tree diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index d96b6dfcaa..131f26219a 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -会话页头保留当前会话 title 作为谱系面包屑,并在会话存在 subagent 后代时,于页头操作行之前追加 `/` 数量触发器;触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话。 +会话页头保留当前会话 title 作为谱系面包屑,并在会话存在 subagent 后代时,于页头操作行之前追加 `/` 数量触发器;触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 ### 浏览目录 diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index 0aa444c6ab..e1afeef9b0 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -39,7 +39,7 @@ For a truncated reference, an optional spill backend saves the full captured tex ### Finding sessions to reference -`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, projected title, or display title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable. Its display title prefers a subagent's durable creation label over that mention label. The candidate also reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. +`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, projected title, or display title, and ranks same-directory sessions first. Each candidate carries its latest title as `label`, falling back to the session id when the title is absent or unreadable. Its display title prefers a subagent's durable creation label over that title. The candidate also reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which labels the canonical mention with `displayTitle` when present. ### Configuration diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index 5da631b846..e55dab4f64 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -39,7 +39,7 @@ kind: "package-reference" ### 查找可引用的会话 -`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录、投影标题或显示标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id。显示标题优先使用 subagent 的持久创建标签,再回退到 mention 标签。候选还会报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 +`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录、投影标题或显示标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选的 `label` 使用最新标题;标题缺失或不可读时回退到会话 id。显示标题优先使用 subagent 的持久创建 label,再回退到该标题。候选还会报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力;该方法在 `displayTitle` 存在时用它标记规范 mention。 ### 配置 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 7deb84f1ab..ea4f8b8860 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -69,9 +69,6 @@ "@deepseek-ai/dsh-spill": { "optional": true }, - "@deepseek-ai/dsh-subagent": { - "optional": true - }, "@deepseek-ai/dsh-session-projection-cache": { "optional": true } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 6655d45ce4..05b2706f2f 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -18,7 +18,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import type {} from '@deepseek-ai/dsh-session-title' -import type { SubagentIdentityProjection } from '@deepseek-ai/dsh-subagent/client' +import type {} from '@deepseek-ai/dsh-subagent' import type {} from '@deepseek-ai/dsh-system-prompt' import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { prepareReferenceOmission, REFERENCE_WARNING } from './spill.ts' @@ -253,13 +253,18 @@ export class SessionReferenceResolver extends TypertRemoteService { : record.header.isSeeded ? undefined : this.ctx.get('sessionProjectionCache')?.cachedSnapshot( - record.header, - SessionLogOffset(0), - ['title', 'subagent'], - ) + record.header, + SessionLogOffset(0), + ['title', 'subagent'], + ) const label = titleOf(snapshot) ?? record.header.id - const subagent = snapshot?.values.subagent as SubagentIdentityProjection | null | undefined - return { label, displayTitle: subagent?.label ?? label } + const subagent = snapshot?.values.subagent + return { + label, + displayTitle: subagent === undefined || subagent === null + ? label + : subagent.label ?? record.header.id, + } } /** @@ -280,7 +285,10 @@ export class SessionReferenceResolver extends TypertRemoteService { const candidates = await this.listCandidates(agent, query, this.config.candidateLimit, signal) return candidates.map(candidate => ({ ...candidate, - mention: formatSessionReferenceMention({ sessionId: candidate.sessionId, label: candidate.label }), + mention: formatSessionReferenceMention({ + sessionId: candidate.sessionId, + label: candidate.displayTitle ?? candidate.label, + }), })) } diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 800c1aa522..3fdfdf8de5 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -49,9 +49,9 @@ export interface SessionReferenceInput { export interface SessionReferenceCandidate { /** Opaque source session identity. */ sessionId: SessionId - /** Latest log-backed title, falling back to the opaque session id; used in the canonical mention. */ + /** Latest log-backed title, falling back to the opaque session id. */ label: string - /** Display text, preferring a subagent's durable creation label over {@link label}. */ + /** Display and canonical-mention text, preferring a subagent's durable creation label over {@link label}. */ displayTitle?: string /** Source session working directory, when recorded. */ cwd?: string diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 9d912738c1..bfd1756e65 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -7,7 +7,6 @@ import SessionStore, { Session, SessionId, SessionSeq } from '@deepseek-ai/dsh-s import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' import SessionTitleService from '@deepseek-ai/dsh-session-title' -import type { SubagentIdentityProjection } from '@deepseek-ai/dsh-subagent/client' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SessionReferenceResolver, { decodeSessionReferenceUri, @@ -59,7 +58,11 @@ function withProjectionCache( ctx: Context, rows: Record, ): void { ctx.provide('sessionProjectionCache', { @@ -737,6 +740,11 @@ describe('session reference discovery and preparation', () => { }]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'investigate')) .resolves.toHaveLength(1) + await expect(ctx.sessionReferenceResolver.remoteExportCandidates( + fakeAgent(target), 'researcher', new AbortController().signal, + )).resolves.toEqual([expect.objectContaining({ + mention: formatSessionReferenceMention({ sessionId: child.id, label: 'researcher' }), + })]) }) it('labels a session no projection answers for by its id, still without a log read', async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0d3427667..6fda355717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3893,9 +3893,6 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../context/session-reference - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../session/session-title '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md new file mode 100644 index 0000000000..931d297c08 --- /dev/null +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -0,0 +1,35 @@ +- button "System prompt": + - img + - img + - text: System prompt +- text: "Explain event sourcing in one sentence.Your parent agent id is \"session-{{uuid}}\". Before you finish, send your result to that agent with send_message({ agent_id: \"session-{{uuid}}\", message: \"\" }). The parent shares your workspace but does not automatically receive your transcript, tool output, or reasoning. Send earlier messages as well when a finding changes what the parent should do next; sending a message does not end your turn. {{clock}}" +- button "Copy": + - img +- button "Thought for a while": + - text: Thought for a while + - img +- paragraph: Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures. +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- button "Ran for {{duration}}": + - img + - text: Ran for {{duration}} +- text: {{clock}} +- textbox "Message or run a task, / commands, @ files or sessions" +- button "Add files or run commands": + - img +- 'button "Access mode, current: Custom"': Custom +- button "6% of context used" +- button "Send message" [disabled] +- button "1 turns 1 steps · {{throughput}} tok/s": + - img + - text: 1 turns 1 steps{{throughput}} tok/s +- button "7.9K tok · Cache hit 99%": + - img + - text: 7.9K tokCache hit 99% From 3e136c2f303d5b31f707bb42638eca26053fd9ba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 15:55:56 +0800 Subject: [PATCH 065/225] docs: refresh module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 27 ++++++++++++++------------- docs/module-graph.zh.md | 27 ++++++++++++++------------- 3 files changed, 30 insertions(+), 28 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index a71977cacf..ff54d13bd5 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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/module-graph.md -module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870 -module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03 +module-graph.md: a886619fa5cba78b2c243c525b8f2a173c1e8038 +module-graph.zh.md: 9c40a086df916cd638599176a95c649d6a2adc0d diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ec3673173..a886619fa5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1116,18 +1116,6 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_session_reference --> pkg_agent - pkg_session_reference --> pkg_compaction - pkg_session_reference --> pkg_llm - pkg_session_reference --> pkg_output_retention - pkg_session_reference --> pkg_session - pkg_session_reference --> pkg_session_projection - pkg_session_reference --> pkg_session_projection_cache - pkg_session_reference --> pkg_session_query - pkg_session_reference --> pkg_session_title - pkg_session_reference --> pkg_spill - pkg_session_reference --> pkg_system_prompt - pkg_session_reference --> pkg_typert_protocol pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session @@ -1211,6 +1199,19 @@ flowchart TD pkg_compaction_basic --> pkg_llm pkg_compaction_basic --> pkg_session pkg_compaction_basic --> pkg_token_meter + pkg_session_reference --> pkg_agent + pkg_session_reference --> pkg_compaction + pkg_session_reference --> pkg_llm + pkg_session_reference --> pkg_output_retention + pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache + pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title + pkg_session_reference --> pkg_spill + pkg_session_reference --> pkg_subagent + pkg_session_reference --> pkg_system_prompt + pkg_session_reference --> pkg_typert_protocol pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_invariants pkg_experimental_agent_team --> pkg_llm @@ -1568,7 +1569,6 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1579,6 +1579,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 1dd43514ac..9c40a086df 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1118,18 +1118,6 @@ flowchart TD pkg_compaction_tool_result_pruner --> pkg_llm pkg_compaction_tool_result_pruner --> pkg_session pkg_compaction_tool_result_pruner --> pkg_token_meter - pkg_session_reference --> pkg_agent - pkg_session_reference --> pkg_compaction - pkg_session_reference --> pkg_llm - pkg_session_reference --> pkg_output_retention - pkg_session_reference --> pkg_session - pkg_session_reference --> pkg_session_projection - pkg_session_reference --> pkg_session_projection_cache - pkg_session_reference --> pkg_session_query - pkg_session_reference --> pkg_session_title - pkg_session_reference --> pkg_spill - pkg_session_reference --> pkg_system_prompt - pkg_session_reference --> pkg_typert_protocol pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_session @@ -1213,6 +1201,19 @@ flowchart TD pkg_compaction_basic --> pkg_llm pkg_compaction_basic --> pkg_session pkg_compaction_basic --> pkg_token_meter + pkg_session_reference --> pkg_agent + pkg_session_reference --> pkg_compaction + pkg_session_reference --> pkg_llm + pkg_session_reference --> pkg_output_retention + pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache + pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title + pkg_session_reference --> pkg_spill + pkg_session_reference --> pkg_subagent + pkg_session_reference --> pkg_system_prompt + pkg_session_reference --> pkg_typert_protocol pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_invariants pkg_experimental_agent_team --> pkg_llm @@ -1570,7 +1571,6 @@ flowchart TD | [`plugin-manager`](../packages/boot/plugin-manager) | `boot` | [`brand`](../packages/util/brand), [`hmr`](../packages/boot/hmr), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1581,6 +1581,7 @@ flowchart TD | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | From 4f38c607972ff2c526706b87dc81b66006dca346 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:05:25 +0800 Subject: [PATCH 066/225] fix(client): satisfy sidebar chat gates --- docs/subsystems/session-reference.i18n.yaml | 4 ++-- packages/client/ui-chat/src/client/sidebar-chat/index.tsx | 8 ++++++-- packages/context/session-reference/README.i18n.yaml | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index 33df78e02a..73cb7b9c29 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.i18n.yaml @@ -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/session-reference.md -session-reference.md: 0d3cd061f4d787772312e97e365d9a24ca6dd492 -session-reference.zh.md: 9537096eec7453d7bdbcd6e39458fac99e61bf8c +session-reference.md: a2567a6cc3f6dde9a32db770a3a5f65c35ada219 +session-reference.zh.md: 43cd567aaa94cf4f54cd286c1dfc29d6a913b3e4 diff --git a/packages/client/ui-chat/src/client/sidebar-chat/index.tsx b/packages/client/ui-chat/src/client/sidebar-chat/index.tsx index 6f4478ebef..a7c17fb4c0 100644 --- a/packages/client/ui-chat/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-chat/src/client/sidebar-chat/index.tsx @@ -95,15 +95,19 @@ function waitForAbort(signal: AbortSignal): Promise { }) } +function isAbortRequested(signal: AbortSignal): boolean { + return signal.aborted +} + function chatResourceProvider(sessions: ISessions): ResourceProvider<'chat'> { return { protocol: 'chat', async *open(resourceAddress, { signal }) { const address = parseSidebarChatAddress(resourceAddress) if (address === undefined) throw new Error(`ui-chat: invalid chat resource address "${resourceAddress}"`) - if (signal.aborted) return + if (isAbortRequested(signal)) return await sessions.refreshSubagents(address.parentSessionId) - if (signal.aborted) return + if (isAbortRequested(signal)) return const reference = sessions.retain(address, { source: 'sidebarChat', signal }) try { yield { ok: true, value: { address, reference } } diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index 6d307eaed5..74c7f4efd6 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/README.i18n.yaml @@ -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/context/session-reference/README.md -README.md: 0aa444c6ab8318586e323fcbcf21165e5d5bc11d -README.zh.md: 5da631b846e979916c49b302f5debca8af528325 +README.md: e1afeef9b0e78826705ad2b3e426373b2bf0833d +README.zh.md: e55dab4f6403cb6aee947f80873ffe99919acc5f From c6c3ad935d37f17d1697ef33430373e293d9d825 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:27:05 +0800 Subject: [PATCH 067/225] test(client): cover sidebar chat edge paths --- .../tests/sidebar-chat.client.spec.tsx | 28 +++++++++++++-- .../tests/service.client.spec.ts | 16 +++++++++ .../tests/tab-domain.client.spec.ts | 9 +++-- .../tests/session-reference.spec.ts | 34 +++++++++++++++++++ .../sidebar-chat.expected.md | 2 +- 5 files changed, 83 insertions(+), 6 deletions(-) diff --git a/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx index fcd313cba0..94690d4bf1 100644 --- a/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx @@ -121,12 +121,30 @@ describe('Sidebar chat registration', () => { expect(await completion).toEqual({ done: true, value: undefined }) expect(release).toHaveBeenCalledOnce() + const abortedAfterYield = new AbortController() + const yielded = provider!.open(sidebarChatAddress(ADDRESS), { signal: abortedAfterYield.signal })[Symbol.asyncIterator]() + expect((await yielded.next()).done).toBe(false) + abortedAfterYield.abort() + expect(await yielded.next()).toEqual({ done: true, value: undefined }) + expect(release).toHaveBeenCalledTimes(2) + + let finishRefresh: (() => void) | undefined + refreshSubagents.mockImplementationOnce(() => new Promise((resolve) => { finishRefresh = resolve })) + const abortedDuringRefresh = new AbortController() + const pending = provider!.open( + sidebarChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal }, + )[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(finishRefresh).toBeTypeOf('function') }) + abortedDuringRefresh.abort() + finishRefresh?.() + expect(await pending).toEqual({ done: true, value: undefined }) + const alreadyAborted = new AbortController() alreadyAborted.abort() const stopped = provider!.open(sidebarChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]() expect(await stopped.next()).toEqual({ done: true, value: undefined }) - expect(retain).toHaveBeenCalledOnce() - expect(release).toHaveBeenCalledOnce() + expect(retain).toHaveBeenCalledTimes(2) + expect(release).toHaveBeenCalledTimes(2) const invalid = provider!.open('invalid', { signal: new AbortController().signal })[Symbol.asyncIterator]() await expect(invalid.next()).rejects.toThrow('invalid chat resource address') @@ -168,6 +186,12 @@ describe('Sidebar chat components', () => { summaryBlank: true, expected: { phase: 'hero', hero: true }, }, + { + name: 'prompt attempted', + session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'open', promptAttempted: true }, + summaryBlank: false, + expected: { phase: 'active', hero: false }, + }, { name: 'loading', session: { blank: true, awaitingFirstTurn: true, running: false, openState: 'loading' }, diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 82dcaabca5..889497fd2a 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -495,6 +495,22 @@ describe('SidebarRightController — a tab\'s own actions', () => { const A_TXT = 'dsh-resource://file/session/s-test/a.txt' const B_TXT = 'dsh-resource://file/session/s-test/b.txt' + it('opens into an adopted store before that store has created its session surface', () => { + const { controller, adopt, instance, titles } = harness() + const releaseOwn = adopt(SESSION, instance) + const other = createSidebarRightStore(() => ({ kind: 'guide', title: 'seed' })).create(OTHER) + const releaseBinding = controller.bind({ + sessionId: OTHER, + actions: other.actions, + surfaces: other.getSnapshot().bySession, + canSplitPane: () => true, + }) + controller.openResourceIn(SESSION, A_TXT) + expect(titles()).toContain('a.txt') + releaseBinding() + releaseOwn() + }) + it('land in the session the tab is in through its own adopted store, after another session\'s seat took over', () => { const { controller, adopt, instance, publish, layout, expand } = harness() const releaseOwn = adopt(SESSION, instance) diff --git a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts index 793bda8083..7c9a10c46e 100644 --- a/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/tab-domain.client.spec.ts @@ -195,16 +195,19 @@ describe('TabDomain — a tab\'s own actions', () => { expect(navigator.closeIn).toHaveBeenCalledWith(SESSION, tabId) }) - it('passes revealIfOpened through, so a tab may open a second copy beside itself', () => { + it('passes placement options through, so a tab may prefer a new pane and open a second copy', () => { const { domain, navigator, controller, current } = harness() const tabId = controller.openContent({ kind: 'text', contentId: 'dsh-resource://file/session/s-one/a.txt', title: 'a' }) domain.sync(SESSION, current()) const { tabActions } = domain.occurrence(SESSION, recordOf(current(), tabId)) - tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { revealIfOpened: false }) + tabActions.openResource('dsh-resource://file/session/s-one/a.txt', { + preferNewPane: true, + revealIfOpened: false, + }) expect(navigator.openResourceIn).toHaveBeenLastCalledWith( SESSION, 'dsh-resource://file/session/s-one/a.txt', - { paneId: current().rootId, revealIfOpened: false }, + { paneId: current().rootId, preferNewPane: true, revealIfOpened: false }, ) }) }) diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index bfd1756e65..d104e6b615 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -747,6 +747,28 @@ describe('session reference discovery and preparation', () => { })]) }) + it('falls back to the Session id when a subagent projection has no label', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const child = { id: SessionId('child'), createdAt: 10, cwd: '/same' } + withProjectionCache(ctx, { + child: { + title: 'Projected title', + subagent: { mode: 'continuable', seq: SessionSeq(2) }, + }, + }) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ + { header: child, live: false, persisted: true }, + ] as never) + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'child')) + .resolves.toEqual([expect.objectContaining({ + sessionId: child.id, + label: 'Projected title', + displayTitle: child.id, + })]) + }) + it('labels a session no projection answers for by its id, still without a log read', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) @@ -805,6 +827,18 @@ describe('session reference discovery and preparation', () => { createdAt: 20, mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }), }]) + + vi.spyOn(ctx.sessionReferenceResolver, 'listCandidates').mockResolvedValueOnce([{ + sessionId: SessionId('fallback'), + label: 'Fallback title', + sameWorkspace: false, + createdAt: 30, + }]) + await expect(ctx.sessionReferenceResolver.remoteExportCandidates( + fakeAgent(target), '', new AbortController().signal, + )).resolves.toEqual([expect.objectContaining({ + mention: formatSessionReferenceMention({ sessionId: SessionId('fallback'), label: 'Fallback title' }), + })]) }) it('prepares direct mentions at pre-step and keeps ordinary and plugin messages unchanged', async () => { diff --git a/snapshots/web/subagent-conversation/sidebar-chat.expected.md b/snapshots/web/subagent-conversation/sidebar-chat.expected.md index 931d297c08..5b0c23e774 100644 --- a/snapshots/web/subagent-conversation/sidebar-chat.expected.md +++ b/snapshots/web/subagent-conversation/sidebar-chat.expected.md @@ -25,7 +25,6 @@ - button "Add files or run commands": - img - 'button "Access mode, current: Custom"': Custom -- button "6% of context used" - button "Send message" [disabled] - button "1 turns 1 steps · {{throughput}} tok/s": - img @@ -33,3 +32,4 @@ - button "7.9K tok · Cache hit 99%": - img - text: 7.9K tokCache hit 99% +- button "6% of context used": 6% From 871f33297163bb0e8663020f88f81283ac5e7cb2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:37:18 +0800 Subject: [PATCH 068/225] fix(client): keep sidebar integration optional --- packages/client/ui-chat/src/client/apply.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index c6d53dd2b2..058e3dcafa 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -49,7 +49,7 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ 'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale', - 'settingsScope', 'remote', 'remote.session', 'resources', 'sidebarRight', 'sidebarRightTabs', + 'settingsScope', 'remote', 'remote.session', 'sidebarRight', ] /** @@ -79,7 +79,9 @@ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-chat: dictionaries') const t = ctx.locale.bind(NS) - registerSidebarChat(ctx, t) + ctx.inject(['resources', 'sidebarRightTabs'], (scope) => { + registerSidebarChat(scope, t) + }) const chatStore = createChatStore() const chatScrollPositions = new Map() const transcriptView = new TranscriptViewPolicy( From 34a43129a619703e217b301561832e4b11458936 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:45:06 +0800 Subject: [PATCH 069/225] fix(session-reference): preserve title fallback --- packages/context/session-reference/src/index.ts | 2 +- .../context/session-reference/tests/session-reference.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 05b2706f2f..848e4ddc15 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -263,7 +263,7 @@ export class SessionReferenceResolver extends TypertRemoteService { label, displayTitle: subagent === undefined || subagent === null ? label - : subagent.label ?? record.header.id, + : subagent.label ?? label, } } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index d104e6b615..98da2c861b 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -747,7 +747,7 @@ describe('session reference discovery and preparation', () => { })]) }) - it('falls back to the Session id when a subagent projection has no label', async () => { + it('falls back to the projected title when a subagent projection has no label', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) const child = { id: SessionId('child'), createdAt: 10, cwd: '/same' } @@ -765,7 +765,7 @@ describe('session reference discovery and preparation', () => { .resolves.toEqual([expect.objectContaining({ sessionId: child.id, label: 'Projected title', - displayTitle: child.id, + displayTitle: 'Projected title', })]) }) From 49b9ccbfcdafac4e0bf9c5e83275f88f782271a5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:22:29 +0800 Subject: [PATCH 070/225] fix(client): preserve round sidebar action --- .../ui-subagent/src/client/SubagentHeaderLineage.module.css | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css index 5dcad20181..713ad62166 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.module.css @@ -261,6 +261,7 @@ padding: 6px; border: 0; border-radius: 50%; + corner-shape: round; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; From d05b42980b8508d4285d106fdcc9c36387cdb53c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:45:50 +0800 Subject: [PATCH 071/225] refactor(client): move sidebar chat to subagent --- docs/subsystems/client-resources.i18n.yaml | 4 +- docs/subsystems/client-resources.md | 4 +- docs/subsystems/client-resources.zh.md | 4 +- docs/subsystems/sidebar-right.i18n.yaml | 4 +- docs/subsystems/sidebar-right.md | 2 +- docs/subsystems/sidebar-right.zh.md | 2 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 - packages/client/ui-chat/README.zh.md | 2 - packages/client/ui-chat/package.json | 2 - packages/client/ui-chat/src/client/apply.ts | 4 -- packages/client/ui-chat/src/client/index.ts | 3 -- packages/client/ui-chat/tsconfig.json | 3 -- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 + packages/client/ui-subagent/README.zh.md | 2 + packages/client/ui-subagent/package.json | 2 + .../client/ui-subagent/src/client/index.ts | 16 +++--- .../client/ui-subagent/src/client/locales.ts | 2 + .../sidebar-chat/SidebarChat.module.css | 0 .../src/client/sidebar-chat/index.tsx | 52 +++++++++---------- .../tests/browser-plugin.client.spec.ts | 4 +- .../tests/sidebar-chat.client.spec.tsx | 51 +++++++++--------- packages/client/ui-subagent/tsconfig.json | 6 +++ .../src/client/slot-catalog.ts | 8 +-- pnpm-lock.yaml | 6 +-- 26 files changed, 97 insertions(+), 98 deletions(-) rename packages/client/{ui-chat => ui-subagent}/src/client/sidebar-chat/SidebarChat.module.css (100%) rename packages/client/{ui-chat => ui-subagent}/src/client/sidebar-chat/index.tsx (81%) rename packages/client/{ui-chat => ui-subagent}/tests/sidebar-chat.client.spec.tsx (80%) diff --git a/docs/subsystems/client-resources.i18n.yaml b/docs/subsystems/client-resources.i18n.yaml index 06d415d06b..b5a597f360 100644 --- a/docs/subsystems/client-resources.i18n.yaml +++ b/docs/subsystems/client-resources.i18n.yaml @@ -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/client-resources.md -client-resources.md: 77b54f26beecf3a9898657b61d4ea41d5e91e03b -client-resources.zh.md: ffb1c7422d67a4ca97d01a115d8fa52fc0604490 +client-resources.md: 09acaf72eb5ee6f6003d9b275afa8a73f0822771 +client-resources.zh.md: 4a0e30d2d885d143b1bc59ca58f59fd8d1f3e9b7 diff --git a/docs/subsystems/client-resources.md b/docs/subsystems/client-resources.md index 77b54f26be..09acaf72eb 100644 --- a/docs/subsystems/client-resources.md +++ b/docs/subsystems/client-resources.md @@ -15,7 +15,7 @@ A resource address is a `dsh-resource:///…` URL. The host names the prot | `dsh-resource://file/session/s1/notes/a.md` | `file` | the metadata of `notes/a.md` under session `s1`'s workspace root, when the `file` provider is registered | | `dsh-resource://file/absolute/home/me/notes.md` | `file` | parseable but fails with `workspace-file/unknown-workspace`: no authorizing Session, and neither current nor Tab Session is borrowed | | `DSH-RESOURCE://File/session/s1/a` | `file` | a distinct record: addresses compare as strings, and `openResource` accepts only the canonical lower-case spelling that `fileAddressFor` emits | -| `dsh-resource://chat/session/c1?parent=p1&mode=continuable` | `chat` | an addressed subagent Conversation whose value owns a `SessionReference` until the resource closes | +| `dsh-resource://subagentchat/session/c1?parent=p1&mode=continuable` | `subagentchat` | an addressed subagent Conversation whose value owns a `SessionReference` until the resource closes | | `sidebar://guide` | — | `none`: a navigation address | | `/home/me/notes.md` | — | `none`: not a URL | @@ -85,7 +85,7 @@ A consumer presents `failed` itself: the model keeps the last value beside the f A resource is alive while it has a holder: a subscribed `useResource`, or a pin. `ctx.resources.pin(address, signal)` keeps a resource open without subscribing until `signal` aborts, and an already-aborted signal pins nothing; the right Sidebar pins every open tab record's address for the record's life, so switching tabs unmounts a body without closing its stream. The first holder opens the provider's stream; the last release aborts it, discards the value, and returns the snapshot to `loading` (provider present) or `none` (absent). A frame the provider yields after that release is dropped, and the iterator is returned. `ctx.resources.source(address)` is the bare observable behind the hook, reference-stable per address, for callers outside React; reading its snapshot does not hold the resource ([lifecycle](../../packages/client/resources/README.md#lifecycle)). -Most streams carry metadata rather than content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). The `chat` provider retains the addressed Session and yields its `SessionReference`, then releases that reference when the resource signal aborts. +Most streams carry metadata rather than content. The `file` provider's value is `WorkspaceFileStat { absolutePath, version, bytes? }`: the first frame comes from Host `stat`, and later observations update the version. A consumer reads content through the Workspace Files Remote namespace; Preview owns refresh independently per tab ([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.md)). The `subagentchat` provider retains the addressed Session and yields its `SessionReference`, then releases that reference when the resource signal aborts. ## Limits diff --git a/docs/subsystems/client-resources.zh.md b/docs/subsystems/client-resources.zh.md index ffb1c7422d..4a0e30d2d8 100644 --- a/docs/subsystems/client-resources.zh.md +++ b/docs/subsystems/client-resources.zh.md @@ -15,7 +15,7 @@ | `dsh-resource://file/session/s1/notes/a.md` | `file` | 会话 `s1` 工作区根下 `notes/a.md` 的元数据(`file` 提供方已注册时) | | `dsh-resource://file/absolute/home/me/notes.md` | `file` | 可解析,但没有授权 Session,以 `workspace-file/unknown-workspace` 失败;不借用当前或 Tab Session | | `DSH-RESOURCE://File/session/s1/a` | `file` | 另一份记录:地址按字符串比较,`openResource` 只接受 `fileAddressFor` 生成的规范小写拼写 | -| `dsh-resource://chat/session/c1?parent=p1&mode=continuable` | `chat` | 已寻址的 subagent Conversation;其值持有一个 `SessionReference`,直到资源关闭 | +| `dsh-resource://subagentchat/session/c1?parent=p1&mode=continuable` | `subagentchat` | 已寻址的 subagent Conversation;其值持有一个 `SessionReference`,直到资源关闭 | | `sidebar://guide` | — | `none`:导航地址 | | `/home/me/notes.md` | — | `none`:不是 URL | @@ -85,7 +85,7 @@ export function FileHeader({ useTabInfo, useResource, t }: Props) { 资源有持有者就存活:一个订阅中的 `useResource`,或一次钉住。`ctx.resources.pin(address, signal)` 在不订阅的情况下让资源保持打开直到 `signal` 中止,已中止的信号什么也不钉;右侧 Sidebar 在每条打开的 tab 记录存续期内钉住其地址,因此切 tab 卸载正文不关流。第一个持有者打开提供方的流;最后一个释放时中止它、丢弃值,并把快照回到 `loading`(有提供方)或 `none`(没有)。提供方在这次释放之后产出的帧被丢弃,迭代器被归还。`ctx.resources.source(address)` 是 hook 背后的裸 observable,按地址引用稳定,供 React 之外的调用方使用;只读它的快照不算持有([生命周期](../../packages/client/resources/README.zh.md#lifecycle))。 -多数流只推元数据而不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。`chat` 提供方保留已寻址 Session 并产出其 `SessionReference`,在资源 signal 中止时释放该 reference。 +多数流只推元数据而不推内容。`file` 提供方的值是 `WorkspaceFileStat { absolutePath, version, bytes? }`:首帧来自 Host 的 `stat`,后续观察更新版本。消费方自己经 Workspace Files Remote 命名空间读取内容;Preview 按 tab 独立刷新([`dsh-api-workspace-files`](../../packages/api/workspace-files/README.zh.md))。`subagentchat` 提供方保留已寻址 Session 并产出其 `SessionReference`,在资源 signal 中止时释放该 reference。 ## 限制 diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 222e948967..8b23e7fe7c 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -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/sidebar-right.md -sidebar-right.md: 327bce99574d66c51bae4b93c30dff3fe8b0f17c -sidebar-right.zh.md: beebbdfe8ac82abe2354f330b11ba0b9407681d3 +sidebar-right.md: a6478e10f19bc11f2a8bfa89d6213a85ffd76ffc +sidebar-right.zh.md: af23b93945ce714cf4f0485752d8e07f155055c2 diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 327bce9957..a6478e10f1 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -137,7 +137,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)). - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)). - **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)). -- **`chat`** — `builtin`, `dsh-resource://chat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-chat/README.md)). +- **`subagentchat`** — `builtin`, `dsh-resource://subagentchat/session/?parent=&mode=`. An explicitly addressed subagent Conversation retained by the resource provider and rendered through the shared Conversation Factory ([README](../../packages/client/ui-subagent/README.md)). ## Not built diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index beebbdfe8a..af23b93945 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -137,7 +137,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。 - **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。 -- **`chat`**——`builtin`,`dsh-resource://chat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-chat/README.zh.md))。 +- **`subagentchat`**——`builtin`,`dsh-resource://subagentchat/session/?parent=&mode=`。资源提供方保留一个显式寻址的 subagent Conversation,并通过共享 Conversation Factory 渲染([README](../../packages/client/ui-subagent/README.zh.md))。 ## 不做 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 32765f7b6e..1d6df37e4d 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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-chat/README.md -README.md: a4e67d1b6d7b377ed2353b9f3bc1e78c16798b6a -README.zh.md: 499c36ce49de85209a74fb35e3dc9d9b5d12467b +README.md: e7d3efe37e089f4b7bcbb7d954e6791899cebfc0 +README.zh.md: 8b49332ad39d373fcc2aae66af2e7c284bfee9bb diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index a4e67d1b6d..e7d3efe37e 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,8 +33,6 @@ Chat supplies file and HTTP(S) navigation through one `MarkdownDelegateProvider` HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references. -Chat also registers the `dsh-resource://chat/session/?parent=&mode=` resource and builtin Sidebar tab type for an explicitly addressed subagent Conversation. The resource retains one `SessionReference`, refreshes the direct-parent catalog before opening, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. - ## System prompt row diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 499c36ce49..8b49332ad3 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -33,8 +33,6 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 也可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。 -Chat 还为显式寻址的 subagent Conversation 注册 `dsh-resource://chat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源在打开前刷新直接 parent 目录并保留一个 `SessionReference`,在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 - ## 系统提示词行 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 1cfc927f72..d716f7b89b 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -31,7 +31,6 @@ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-resources", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-input-trigger", "@deepseek-ai/dsh-client-ui-layout", @@ -61,7 +60,6 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", - "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-approval": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 058e3dcafa..c9822804ab 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -36,7 +36,6 @@ import { createChatStore } from './stores.ts' import { TranscriptViewPolicy } from './transcript-view.ts' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts' import { useTurnDataValue } from './chat/use-turn-data.ts' -import { registerSidebarChat } from './sidebar-chat/index.tsx' const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { hooks: { @@ -79,9 +78,6 @@ export function apply(ctx: Context): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-chat: dictionaries') const t = ctx.locale.bind(NS) - ctx.inject(['resources', 'sidebarRightTabs'], (scope) => { - registerSidebarChat(scope, t) - }) const chatStore = createChatStore() const chatScrollPositions = new Map() const transcriptView = new TranscriptViewPolicy( diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index dea4e2668a..9c2fe3a5d0 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -1,8 +1,5 @@ /** Browser Chat target plugin. */ export { apply, inject } from './apply.ts' -export type { - ConversationSlotPanelProps, SidebarChatResource, SidebarChatTabProps, -} from './sidebar-chat/index.tsx' export type {} from './conversation-nodes/assistant.ts' export type {} from './conversation-nodes/command.ts' export type {} from './conversation-nodes/compaction.ts' diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index b52426335d..a004571d35 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -68,9 +68,6 @@ { "path": "../locale" }, - { - "path": "../resources" - }, { "path": "../store" }, diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index aa6f36e574..126d405051 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -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-subagent/README.md -README.md: 69ea7da7dea7ca9ee2fa5bc07210441bb984509c -README.zh.md: 131f26219acbcc6d4a86dfda0af3b36408546295 +README.md: d0dadc79f552c28c5fea20a23a91c25a24e8ed61 +README.zh.md: 295206ace11baf67112db17b5bda10a52b80d9aa diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 69ea7da7de..d0dadc79f5 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -27,6 +27,8 @@ Use this package to browse every subagent conversation beneath a parent session, The session header keeps the current session title as the lineage breadcrumb and, when the session has subagent descendants, appends a `/` count trigger before the header's action row; the trigger opens the descendant catalog, counts the complete subagent-only lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits. +This package registers the `dsh-resource://subagentchat/session/?parent=&mode=` resource and builtin Sidebar tab type. The resource refreshes the direct-parent catalog before it retains the child `SessionReference`, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls. + ### Browsing the tree Rows display mode plus `running`/`inactive` activity and an optional log-backed title; the trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 131f26219a..295206ace1 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 会话页头保留当前会话 title 作为谱系面包屑,并在会话存在 subagent 后代时,于页头操作行之前追加 `/` 数量触发器;触发器打开后代目录,统计仅含 subagent 的完整谱系、在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。 +本包注册 `dsh-resource://subagentchat/session/?parent=&mode=` 资源与 builtin Sidebar tab 类型。资源先刷新直接 parent 目录,再保留 child 的 `SessionReference`,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。 + ### 浏览目录 行显示 mode、`running`/`inactive` 活动状态与由日志支撑的可选 title;尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。 diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 89862a1a19..bf54443392 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -30,6 +30,7 @@ "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-resources", "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-primitives", @@ -50,6 +51,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-resources": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-subagent/src/client/index.ts b/packages/client/ui-subagent/src/client/index.ts index a868d28e03..9269195954 100644 --- a/packages/client/ui-subagent/src/client/index.ts +++ b/packages/client/ui-subagent/src/client/index.ts @@ -7,7 +7,9 @@ import { SubagentHeaderLineage, type SubagentCatalogInjected } from './SubagentH import { SubagentReadOnlyComposer, type SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' +import { registerSidebarChat, subagentChatAddress } from './sidebar-chat/index.tsx' import type {} from '@deepseek-ai/dsh-client-locale/client' +import type {} from '@deepseek-ai/dsh-client-ui-chat/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-client-ui-sidebar-right/client' @@ -28,14 +30,9 @@ export type { SubagentReadOnlyComposerProps, SubagentReadOnlyMatch, } from './SubagentReadOnlyComposer.tsx' -/** Required services for conversation slots, session navigation, and Sidebar resources. */ +/** Required services for subagent presentation and navigation. */ export const inject = ['sessions', 'uiWorkspace', 'slots', 'locale', 'sidebarRight'] -function sidebarChatAddress(address: SubagentAddress): string { - const query = new URLSearchParams({ parent: address.parentSessionId, mode: address.mode }) - return `dsh-resource://chat/session/${encodeURIComponent(address.childSessionId)}?${query}` -} - /** Claim the composer for one-shot history or an unavailable continuation owner. */ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatch | null { const subagent = owner.session?.subagent @@ -57,14 +54,17 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc */ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-subagent: dictionaries') + ctx.inject(['resources', 'sidebarRightTabs'], (scope) => { + registerSidebarChat(scope, ctx.locale.bind(NS)) + }) const sessions = ctx.sessions const catalogActions = (_parentSessionId: SessionId): SubagentCatalogInjected => ({ openChild(address: SubagentAddress) { ctx.uiWorkspace.openSession(address) }, openChildAside(address: SubagentAddress) { - ctx.sidebarRight.openResource(sidebarChatAddress(address), { - kind: 'chat', + ctx.sidebarRight.openResource(subagentChatAddress(address), { + kind: 'subagentchat', preferNewPane: true, }) }, diff --git a/packages/client/ui-subagent/src/client/locales.ts b/packages/client/ui-subagent/src/client/locales.ts index de248949d6..fe81ef6c01 100644 --- a/packages/client/ui-subagent/src/client/locales.ts +++ b/packages/client/ui-subagent/src/client/locales.ts @@ -39,6 +39,7 @@ export const zh = { 'switcher.aria': '切换子代理:{title}', 'tree.aria': '子代理会话', 'open.sidebar': '在侧边栏打开 {label}', + 'sidebar.chat': '聊天', 'readonly.oneShot.title': '一次性子代理记录', 'readonly.title': '此子代理暂时只读', 'readonly.oneShot.body': '一次性任务不支持后续消息,可在这里查看完整执行记录。', @@ -81,6 +82,7 @@ export const en: Record = { 'switcher.aria': 'Switch subagent: {title}', 'tree.aria': 'Subagent sessions', 'open.sidebar': 'Open {label} in sidebar', + 'sidebar.chat': 'Chat', 'readonly.oneShot.title': 'One-shot subagent record', 'readonly.title': 'This subagent is read-only for now', 'readonly.oneShot.body': 'One-shot tasks do not accept follow-ups; review the full execution record here.', diff --git a/packages/client/ui-chat/src/client/sidebar-chat/SidebarChat.module.css b/packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css similarity index 100% rename from packages/client/ui-chat/src/client/sidebar-chat/SidebarChat.module.css rename to packages/client/ui-subagent/src/client/sidebar-chat/SidebarChat.module.css diff --git a/packages/client/ui-chat/src/client/sidebar-chat/index.tsx b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx similarity index 81% rename from packages/client/ui-chat/src/client/sidebar-chat/index.tsx rename to packages/client/ui-subagent/src/client/sidebar-chat/index.tsx index a7c17fb4c0..375dcadfd3 100644 --- a/packages/client/ui-chat/src/client/sidebar-chat/index.tsx +++ b/packages/client/ui-subagent/src/client/sidebar-chat/index.tsx @@ -11,17 +11,17 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' -import type { NS } from '../locale.ts' +import type { NS } from '../locales.ts' import css from './SidebarChat.module.css' /** Stable implementation identity for the Sidebar tab body. */ -export const SIDEBAR_CHAT_ID = '@deepseek-ai/dsh-client-ui-chat' +export const SUBAGENT_CHAT_ID = '@deepseek-ai/dsh-client-ui-subagent' /** Resource-address prefix for an embedded Session chat. */ -export const SIDEBAR_CHAT_ADDRESS = 'dsh-resource://chat/session/' +export const SUBAGENT_CHAT_ADDRESS = 'dsh-resource://subagentchat/session/' /** Value retained by one live chat resource occurrence. */ -export interface SidebarChatResource { +export interface SubagentChatResource { readonly address: SubagentAddress readonly reference: SessionReference } @@ -34,7 +34,7 @@ declare module '@deepseek-ai/dsh-api-session-controller/client' { declare module '@deepseek-ai/dsh-client-ui-slots' { interface ResourceProtocolMap { - chat: SidebarChatResource + subagentchat: SubagentChatResource } interface SlotMap { @@ -48,12 +48,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * @param address - durable direct-parent subagent address. * @returns canonical Sidebar resource address. */ -export function sidebarChatAddress(address: SubagentAddress): string { +export function subagentChatAddress(address: SubagentAddress): string { const query = new URLSearchParams({ parent: address.parentSessionId, mode: address.mode, }) - return `${SIDEBAR_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query}` + return `${SUBAGENT_CHAT_ADDRESS}${encodeURIComponent(address.childSessionId)}?${query}` } /** @@ -61,14 +61,14 @@ export function sidebarChatAddress(address: SubagentAddress): string { * @param value - possible chat resource address. * @returns the encoded direct-parent address, or undefined for another or malformed resource. */ -export function parseSidebarChatAddress(value: string): SubagentAddress | undefined { +export function parseSubagentChatAddress(value: string): SubagentAddress | undefined { let url: URL try { url = new URL(value) } catch (_invalidUrl) { return undefined } - if (url.protocol !== 'dsh-resource:' || url.hostname.toLowerCase() !== 'chat') return undefined + if (url.protocol !== 'dsh-resource:' || url.hostname.toLowerCase() !== 'subagentchat') return undefined const parts = url.pathname.split('/').filter(Boolean) if (parts.length !== 2 || parts[0] !== 'session') return undefined const parentSessionId = url.searchParams.get('parent') @@ -99,12 +99,12 @@ function isAbortRequested(signal: AbortSignal): boolean { return signal.aborted } -function chatResourceProvider(sessions: ISessions): ResourceProvider<'chat'> { +function subagentChatResourceProvider(sessions: ISessions): ResourceProvider<'subagentchat'> { return { - protocol: 'chat', + protocol: 'subagentchat', async *open(resourceAddress, { signal }) { - const address = parseSidebarChatAddress(resourceAddress) - if (address === undefined) throw new Error(`ui-chat: invalid chat resource address "${resourceAddress}"`) + const address = parseSubagentChatAddress(resourceAddress) + if (address === undefined) throw new Error(`ui-subagent: invalid chat resource address "${resourceAddress}"`) if (isAbortRequested(signal)) return await sessions.refreshSubagents(address.parentSessionId) if (isAbortRequested(signal)) return @@ -156,7 +156,7 @@ export type SidebarChatTabProps = /** Bind a chat resource's child reference around its Conversation slot. */ export function SidebarChatTab({ useResource, useTabInfo, SessionProvider, renderSlot }: SidebarChatTabProps) { const { tab } = useTabInfo() - const resource = useResource<'chat'>(tab.contentId) + const resource = useResource<'subagentchat'>(tab.contentId) return (
    {resource.value === undefined @@ -177,28 +177,28 @@ export function SidebarChatTab({ useResource, useTabInfo, SessionProvider, rende */ export function registerSidebarChat(ctx: Context, t: TranslateNS): void { ctx.effect( - () => ctx.resources.register(chatResourceProvider(ctx.sessions)), - 'ui-chat: Sidebar chat resources', + () => ctx.resources.register(subagentChatResourceProvider(ctx.sessions)), + 'ui-subagent: Sidebar chat resources', ) ctx.effect(() => ctx.sidebarRightTabs.register({ - id: SIDEBAR_CHAT_ID, - kind: 'chat', - patterns: [`${SIDEBAR_CHAT_ADDRESS}**`], + id: SUBAGENT_CHAT_ID, + kind: 'subagentchat', + patterns: [`${SUBAGENT_CHAT_ADDRESS}**`], priority: 'builtin', - canOpen: address => parseSidebarChatAddress(address) !== undefined, + canOpen: address => parseSubagentChatAddress(address) !== undefined, title: (address) => { - const child = parseSidebarChatAddress(address)?.childSessionId + const child = parseSubagentChatAddress(address)?.childSessionId return child === undefined - ? t('view.chat') + ? t('sidebar.chat') : ctx.sessions.list.getSnapshot().byId[child]?.projectionValues?.subagent?.label ?? child }, - } satisfies SidebarRightTabDefinition), 'ui-chat: Sidebar chat type') + } satisfies SidebarRightTabDefinition), 'ui-subagent: Sidebar chat type') ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({ name: 'sidebar.right.pane.tab', - key: SIDEBAR_CHAT_ID, + key: SUBAGENT_CHAT_ID, children: { 'sidebar.chat.conversation': { kind: 'single', scope: 'session' } }, - }, SidebarChatTab)), 'ui-chat: Sidebar chat body') + }, SidebarChatTab)), 'ui-subagent: Sidebar chat body') ctx.effect(() => ctx.slots.inject('sidebar.chat.conversation', () => ctx.slots.register({ name: 'sidebar.chat.conversation', - }, ConversationSlotPanel)), 'ui-chat: Sidebar Conversation') + }, ConversationSlotPanel)), 'ui-subagent: Sidebar Conversation') } diff --git a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts index 44452a448f..e2fa92ac49 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.client.spec.ts @@ -119,8 +119,8 @@ describe('apply', () => { { method: 'openResource', args: [ - 'dsh-resource://chat/session/c1?parent=parent&mode=continuable', - { kind: 'chat', preferNewPane: true }, + 'dsh-resource://subagentchat/session/c1?parent=parent&mode=continuable', + { kind: 'subagentchat', preferNewPane: true }, ], }, { method: 'refreshSubagents', args: [sid('parent')] }, diff --git a/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx similarity index 80% rename from packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx rename to packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx index 94690d4bf1..a75f111f5f 100644 --- a/packages/client/ui-chat/tests/sidebar-chat.client.spec.tsx +++ b/packages/client/ui-subagent/tests/sidebar-chat.client.spec.tsx @@ -13,8 +13,8 @@ import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sideb import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { - ConversationSlotPanel, FixedChatConversationView, parseSidebarChatAddress, - registerSidebarChat, sidebarChatAddress, SIDEBAR_CHAT_ID, type SidebarChatTabProps, + ConversationSlotPanel, FixedChatConversationView, parseSubagentChatAddress, + registerSidebarChat, subagentChatAddress, SUBAGENT_CHAT_ID, type SidebarChatTabProps, } from '../src/client/sidebar-chat/index.tsx' import { SidebarChatTab } from '../src/client/sidebar-chat/index.tsx' @@ -33,10 +33,10 @@ afterEach(() => { describe('Sidebar chat address', () => { it('round-trips the child identity and direct-parent routing metadata', () => { - const resource = sidebarChatAddress(ADDRESS) - expect(resource).toBe('dsh-resource://chat/session/child%231?parent=parent%2Fa&mode=continuable') - expect(parseSidebarChatAddress(resource)).toEqual(ADDRESS) - expect(parseSidebarChatAddress(sidebarChatAddress({ ...ADDRESS, mode: 'one-shot' }))) + const resource = subagentChatAddress(ADDRESS) + expect(resource).toBe('dsh-resource://subagentchat/session/child%231?parent=parent%2Fa&mode=continuable') + expect(parseSubagentChatAddress(resource)).toEqual(ADDRESS) + expect(parseSubagentChatAddress(subagentChatAddress({ ...ADDRESS, mode: 'one-shot' }))) .toEqual({ ...ADDRESS, mode: 'one-shot' }) }) @@ -44,13 +44,13 @@ describe('Sidebar chat address', () => { 'not an address', 'https://chat/session/child?parent=parent&mode=continuable', 'dsh-resource://other/session/child?parent=parent&mode=continuable', - 'dsh-resource://chat/other/child?parent=parent&mode=continuable', - 'dsh-resource://chat/session/child?mode=continuable', - 'dsh-resource://chat/session/child?parent=&mode=continuable', - 'dsh-resource://chat/session/child?parent=parent&mode=unknown', - 'dsh-resource://chat/session/%?parent=parent&mode=continuable', + 'dsh-resource://subagentchat/other/child?parent=parent&mode=continuable', + 'dsh-resource://subagentchat/session/child?mode=continuable', + 'dsh-resource://subagentchat/session/child?parent=&mode=continuable', + 'dsh-resource://subagentchat/session/child?parent=parent&mode=unknown', + 'dsh-resource://subagentchat/session/%?parent=parent&mode=continuable', ])('rejects %s', (address) => { - expect(parseSidebarChatAddress(address)).toBeUndefined() + expect(parseSubagentChatAddress(address)).toBeUndefined() }) }) @@ -80,12 +80,12 @@ describe('Sidebar chat registration', () => { } as unknown as SessionListState), subscribe: () => () => {}, } - let provider: ResourceProvider<'chat'> | undefined + let provider: ResourceProvider<'subagentchat'> | undefined let definition: SidebarRightTabDefinition | undefined const registrations: { options: Record; component: unknown }[] = [] const ctx = { sessions: { retain, refreshSubagents, list } as unknown as ISessions, - resources: { register: (value: ResourceProvider<'chat'>) => { provider = value; return () => {} } }, + resources: { register: (value: ResourceProvider<'subagentchat'>) => { provider = value; return () => {} } }, sidebarRightTabs: { register: (value: SidebarRightTabDefinition) => { definition = value; return () => {} } }, slots: { inject: (_name: string, install: () => () => void) => install(), @@ -97,13 +97,14 @@ describe('Sidebar chat registration', () => { effect: (install: () => unknown) => { install(); return () => {} }, } as unknown as Context - registerSidebarChat(ctx, (key: string) => key === 'view.chat' ? 'Chat' : key) + registerSidebarChat(ctx, (key: string) => key === 'sidebar.chat' ? 'Chat' : key) - expect(definition?.id).toBe(SIDEBAR_CHAT_ID) - expect(definition?.canOpen?.(sidebarChatAddress(ADDRESS))).toBe(true) - expect(definition?.canOpen?.('dsh-resource://chat/invalid')).toBe(false) - expect(definition?.title(sidebarChatAddress(ADDRESS))).toBe('Worker') - expect(definition?.title(sidebarChatAddress({ ...ADDRESS, childSessionId: 'unknown' as SessionId }))).toBe('unknown') + expect(definition?.id).toBe(SUBAGENT_CHAT_ID) + expect(definition?.kind).toBe('subagentchat') + expect(definition?.canOpen?.(subagentChatAddress(ADDRESS))).toBe(true) + expect(definition?.canOpen?.('dsh-resource://subagentchat/invalid')).toBe(false) + expect(definition?.title(subagentChatAddress(ADDRESS))).toBe('Worker') + expect(definition?.title(subagentChatAddress({ ...ADDRESS, childSessionId: 'unknown' as SessionId }))).toBe('unknown') expect(definition?.title('invalid')).toBe('Chat') expect(registrations.map(entry => entry.options.name)).toEqual([ 'sidebar.right.pane.tab', @@ -111,7 +112,7 @@ describe('Sidebar chat registration', () => { ]) const controller = new AbortController() - const stream = provider!.open(sidebarChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]() + const stream = provider!.open(subagentChatAddress(ADDRESS), { signal: controller.signal })[Symbol.asyncIterator]() expect(await stream.next()).toEqual({ done: false, value: { ok: true, value: { address: ADDRESS, reference } } }) expect(refreshSubagents).toHaveBeenCalledWith(PARENT) expect(retain).toHaveBeenCalledWith(ADDRESS, { source: 'sidebarChat', signal: controller.signal }) @@ -122,7 +123,7 @@ describe('Sidebar chat registration', () => { expect(release).toHaveBeenCalledOnce() const abortedAfterYield = new AbortController() - const yielded = provider!.open(sidebarChatAddress(ADDRESS), { signal: abortedAfterYield.signal })[Symbol.asyncIterator]() + const yielded = provider!.open(subagentChatAddress(ADDRESS), { signal: abortedAfterYield.signal })[Symbol.asyncIterator]() expect((await yielded.next()).done).toBe(false) abortedAfterYield.abort() expect(await yielded.next()).toEqual({ done: true, value: undefined }) @@ -132,7 +133,7 @@ describe('Sidebar chat registration', () => { refreshSubagents.mockImplementationOnce(() => new Promise((resolve) => { finishRefresh = resolve })) const abortedDuringRefresh = new AbortController() const pending = provider!.open( - sidebarChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal }, + subagentChatAddress(ADDRESS), { signal: abortedDuringRefresh.signal }, )[Symbol.asyncIterator]().next() await vi.waitFor(() => { expect(finishRefresh).toBeTypeOf('function') }) abortedDuringRefresh.abort() @@ -141,7 +142,7 @@ describe('Sidebar chat registration', () => { const alreadyAborted = new AbortController() alreadyAborted.abort() - const stopped = provider!.open(sidebarChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]() + const stopped = provider!.open(subagentChatAddress(ADDRESS), { signal: alreadyAborted.signal })[Symbol.asyncIterator]() expect(await stopped.next()).toEqual({ done: true, value: undefined }) expect(retain).toHaveBeenCalledTimes(2) expect(release).toHaveBeenCalledTimes(2) @@ -158,7 +159,7 @@ describe('Sidebar chat components', () => { const SessionProvider = vi.fn(({ children }: { children: ReactNode }) => <>{children}) const renderSlot = vi.fn(() => child conversation) const props = { - useTabInfo: () => ({ tab: { contentId: sidebarChatAddress(ADDRESS) } }), + useTabInfo: () => ({ tab: { contentId: subagentChatAddress(ADDRESS) } }), useResource: () => snapshot, SessionProvider, renderSlot, diff --git a/packages/client/ui-subagent/tsconfig.json b/packages/client/ui-subagent/tsconfig.json index c82fbadb34..f5818c86fb 100644 --- a/packages/client/ui-subagent/tsconfig.json +++ b/packages/client/ui-subagent/tsconfig.json @@ -20,6 +20,12 @@ { "path": "../locale" }, + { + "path": "../resources" + }, + { + "path": "../ui-chat" + }, { "path": "../ui-conversation" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 8d39bbebf7..0caa5b38bf 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2447,13 +2447,13 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ keyDomain: '', hookContext: '', slotInject: '', - declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-chat), so it exists while that entry is mounted', + declaredBy: 'an entry in \'sidebar.right.pane.tab\' (client-ui-subagent), so it exists while that entry is mounted', occupants: [ - 'client-ui-chat ConversationSlotPanel', + 'client-ui-subagent ConversationSlotPanel', ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.chat.conversation\', () => ctx.slots.register(\n { name: \'sidebar.chat.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/sidebar-chat/index.tsx:42', + source: 'packages/client/ui-subagent/src/client/sidebar-chat/index.tsx:42', }, { key: 'sidebar.footer.action', @@ -2593,7 +2593,6 @@ 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-chat SidebarChatTab', 'client-ui-deliverables ReviewTab', 'client-ui-plan PlanPreview', 'client-ui-sidebar-browser BrowserBody', @@ -2601,6 +2600,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ 'client-ui-sidebar-files FilesBody', 'client-ui-sidebar-right GuideBody', 'client-ui-sidebar-terminal LazyTerminalBody', + 'client-ui-subagent SidebarChatTab', ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.right.pane.tab\', () => ctx.slots.register(\n { name: \'sidebar.right.pane.tab\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6fda355717..13ab4ef874 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2742,9 +2742,6 @@ importers: '@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 @@ -4768,6 +4765,9 @@ importers: '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-resources': + specifier: workspace:^ + version: link:../resources '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime From 4937343a5ef473fc0a00010273f48ff10f477877 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Thu, 17 Sep 2026 18:50:55 +0800 Subject: [PATCH 072/225] feat(web): unify the client visual language --- ...6-09-17-skill-tool-row-font-axis.i18n.yaml | 6 + .../2026-09-17-skill-tool-row-font-axis.md | 28 + .../2026-09-17-skill-tool-row-font-axis.zh.md | 28 + .agents/notes/archived/manifest.json | 3 + ...client-derived-tool-presentation.i18n.yaml | 4 +- ...-08-23-client-derived-tool-presentation.md | 4 +- ...-23-client-derived-tool-presentation.zh.md | 4 +- ...shared-client-control-primitives.i18n.yaml | 4 +- ...-09-05-shared-client-control-primitives.md | 19 +- ...-05-shared-client-control-primitives.zh.md | 19 +- ...ize-neutral-product-icon-weights.i18n.yaml | 6 + ...09-16-size-neutral-product-icon-weights.md | 29 + ...16-size-neutral-product-icon-weights.zh.md | 29 + ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 2 +- ...026-07-27-web-subagent-conversations.zh.md | 2 +- ...-09-04-web-clickable-link-styles.i18n.yaml | 4 +- .../2026-09-04-web-clickable-link-styles.md | 6 +- ...2026-09-04-web-clickable-link-styles.zh.md | 6 +- ...-07-composer-session-stats-pills.i18n.yaml | 4 +- ...2026-09-07-composer-session-stats-pills.md | 3 +- ...6-09-07-composer-session-stats-pills.zh.md | 3 +- ...menu-sections-and-localized-rows.i18n.yaml | 4 +- ...mposer-menu-sections-and-localized-rows.md | 4 +- ...ser-menu-sections-and-localized-rows.zh.md | 4 +- ...026-09-08-shared-file-type-icons.i18n.yaml | 4 +- .../2026-09-08-shared-file-type-icons.md | 16 +- .../2026-09-08-shared-file-type-icons.zh.md | 16 +- ...connection-indicator-refinements.i18n.yaml | 4 +- ...-09-10-connection-indicator-refinements.md | 2 +- ...-10-connection-indicator-refinements.zh.md | 2 +- ...2026-09-16-known-site-link-marks.i18n.yaml | 4 +- .../2026-09-16-known-site-link-marks.md | 2 +- .../2026-09-16-known-site-link-marks.zh.md | 2 +- ...ompact-translucent-menu-surfaces.i18n.yaml | 6 + ...09-17-compact-translucent-menu-surfaces.md | 29 + ...17-compact-translucent-menu-surfaces.zh.md | 29 + ...-09-17-state-dot-visual-language.i18n.yaml | 6 + .../2026-09-17-state-dot-visual-language.md | 31 + ...2026-09-17-state-dot-visual-language.zh.md | 31 + ...09-17-plugin-inventory-phase-dot.i18n.yaml | 4 +- .../2026-09-17-plugin-inventory-phase-dot.md | 2 +- ...026-09-17-plugin-inventory-phase-dot.zh.md | 2 +- apps/web/tests/access-confirmation.e2e.ts | 4 +- apps/web/tests/built-boot.expected.e2e.ts | 8 +- apps/web/tests/clickable-links-gallery.e2e.ts | 4 +- .../auto-confirmation.expected.md | 4 +- .../current-session-picker.expected.md | 4 +- .../slash-picker.expected.md | 4 +- .../access-confirmation/ui.expected.md | 4 +- .../copy-dialog.expected.md | 3 +- .../created.expected.md | 72 +- .../damaged.expected.md | 84 +- .../section.expected.md | 60 +- .../agent-preset-selection/header.expected.md | 11 +- .../agent-preset-selection/hero.expected.md | 10 +- .../agent-preset-selection/menu.expected.md | 4 +- .../clickable-links-gallery/ui.expected.md | 93 +- .../cold-blank-session/queue.expected.md | 9 +- .../geometry.expected.md | 10 +- .../expected/cordis-history/ui.expected.md | 131 +- .../declared-reasoning/ui.expected.md | 4 +- .../cards.expected.md | 48 +- .../picker.expected.md | 4 +- .../conversation-expanded.expected.md | 60 +- .../conversation.expected.md | 50 +- .../expected/goal-bar/active.expected.md | 10 +- .../expected/goal-bar/inactive.expected.md | 10 +- .../goal-command-presentation/ui.expected.md | 19 +- .../markdown-cjk-strong/ui.expected.md | 36 +- .../expected/markdown-images/ui.expected.md | 36 +- .../markdown-inline-code-links/ui.expected.md | 33 +- .../expected/math-rendering/ui.expected.md | 36 +- .../stored-error.expected.md | 32 +- .../catalog-inputs.expected.md | 42 +- .../models-settings/configured.expected.md | 32 +- .../models-settings/declared-edit.expected.md | 42 +- .../models-settings/declared.expected.md | 32 +- .../models-settings/delete.expected.md | 3 +- .../models-settings/empty.expected.md | 24 +- .../models-settings/model-picker.expected.md | 3 +- .../models-settings/native-delete.expected.md | 3 +- .../default-models.expected.md | 48 +- .../models.expected.md | 42 +- .../dismissed.expected.md | 24 +- .../plan-review/temporary.expected.md | 3 +- .../plugin-config/official.expected.md | 4 +- .../expected/plugin-config/row.expected.md | 4 +- .../plugin-config/subagent.expected.md | 10 +- .../blocked.expected.md | 11 +- .../cancelled.expected.md | 7 +- .../plugin-manager/live-enabled.expected.md | 4 +- .../plugin-manager/manager.expected.md | 4 +- .../reference-composer/menu.expected.md | 3 +- .../reference-composer/order.expected.md | 20 +- .../settings-chrome/dialog-en.expected.md | 58 +- .../settings-chrome/dialog.expected.md | 58 +- .../plugin-instances.expected.md | 6 - .../settings-chrome/plugins.expected.md | 1 - .../owner-running.expected.md | 4 +- .../sidebar-terminal/guide.expected.md | 3 +- .../sidebar-terminal/shell-menu.expected.md | 4 +- .../skill-user-invoke/ui-expanded.expected.md | 64 +- .../expected/skill-user-invoke/ui.expected.md | 54 +- .../stats-paged-history/ui.expected.md | 552 ++--- .../expected/steer-all/mid-steer.expected.md | 38 +- .../steer-all/settled-expanded.expected.md | 71 +- .../expected/steer-all/settled.expected.md | 56 +- .../expected/thinking-markdown/ui.expected.md | 4 +- .../grouping-options.expected.md | 8 +- .../parent-folders.expected.md | 12 +- .../sidebar.expected.md | 7 +- apps/web/tests/markdown-wide-table.e2e.ts | 4 +- apps/web/tests/plan-review.e2e.ts | 2 + apps/web/tests/question-composer.e2e.ts | 2 + apps/web/tests/sidebar-scrollbar.e2e.ts | 4 +- apps/web/tests/skill-tool-row.e2e.ts | 42 + .../agent-team-panel/task.expected.md | 22 +- .../mid-stream.expected.md | 27 +- apps/web/tests/subagent-conversation.e2e.ts | 4 +- docs/web-styling.i18n.yaml | 4 +- docs/web-styling.md | 3 +- docs/web-styling.zh.md | 3 +- .../client/locale/src/client/LanguageRow.tsx | 4 +- .../src/client/AgentPresetLabel.tsx | 4 +- .../src/client/AgentPresetSeat.tsx | 8 +- .../src/client/AgentPresetSection.tsx | 14 +- .../src/client/ApprovalPanel.module.css | 8 - .../ui-approval/src/client/ApprovalPanel.tsx | 6 +- .../tests/ui-approval.client.spec.tsx | 5 + .../ui-attachment/src/AttachmentRail.tsx | 6 +- .../client/ui-attachment/src/FileCard.tsx | 4 +- .../ui-attachment/src/ImageLightbox.tsx | 4 +- .../client/ui-attachment/src/MessageImage.tsx | 6 +- .../src/client/ComposerAttachments.tsx | 4 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 8 + packages/client/ui-chat/README.zh.md | 6 + .../client/chat/AssistantMarkdown.module.css | 2 +- .../ui-chat/src/client/chat/ChatView.tsx | 6 +- .../src/client/chat/CompactionItem.tsx | 10 +- .../src/client/chat/ContextInjectionRow.tsx | 6 +- .../src/client/chat/GenericCommandCard.tsx | 10 +- .../client/chat/MessageIconActions.module.css | 31 +- .../src/client/chat/MessageIconActions.tsx | 13 +- .../ui-chat/src/client/chat/ReasoningRow.tsx | 4 +- .../src/client/chat/StatsPills.module.css | 4 +- .../ui-chat/src/client/chat/StatsPills.tsx | 12 +- .../src/client/chat/SystemPromptRow.tsx | 4 +- .../src/client/chat/TurnProcessNodeView.tsx | 4 +- .../src/client/chat/TurnUsagePanel.module.css | 8 +- .../src/client/chat/TurnUsagePanel.tsx | 10 +- .../src/client/chat/stat-dialog.module.css | 1 + .../src/client/settings/TranscriptViewRow.tsx | 4 +- .../chat-font-axis-styles.client.spec.ts | 9 +- .../ui-chat/tests/chat-view.client.spec.tsx | 1 + .../src/client/PopupSelectView.module.css | 44 +- .../src/client/PopupSelectView.tsx | 4 +- .../ui-commands/src/client/presentation.ts | 16 +- .../ui-commands/tests/service.client.spec.ts | 4 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 6 +- .../src/client/input/editor/ReferenceChip.tsx | 4 +- .../ui-conversation/src/client/locales.ts | 6 + .../src/client/queue/QueueDock.module.css | 3 +- .../src/client/queue/QueueDock.tsx | 30 +- .../src/client/settings/EnterBehaviorRow.tsx | 4 +- .../client/skeleton/ContextMeter.module.css | 1 + .../src/client/skeleton/EmptyHero.tsx | 8 +- .../src/client/skeleton/InputBar.module.css | 21 +- .../src/client/skeleton/InputBar.tsx | 6 +- .../src/client/skeleton/TodoPanel.module.css | 34 +- .../src/client/skeleton/TodoPanel.tsx | 73 +- .../tests/todo-panel.client.spec.tsx | 6 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/ChangedFiles.tsx | 4 +- .../src/client/Deliverables.tsx | 4 +- .../ui-deliverables/src/client/PresentRow.tsx | 4 +- .../src/client/PresentedFileCard.tsx | 10 +- .../ui-deliverables/src/client/ReviewTab.tsx | 13 +- .../tests/present-row.client.spec.tsx | 4 + .../src/client/DirectoryBrowser.tsx | 18 +- .../ui-dockkit/src/components/FloatLayer.tsx | 6 +- .../ui-dockkit/src/components/TabPanel.tsx | 11 +- .../src/components/dockkit.module.css | 13 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- .../ui-goal/src/client/GoalBar.module.css | 7 +- .../client/ui-goal/src/client/GoalBar.tsx | 18 +- .../src/client/MenuView.module.css | 90 +- .../ui-input-trigger/src/client/MenuView.tsx | 10 +- .../tests/menu-view.client.spec.tsx | 2 +- .../src/client/JobListAction.module.css | 29 +- .../ui-jobs/src/client/JobListAction.tsx | 4 +- .../tests/job-list-action.client.spec.tsx | 2 + .../src/client/FeedbackDialog.tsx | 6 +- .../src/client/MessageFeedbackActions.tsx | 6 +- .../src/client/ModelSelect.module.css | 70 +- .../src/client/ModelSelect.tsx | 18 +- .../ui-model-selection/src/client/index.ts | 4 +- .../src/client/OpenInAppAction.module.css | 13 +- .../src/client/OpenInAppAction.tsx | 12 +- .../tests/open-in-app-action.client.spec.tsx | 2 +- .../src/client/PermissionRow.tsx | 4 +- .../src/client/PermissionSelect.tsx | 36 +- .../client/ui-plan/src/client/PlanCard.tsx | 4 +- .../ui-plan/src/client/PlanModeControl.tsx | 4 +- .../client/ui-plan/src/client/PlanPreview.tsx | 6 +- .../client/ui-plugin-manager/README.i18n.yaml | 4 +- packages/client/ui-plugin-manager/README.md | 4 +- .../client/ui-plugin-manager/README.zh.md | 4 +- .../src/client/PluginManagerPage.module.css | 46 +- .../src/client/PluginManagerPage.tsx | 67 +- .../src/client/PluginsPanelIcon.tsx | 4 +- .../tests/components.client.spec.tsx | 15 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 13 +- packages/client/ui-primitives/README.zh.md | 13 +- .../src/ConnectionIndicator.module.css | 11 +- .../ui-primitives/src/ConnectionIndicator.tsx | 9 +- .../ui-primitives/src/DisclosureRow.tsx | 6 +- .../client/ui-primitives/src/FileTypeIcon.tsx | 40 +- .../client/ui-primitives/src/JsonTree.tsx | 11 +- .../client/ui-primitives/src/LinkIcon.tsx | 157 +- .../client/ui-primitives/src/Menu.module.css | 98 +- packages/client/ui-primitives/src/Menu.tsx | 4 +- packages/client/ui-primitives/src/Modal.tsx | 4 +- .../src/OnboardingSurface.module.css | 3 +- .../ui-primitives/src/PermissionIcon.tsx | 92 + .../ui-primitives/src/ReferenceIcon.tsx | 72 +- .../ui-primitives/src/RiskConfirmation.tsx | 4 +- .../client/ui-primitives/src/SiteGlyph.tsx | 2 +- .../ui-primitives/src/StateDot.module.css | 95 +- .../client/ui-primitives/src/StateDot.tsx | 39 +- .../client/ui-primitives/src/WebBlock.tsx | 4 +- .../ui-primitives/src/code-file-types.ts | 4 +- .../client/ui-primitives/src/icons/index.tsx | 2046 ++++++++++------- .../client/ui-primitives/src/icons/props.ts | 2 +- packages/client/ui-primitives/src/index.ts | 9 +- .../src/markdown/MarkdownText.module.css | 2 +- .../ui-primitives/src/markdown/render.tsx | 8 +- .../client/ui-primitives/src/user-text.tsx | 4 +- .../ui-primitives/tests/atoms.client.spec.tsx | 5 +- .../tests/file-type-icon.client.spec.tsx | 17 +- ...cjk-strong-and-inline-code-url.settled.txt | 7 +- ...k-strong-and-inline-code-url.streaming.txt | 7 +- .../markdown-dom/footnotes.settled.txt | 7 +- .../markdown-dom/footnotes.streaming.txt | 7 +- ...gfm-strikethrough-and-literals.settled.txt | 14 +- ...m-strikethrough-and-literals.streaming.txt | 14 +- .../links-and-autolinks.settled.txt | 42 +- .../links-and-autolinks.streaming.txt | 42 +- .../reference-links-and-images.settled.txt | 21 +- .../reference-links-and-images.streaming.txt | 21 +- .../table-with-alignment.settled.txt | 7 +- .../table-with-alignment.streaming.txt | 7 +- .../ui-primitives/tests/icons.client.spec.tsx | 71 +- .../tests/link-icon.client.spec.tsx | 38 +- .../tests/markdown.client.spec.tsx | 9 +- .../tests/state-dot-styles.client.spec.ts | 35 +- .../tests/state-dot.client.spec.tsx | 28 +- .../tests/web-block.client.spec.tsx | 4 +- packages/client/ui-schedule/README.i18n.yaml | 4 +- packages/client/ui-schedule/README.md | 2 +- packages/client/ui-schedule/README.zh.md | 2 +- .../client/ScheduleCatalogAction.module.css | 44 +- .../src/client/ScheduleCatalogAction.tsx | 11 +- .../schedule-catalog-action.client.spec.tsx | 2 + .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../src/client/DesktopUpdateIndicator.tsx | 4 +- .../src/client/SettingsRoot.tsx | 16 +- .../ui-settings-general/src/client/chrome.tsx | 4 +- .../src/client/DeepSeekModelsEditor.tsx | 4 +- .../src/client/ModelListEditor.tsx | 4 +- .../src/client/ModelRow.tsx | 8 +- .../src/client/ModelsSection.tsx | 6 +- .../README.i18n.yaml | 4 +- .../ui-settings-plugin-inventory/README.md | 4 +- .../ui-settings-plugin-inventory/README.zh.md | 4 +- .../PluginInventorySettingsTab.module.css | 6 + .../src/client/PluginInventorySettingsTab.tsx | 34 +- .../tests/components.client.spec.tsx | 7 +- .../ui-settings-plugins/src/client/fields.tsx | 4 +- .../src/client/ArchivedSessionsSection.tsx | 4 +- .../src/client/definition.tsx | 4 +- .../src/client/view/BrowserBody.tsx | 24 +- .../src/client/view/BrowserTitle.tsx | 4 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/LoadingIndicator.module.css | 18 - .../src/client/LoadingIndicator.tsx | 4 +- .../src/client/TextPreview.tsx | 6 +- .../src/client/icons.tsx | 18 +- .../src/client/office/FontNotice.tsx | 10 +- .../src/client/pdf/PdfBody.module.css | 14 - .../src/client/pdf/pdf.tsx | 4 +- .../tests/loading-indicator.client.spec.tsx | 1 + .../tests/pdf-body.client.spec.tsx | 1 + .../ui-sidebar-files/src/client/FilesBody.tsx | 6 +- .../src/client/shell/ExpandButton.tsx | 4 +- .../src/client/shell/SidebarRight.tsx | 21 +- .../client/tabs/guide/GuideBody.module.css | 4 +- .../src/client/tabs/guide/GuideTitle.tsx | 19 +- .../src/client/TerminalGuide.module.css | 5 +- .../src/client/TerminalGuide.tsx | 4 +- .../src/client/TerminalIcon.tsx | 7 +- .../src/client/terminal.tsx | 4 +- .../src/client/HeaderLeadingControls.tsx | 6 +- .../ui-sidebar/src/client/SidebarRoot.tsx | 6 +- .../sidebar-snapshot.client.spec.tsx.snap | 40 +- .../tests/panel-list.client.spec.tsx | 4 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 2 +- packages/client/ui-skill/README.zh.md | 2 +- .../ui-skill/src/client/SkillRow.module.css | 21 +- .../client/ui-skill/src/client/SkillRow.tsx | 23 +- .../tests/skill-row-styles.client.spec.ts | 38 + .../ui-skill/tests/skill-row.client.spec.tsx | 5 +- packages/client/ui-subagent/README.i18n.yaml | 4 +- packages/client/ui-subagent/README.md | 2 +- packages/client/ui-subagent/README.zh.md | 2 +- .../client/SubagentHeaderLineage.module.css | 81 +- .../src/client/SubagentHeaderLineage.tsx | 15 +- .../tests/conversation-ui.client.spec.tsx | 4 +- packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 6 +- packages/client/ui-theme/README.zh.md | 6 +- .../ui-theme/src/client/AppearanceRow.tsx | 10 +- .../ui-theme/src/client/FontSizeRow.tsx | 6 +- .../ui-theme/src/styles/design-platform.css | 4 +- .../src/styles/gradient-shadow-text.css | 3 +- .../client/ui-theme/src/styles/scrollbar.css | 4 +- .../tests/elevation-styles.client.spec.ts | 45 +- .../tests/scrollbar-styles.client.spec.ts | 11 +- packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../client/tool/components/ToolRow.module.css | 5 +- .../src/client/tool/components/ToolRow.tsx | 17 +- .../src/client/tool/models/tool-call-model.ts | 2 +- .../client/tool/toolviews/GenericToolCard.tsx | 19 +- .../tool/toolviews/ask-question-row.tsx | 4 +- .../tool/toolviews/bash-sample.module.css | 3 +- .../src/client/tool/toolviews/bash-sample.tsx | 26 +- .../tool/toolviews/file-mutation-row.tsx | 4 +- .../client/tool/toolviews/read-family-row.tsx | 4 +- .../src/client/tool/toolviews/search-row.tsx | 4 +- .../src/client/tool/toolviews/todo-row.tsx | 4 +- .../src/client/tool/toolviews/web-row.tsx | 4 +- .../tests/ask-question-row.client.spec.tsx | 2 + .../tests/chat-ptc-subcalls.client.spec.tsx | 2 +- .../tests/coverage-tails.client.spec.tsx | 12 +- .../ui-tool/tests/diff-card.client.spec.tsx | 6 +- .../ui-tool/tests/read-card.client.spec.tsx | 2 + .../ui-tool/tests/search-card.client.spec.tsx | 1 + .../tests/terminal-card.client.spec.tsx | 5 +- .../ui-tool/tests/todo-row.client.spec.tsx | 1 + .../ui-tool/tests/tool-row.client.spec.tsx | 20 +- .../ui-tool/tests/web-card.client.spec.tsx | 5 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../src/client/TrajectoryTable.module.css | 21 - .../src/client/TrajectoryTable.tsx | 51 +- .../src/client/TrajectoryToolbar.tsx | 4 +- .../ui-trajectory/tests/table.client.spec.tsx | 2 + .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/PlanReviewPanel.module.css | 8 - .../src/client/PlanReviewPanel.tsx | 10 +- .../src/client/QuestionComposer.tsx | 23 +- .../tests/plan-review-panel.client.spec.tsx | 3 + .../src/client/WorkflowRunPanel.tsx | 6 +- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 6 +- packages/client/ui-workspace/README.zh.md | 6 +- .../src/client/WorkspacePicker.tsx | 6 +- .../client/ui-workspace/src/client/locales.ts | 6 + .../src/client/rows/Rows.module.css | 6 +- .../ui-workspace/src/client/rows/Rows.tsx | 68 +- .../client/rows/WorkspaceBrowser.module.css | 4 +- .../src/client/rows/WorkspaceBrowser.tsx | 14 +- .../tests/browser-styles.client.spec.ts | 2 +- .../ui-workspace/tests/rows.client.spec.tsx | 41 +- .../client-ui-agent-team/README.i18n.yaml | 4 +- .../client-ui-agent-team/README.md | 4 +- .../client-ui-agent-team/README.zh.md | 4 +- .../src/client/TeamAction.module.css | 9 +- .../src/client/TeamAction.tsx | 57 +- .../tests/team-action.client.spec.tsx | 12 +- .../extensions/ui-cordis/README.i18n.yaml | 4 +- packages/extensions/ui-cordis/README.md | 4 +- packages/extensions/ui-cordis/README.zh.md | 4 +- .../ui-cordis/src/client/CordisActionRow.tsx | 10 +- .../src/client/CordisDefineRow.module.css | 4 +- .../ui-cordis/src/client/CordisDefineRow.tsx | 16 +- .../src/client/CordisPanel.module.css | 26 +- .../ui-cordis/src/client/CordisPanel.tsx | 41 +- .../ui-cordis/src/client/CordisRunRow.tsx | 13 +- .../tests/status-icons.client.spec.tsx | 126 + .../src/client/HeaderAction.tsx | 6 +- .../web/auto-review-denial/ui.expected.md | 88 +- snapshots/web/bash-abort-row/ui.expected.md | 42 +- .../web/changed-files-turn/ui.expected.md | 90 +- .../web/deepseek-messages-chat/ui.expected.md | 59 +- snapshots/web/diff-bounded/ui.expected.md | 1 - snapshots/web/diff-context/ui.expected.md | 1 - .../feedback-command/ack-expanded.expected.md | 65 +- .../web/feedback-command/ack.expected.md | 55 +- .../feedback-release/ack-expanded.expected.md | 65 +- .../web/feedback-release/ack.expected.md | 55 +- .../web/file-upload-round/ui.expected.md | 54 +- .../fresh-round-trip/ui-expanded.expected.md | 70 +- snapshots/web/fresh-round-trip/ui.expected.md | 50 +- .../ui-expanded.expected.md | 199 +- .../goal-multi-turn-actions/ui.expected.md | 77 +- .../connection-error.expected.md | 4 +- .../web/lifecycle-chrome/hero.expected.md | 44 +- .../lifecycle-chrome/plan-active.expected.md | 44 +- .../reloaded-expanded.expected.md | 60 +- .../web/lifecycle-chrome/reloaded.expected.md | 50 +- .../cancel-expanded.expected.md | 51 +- .../web/live-interactions/cancel.expected.md | 46 +- .../live-interactions/error-auth.expected.md | 28 +- .../web/live-interactions/loading.expected.md | 27 +- .../retry-exhausted.expected.md | 28 +- .../retry-expanded.expected.md | 60 +- .../web/live-interactions/retry.expected.md | 50 +- .../running-draft.expected.md | 27 +- .../web/message-actions/fork.expected.md | 4 +- snapshots/web/message-actions/ui.expected.md | 97 +- snapshots/web/minimal-preset/ui.expected.md | 54 +- .../navigation-panes/trajectory.expected.md | 1 - .../web/open-in-app-ssh/header.expected.md | 6 +- .../plan-review/approved-expanded.expected.md | 73 +- .../web/plan-review/approved.expected.md | 53 +- snapshots/web/plan-review/preview.expected.md | 3 +- snapshots/web/plan-review/review.expected.md | 8 +- snapshots/web/plan-review/sidebar.expected.md | 2 +- snapshots/web/present-svg/ui.expected.md | 56 +- snapshots/web/present/ui.expected.md | 92 +- snapshots/web/ptc-round/code.expected.md | 3 +- .../web/ptc-round/trajectory.expected.md | 14 +- snapshots/web/ptc-round/ui.expected.md | 78 +- .../answered-expanded.expected.md | 69 +- .../question-composer/answered.expected.md | 50 +- .../question-composer/cancelled.expected.md | 43 +- .../question-composer/composed.expected.md | 12 +- .../web/question-composer/sidebar.expected.md | 2 +- .../web/question-composer/ui.expected.md | 12 +- .../web/queue-actions/collapsed.expected.md | 27 +- .../web/queue-actions/editing.expected.md | 42 +- .../web/queue-actions/failed.expected.md | 7 +- .../web/queue-actions/layout.expected.md | 44 +- .../preserved-expanded.expected.md | 69 +- .../web/queue-actions/preserved.expected.md | 64 +- .../web/queue-actions/sending.expected.md | 36 +- snapshots/web/queue-actions/ui.expected.md | 36 +- .../web/queue-actions/writer-held.expected.md | 7 +- .../web/queued-image/delivered.expected.md | 96 +- snapshots/web/queued-image/queued.expected.md | 36 +- .../seeded-history/command-row.expected.md | 69 +- .../seeded-history/feedback-row.expected.md | 73 +- .../seeded-history/file-preview.expected.md | 3 +- .../thinking-expanded.expected.md | 4 +- .../seeded-history/ui-expanded.expected.md | 68 +- snapshots/web/seeded-history/ui.expected.md | 54 +- .../sidebar-scrollbar/geometry.expected.md | 8 +- snapshots/web/skill-tool-row/ui.expected.md | 73 +- snapshots/web/steering/mid-steer.expected.md | 40 +- .../web/steering/settled-expanded.expected.md | 63 +- snapshots/web/steering/settled.expected.md | 53 +- .../branchless.expected.md | 3 +- .../subagent-conversation/fork.expected.md | 4 +- .../subagent-conversation/nested.expected.md | 9 +- .../sidebar-chat.expected.md | 39 +- .../subagent-conversation/sidebar.expected.md | 4 +- .../subagent-conversation/tree.expected.md | 12 +- .../ui-expanded.expected.md | 93 +- .../web/subagent-conversation/ui.expected.md | 78 +- .../offline-composer.expected.md | 32 +- .../turn-tail-actions/completed.expected.md | 54 +- .../web/turn-tail-actions/focused.expected.md | 69 +- .../web/turn-tail-actions/running.expected.md | 45 +- .../web/turn-tail-actions/settled.expected.md | 50 +- .../usage-expanded.expected.md | 54 +- snapshots/web/web-search-round/ui.expected.md | 60 +- .../web/workflow-run/ui-live.expected.md | 8 +- snapshots/web/workflow-run/ui.expected.md | 52 +- .../directory-browser.expected.md | 48 +- .../manual-blank.expected.md | 4 +- .../web/workspace-recency/sidebar.expected.md | 4 +- 501 files changed, 5455 insertions(+), 7067 deletions(-) create mode 100644 .agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.i18n.yaml create mode 100644 .agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.md create mode 100644 .agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md create mode 100644 .agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md create mode 100644 .agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md create mode 100644 .agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md create mode 100644 .agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md create mode 100644 .agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md create mode 100644 packages/client/ui-primitives/src/PermissionIcon.tsx create mode 100644 packages/client/ui-skill/tests/skill-row-styles.client.spec.ts create mode 100644 packages/extensions/ui-cordis/tests/status-icons.client.spec.tsx diff --git a/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.i18n.yaml b/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.i18n.yaml new file mode 100644 index 0000000000..f3ace3a7dc --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-skill-tool-row-font-axis.md +2026-09-17-skill-tool-row-font-axis.md: c36769febfb1e493e2a7acbd53943a1550289312 +2026-09-17-skill-tool-row-font-axis.zh.md: a5891ec9a7e483761247af69e89014f4a53a823c diff --git a/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.md b/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.md new file mode 100644 index 0000000000..c36769febf --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.md @@ -0,0 +1,28 @@ +# Agent Note: Skill Tool row follows the shared font-size axis + +Status: implemented +Archived: 2026-09-17 + +English | [中文](2026-09-17-skill-tool-row-font-axis.zh.md) + +## Problem + +The collapsed Skill Tool row used fixed 14px text, a 24px row, and a 16px leading slot while ordinary Tool rows used the global secondary font-size tier and content-size delta. Skill therefore rendered larger at the default setting and stopped scaling with the rest of the conversation when the user changed content size. + +## Decision + +The collapsed Skill row uses `--dsh-content-font-size-secondary` for its title and summary, and applies `--dsh-content-font-delta` to its line height, row height, leading slot, and business glyph. These declarations match the ordinary Tool row and shared `DisclosureRow` summary line. The expanded Instructions card retains its independent header, code font, spacing, and controls. + +## Alternatives considered + +**Keep the fixed Skill typography.** Rejected because the Skill row is a Tool-call summary peer and should not grow independently from adjacent Tool rows. + +**Move the complete Skill row onto `DisclosureRow`.** Rejected for this correction because its existing expanded Instructions layout and interaction are already local; aligning the shared font-size declarations removes the visible mismatch without restructuring the component. + +## Testing + +The Skill row stylesheet test pins the secondary font-size variable, content delta, collapsed row height, leading-slot size, and glyph size. The authored `skill-tool-row` Web replay changes the global font variables in the shipped composition and checks the computed title, summary, row, and glyph sizes. Existing Skill component tests continue to cover lifecycle, expansion, and durable replay behavior. + +## Consequences + +The collapsed Skill line now changes size with ordinary Tool rows and remains aligned beside them. The expanded Instructions card does not inherit this change. diff --git a/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.zh.md b/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.zh.md new file mode 100644 index 0000000000..a5891ec9a7 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-09-17-skill-tool-row-font-axis.zh.md @@ -0,0 +1,28 @@ +# Agent Note: Skill 工具行跟随共享字号轴 + +Status: implemented +Archived: 2026-09-17 + +[English](2026-09-17-skill-tool-row-font-axis.md) | 中文 + +## 问题 + +收起的 Skill 工具行使用固定的 14px 文字、24px 行和 16px 前置槽,而普通工具行使用全局 secondary 字号层级与内容字号增量。因此 Skill 在默认设置下更大,并且用户修改内容字号时不会与对话中的其他工具行一起缩放。 + +## 决策 + +收起的 Skill 行为标题与摘要使用 `--dsh-content-font-size-secondary`,并把 `--dsh-content-font-delta` 应用于行高、行高度、前置槽和业务图标。这些声明与普通工具行及共享 `DisclosureRow` 摘要行一致。展开的 Instructions 卡片保留独立的标题、代码字体、间距和控件。 + +## 考虑过的替代方案 + +**保留固定的 Skill 排版。** 否决,因为 Skill 行是工具调用摘要的同级元素,不应与相邻工具行各自缩放。 + +**把完整 Skill 行迁移到 `DisclosureRow`。** 本次修正不采用,因为现有的展开 Instructions 布局与交互已经由本组件拥有;对齐共享字号声明即可消除可见差异,无需重构组件。 + +## 测试 + +Skill 行样式测试固定 secondary 字号变量、内容字号增量、收起行高度、前置槽尺寸和图标尺寸。手工编写的 `skill-tool-row` Web 回放会在已发布组合中修改全局字号变量,并检查标题、摘要、行和图标的计算尺寸。现有 Skill 组件测试继续覆盖生命周期、展开和持久化回放行为。 + +## 后果 + +收起的 Skill 行会与普通工具行同步改变字号,并继续与其对齐。展开的 Instructions 卡片不继承本次变化。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 926441caeb..b4f26b7dc1 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -751,6 +751,9 @@ "bug-fix/2026-09-09-content-sized-diagram-previews.i18n.yaml": "sha256:a4404fe8e35ca5b6371f9cdb58fea6724cf3e7737bb2db8a3a321d534454b17c", "bug-fix/2026-09-09-content-sized-diagram-previews.md": "sha256:5239e98e5c057d6f7e10c3f10a0f67273778d99b0c4f10b31a1921c3c6474b80", "bug-fix/2026-09-09-content-sized-diagram-previews.zh.md": "sha256:c1ee3790a9d5c3561e1d93d5e649afa904c7d19b9d77248c85ef16daccd953e2", + "bug-fix/2026-09-17-skill-tool-row-font-axis.i18n.yaml": "sha256:82234faebd8ae3d416b4efdac5bbe3f9ff5d471ca950bb7f38de9e6e16ac474f", + "bug-fix/2026-09-17-skill-tool-row-font-axis.md": "sha256:4421a088e69776ef42915ec28c5e8da93abb832d866c7f4d8e0c52cd55c225bd", + "bug-fix/2026-09-17-skill-tool-row-font-axis.zh.md": "sha256:04ab2a05aaf74ab266ba37af1df9fa9fa14171b8ec0eeabdcf38f0ae98707bce", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml index fff8e98bc5..cdf124f72a 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md -2026-08-23-client-derived-tool-presentation.md: 703960cb24f90eb678fda381f929efb3f94bbd28 -2026-08-23-client-derived-tool-presentation.zh.md: 7f161379553f3aca33c72c2edf93e114d27eb7f9 +2026-08-23-client-derived-tool-presentation.md: 8befe840b08006a7d2ee0fa1b7875c2c989d4779 +2026-08-23-client-derived-tool-presentation.zh.md: 249366f10e6eb06941b344977169609358786413 diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md index 703960cb24..8befe840b0 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md @@ -64,7 +64,7 @@ The Host `ToolDefinition.presentCall`, `ToolDefinition.presentResult`, `ToolCall **Client card model** means the pure props data under `ui-tool/src/client/tool/models/` consumed directly by `TerminalBlock`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, or `ToolRow`. -**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, status dots, and ordinary IN/OUT text remain part of the generic tool row. +**Specialized card** means the structured terminal, diff, read, search, or web body. Titles, summaries, lifecycle styling, business glyphs, and ordinary IN/OUT text remain part of the generic tool row. **Equivalent** means that the same supported input produces the user-visible result and interaction pinned by the existing component, assembly, and browser evidence. It does not require the same intermediate TypeScript types or internal calls. @@ -301,7 +301,7 @@ The Client terminal model derives existing `TerminalBlock` props from the tool n | Input | Preserved result | |---|---| | running standard `bash`/`pwsh` foreground call | terminal prompt, description, cwd, and running state | -| successful standard foreground call | terminal output, exit code/signal, and success or failure status dot | +| successful standard foreground call | terminal output, exit code/signal, and settled state presentation | | `run_in_background:true` | Generic row and raw result | | tool execution error | Generic IN/OUT and error summary | | running persistent `bash`/`pwsh` | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md index 7f16137955..249366f10e 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.zh.md @@ -64,7 +64,7 @@ Host 的 `ToolDefinition.presentCall`、`ToolDefinition.presentResult`、`ToolCa **Client card model**指 `ui-tool/src/client/tool/models/` 下直接供 `TerminalBlock`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 或 `ToolRow` 使用的纯 props 数据。 -**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、状态点和普通 IN/OUT 文本仍属于通用工具行。 +**专用卡片**指 terminal、diff、read、search 与 web 的结构化正文;标题、摘要、生命周期样式、业务图形和普通 IN/OUT 文本仍属于通用工具行。 **对等**指同一受支持输入产生由现有组件、组装与浏览器证据固定的用户可见结果和交互,不要求相同的中间 TypeScript 类型或内部函数调用。 @@ -301,7 +301,7 @@ Client terminal model 从工具名称、调用参数、结果 content、error | 输入 | 保持的结果 | |---|---| | 标准 `bash`/`pwsh` 前台 running | terminal prompt、description、cwd、running 状态 | -| 标准前台 success | terminal output、exit code/signal、成功或失败状态点 | +| 标准前台 success | terminal output、exit code/signal 与结算状态呈现 | | `run_in_background:true` | Generic 行与原始结果 | | 工具执行 error | Generic IN/OUT 与错误摘要 | | persistent `bash`/`pwsh` running | terminal prompt | diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml index 89a705592d..55f30d278a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md -2026-09-05-shared-client-control-primitives.md: 14128497cf4152659a62136cbdeb74b92ee3fbf0 -2026-09-05-shared-client-control-primitives.zh.md: 66268b48f81887181c3981bf89ead30eb74250af +2026-09-05-shared-client-control-primitives.md: db8cb9a8bc515918867b27b216fe8edb50c392db +2026-09-05-shared-client-control-primitives.zh.md: 6d4b04a21bf224eaa32b5fbdfb2bf1c3a2cd804d diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md index 14128497cf..db8cb9a8bc 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.md @@ -18,15 +18,15 @@ Neither half of the fix was available to an author. No checklist told them to lo `Switch` is the two-state toggle, at the 36×20 track and 16px thumb `ui-settings-plugins` established. `label` is required and has no default, so a render site cannot omit the accessible name; `title` carries a lock reason where a deployment disables the control. -`StateDotState` gains `idle`, a static grey dot in the same halo-and-core construction as `done`, `warning`, and `error`. The plugin inventory's `pending` and `unloading` phases mean no activity is in progress, and the four existing states had no member for that; without `idle` those two phases lose their marker entirely. The addition is safe for the eight packages already consuming `StateDot` because each of them produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. +`StateDotState` includes `idle` for a tracked subject with no activity in progress. The plugin inventory's `pending` and `unloading` phases need that semantic member; without it those two phases lose their marker entirely. The five-state union remains safe for consumers because each package produces a `StateDotState` from its own closed status union and none switches over `StateDotState` itself. The current solid-dot palette and rotating `ongoing` loader are owned by the later [StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md). -**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. `Pill`'s own header comment previously advertised itself for badges, which contradicted the catalog, and no longer does. +**The component catalog in the `ui-primitives` README is what makes the rule usable.** It lists every exported component with its purpose and the case it is wrong for, and it names the three pairs that are easy to confuse: `Tag` against `Pill`, `DisclosureRow` against card-shaped disclosure, and the package-internal `FoldToggle` against the exported surface. `Pill` is the selectable capsule button — it takes `active` and `onClick` and drives view switchers and filters; `Tag` is the read-only badge and takes neither. The component comments use the same distinction. The rule is stated in [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) as the first step of the new-component checklist, and [docs/web-styling.md](../../../../docs/web-styling.md) points at the catalog so an author arriving from the styling side reaches it too. ## Finding the duplicates -A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`, and its rule was byte-identical to the badge two files away. What finds them is the geometry: a CSS Modules rule carrying both `border-radius: 999px` and `padding: 1px 8px`. After this change that signature matches exactly two rules: `Tag` itself and the broken badge below. The unsaved marker keeps only a `flex: none` placement class, which the signature no longer matches. +A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` miss a capsule named for its role rather than its appearance — `PluginCard`'s unsaved marker is `.pending`. Geometry is the reliable probe: search CSS Modules rules carrying both `border-radius: 999px` and `padding: 1px 8px`. That signature belongs to `Tag` and the deliberately local broken badge below; the unsaved marker keeps only its `flex: none` placement class. ## What stays local @@ -34,7 +34,6 @@ A name-based search undercounts. `.badge`, `.tag`, `.chip`, and `.configTag` mis A mechanical search groups these controls with the promoted three. They stay in their own packages, because the grouping is superficial: - **`ui-trajectory`'s toolbar toggle** carries `role="switch"` but is an 88px labelled control with an inline track, and it currently renders `hidden`. It is not the same widget as `Switch`. -- **`ui-schedule`'s status dot** is a static blue dot for the next run that turns amber when overdue. `StateDot` has no static blue — its only blue is `ongoing`, an animated pixel matrix — and one animation per row in a schedule list would misstate the meaning as well as the appearance. - **`ui-plan`'s mode chip** and **`ui-conversation`'s `ReferenceChip`** are interactive: the first is a warning-toned button with hover, focus, disabled, and a close affordance; the second is a Lexical atom node with its own truncation. Neither is a read-only badge. - **`ui-trajectory`'s cell tag** and **`ui-user-questions`'s recommendation badge** use their own geometry — a 6px radius at table density, and a 6px radius at 600 weight on the sidebar accent. Forcing either into the capsule baseline would change a deliberate design, not an accidental one. - **`TerminalBlock`'s exit-status pill** stays a static `Pill`. It sits on a 24px command line at the pill's own geometry, and `Tag`'s 11px capsule would not fit that row. The read-only/selectable split is the usual guide, but size decides this one, and the catalog says so. @@ -52,7 +51,7 @@ A mechanical search groups these controls with the promoted three. They stay in **A two-axis `variant × tone` API for `Tag`.** Rejected. Three variants against six tones describes eighteen combinations of which eight ship, and it lets a caller request combinations with no defined appearance. The flat eight-member union maps each value to exactly one shipped appearance. -**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those rows show a grey dot today, and dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. +**Rendering no dot for `pending` and `unloading` instead of adding `idle`.** Rejected. Those phases need a compact inactive or transitional marker; dropping it would remove information from the inventory in a change whose purpose is to consolidate presentation. **Putting the reuse rule in the root `AGENTS.md`.** Rejected. The rule governs `packages/client` alone, and the root file sits exactly at its 1950-word ceiling in `scripts/doc-budgets.manifest.json`, so stating it there would have to displace an unrelated repository-wide rule. @@ -60,15 +59,15 @@ A mechanical search groups these controls with the promoted three. They stay in `Tag`, `Switch`, and the extended `StateDot` carry component specs in `packages/client/ui-primitives/tests`, inside the per-file 100% coverage gate. `StateDot`'s palette is pinned by reading its stylesheet: CSS Modules resolve to class-name maps in the component suites, so a state whose color rule is missing renders on the inherited color and no render assertion notices. -The migrated render sites keep their existing package specs unchanged. The web e2e goldens are ARIA snapshots, and the full replayed web suite passes without re-recording, because the migration preserves every role, accessible name, and state — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper, since `StateDot` is `aria-hidden`. +The migrated render sites keep package specs for their role, accessible name, state mapping, and interaction. Web ARIA snapshots cover the assembled presentations — `Switch` keeps `role="switch"` with `aria-checked`, and the inventory's phase dot keeps its `role="img"` name on a wrapper because `StateDot` is `aria-hidden`. -That is also the limit of the automated evidence. No gate in this repository compares pixels, so the capsule geometry, the dot halo, and the font-weight change are verified by review against the light and dark screenshots in the pull request. +The component and stylesheet specs pin the capsule geometry, StateDot artwork, animation, tokens, and font weight; Web snapshots pin accessible structure. Manual visual inspection remains responsible for the rendered result across themes. ## Consequences - A new client control now has one place to check and one place to add, and the catalog makes the check a single file read rather than a `grep` over forty exports. - `TagTone` is eight members wide because eight appearances shipped. Adding a ninth requires a render site that needs it, not a symmetry argument. -- The plugin inventory's tags change from a 5px rectangle to a capsule, and the disabled tag's fill moves from `--dsw-alias-bg-layer-1` to the `neutral` tone's `--dsw-alias-bg-module-platform`, which is a visible grey where it used to be near-transparent. Its phase dots gain a halo and animate through `loading` and `unloading`. The unconfigured-secret badge in the plugin settings fields moves from 400 to the baseline 500 weight. These are deliberate visual changes, recorded here so a later reader does not treat them as regressions. -- The migration removes a literal `#b45309`. The plugin inventory's `conditional` tag read `var(--dsw-alias-state-warning-primary, #b45309)`, and no such alias exists — the real token is `--dsw-alias-state-warn-primary` — so both themes had been painting the hardcoded fallback that [docs/web-styling.md](../../../../docs/web-styling.md) forbids. +- The plugin inventory uses capsule tags; the disabled tag uses the `neutral` tone's visible `--dsw-alias-bg-module-platform` fill. Its phase markers use the shared `StateDot`, including the rotating loader for active transitions. The unconfigured-secret badge in the plugin settings fields uses the baseline 500 weight. +- The plugin inventory palette contains no literal `#b45309`. The conditional tag uses the real `--dsw-alias-state-warn-primary` token rather than the nonexistent `--dsw-alias-state-warning-primary` alias and its hardcoded fallback. - The rule cannot be checked mechanically. A future author can still copy a control, and only review will catch it. That is the accepted cost of not gating: the alternative rejects legitimate work, and the packages that stay local above are proof that legitimate work exists. -- `ui-primitives` grows two components that one package each consumes today. The switch in particular is a single-consumer primitive, promoted because it is a general control and because the plugin-management work already in flight will adopt it rather than adding two further copies. +- `Tag` and `Switch` are shared across settings and preset packages; future consumers extend these primitives only when an existing prop cannot express a deliberate difference. diff --git a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md index 66268b48f8..6d4b04a21b 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-shared-client-control-primitives.zh.md @@ -18,15 +18,15 @@ Status: implemented `Switch` 是双态开关,采用 `ui-settings-plugins` 确立的 36×20 轨道与 16px 滑块。`label` 必填且没有默认值,渲染点无法省略无障碍名称;`title` 在部署禁用该控件时承载锁定原因。 -`StateDotState` 增加 `idle`,一个静态灰点,与 `done`、`warning`、`error` 采用同样的光晕加实心核结构。插件清单的 `pending` 与 `unloading` 相位表示没有进行中的活动,原有四态没有对应成员;没有 `idle`,这两个相位会彻底失去标记。这次增补对已经消费 `StateDot` 的八个包是安全的:它们各自从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。 +`StateDotState` 包含 `idle`,表示一个被跟踪的对象当前没有进行中的活动。插件清单的 `pending` 与 `unloading` 相位需要这个语义成员;没有 `idle`,这两个相位会彻底失去标记。五态联合对消费方保持安全,因为每个包都从自己的闭合状态联合**产生** `StateDotState`,没有任何一个对 `StateDotState` 本身做 switch。当前的纯色点配色与旋转 `ongoing` loading 由后续的 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责。 -**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。`Pill` 自己的头注释此前把自身宣传为可用作徽章,与目录互相矛盾,现已改掉。 +**`ui-primitives` README 里的组件目录,是这条规则得以可用的前提。** 它列出每个导出组件的用途,以及它不适用的场合,并点名三组容易混淆的配对:`Tag` 与 `Pill`、`DisclosureRow` 与卡片式折叠、包内的 `FoldToggle` 与对外导出面。`Pill` 是可选中的胶囊按钮——它接受 `active` 与 `onClick`,用于视图切换与筛选器;`Tag` 是只读徽章,两者都不接受。组件注释使用同一套区分。 规则写在 [packages/client/AGENTS.md](../../../../packages/client/AGENTS.md) 新组件清单的第一步,[docs/web-styling.md](../../../../docs/web-styling.zh.md) 指向该目录,让从样式一侧进来的作者也能到达。 ## 如何找出重复 -按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`,而它的规则与两个文件之外的徽章逐字节相同。能找出它们的是几何特征:同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。本次改动后,这个特征恰好匹配两条规则:`Tag` 本身与下面那个损坏徽章。未保存标记只剩一个 `flex: none` 布局类,已不再匹配该特征。 +按名字搜会漏。`.badge`、`.tag`、`.chip`、`.configTag` 找不到以角色而非外观命名的胶囊——`PluginCard` 的未保存标记叫 `.pending`。几何特征才是可靠探针:搜索同时带有 `border-radius: 999px` 与 `padding: 1px 8px` 的 CSS Modules 规则。这个特征属于 `Tag` 与下面那个有意保留在原包的损坏徽章;未保存标记只保留自己的 `flex: none` 布局类。 ## 保留在原包的控件 @@ -34,7 +34,6 @@ Status: implemented 机械搜索会把下面这些控件归到被提升的那三类里。它们留在各自的包中,因为那种归并只是表面相似: - **`ui-trajectory` 的工具栏开关**带 `role="switch"`,但它是 88px 宽的带标签控件、轨道内联,而且当前渲染为 `hidden`。它和 `Switch` 不是同一个部件。 -- **`ui-schedule` 的状态点**是表示下次运行的静态蓝点,逾期转为琥珀色。`StateDot` 没有静态蓝——它唯一的蓝是 `ongoing`,一个动画像素方阵——日程列表里每行一个动画,既说错了含义也说错了观感。 - **`ui-plan` 的模式 chip** 与 **`ui-conversation` 的 `ReferenceChip`** 都是可交互的:前者是警告色调的按钮,带 hover、focus、disabled 与关闭操作;后者是带自有截断逻辑的 Lexical 原子节点。两者都不是只读徽章。 - **`ui-trajectory` 的单元格 tag** 与 **`ui-user-questions` 的推荐徽章**使用各自的几何——一个是表格密度下的 6px 圆角,一个是侧栏强调色上 600 字重的 6px 圆角。把它们塞进胶囊基准,改掉的是有意的设计,不是意外的分歧。 - **`TerminalBlock` 的退出状态胶囊**保持为静态 `Pill`。它落在 24px 的命令行上、用的是 pill 自己的几何,`Tag` 的 11px 胶囊放不进那一行。只读与可选中的区分是通常的判据,但这一处由尺寸决定,目录里也是这么写的。 @@ -52,7 +51,7 @@ Status: implemented **给 `Tag` 设计 `variant × tone` 双轴 API。** 否决。三个 variant 乘六个 tone 描述十八种组合,其中八种会发布,而且它允许调用方请求没有定义外观的组合。扁平的八成员联合把每个值映射到恰好一种已发布外观。 -**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这两行今天显示灰点,在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 +**让 `pending` 与 `unloading` 不渲染点,而不是新增 `idle`。** 否决。这些阶段需要紧凑的未活动或过渡标记;在一个目的是统一呈现的改动里把它删掉,等于从清单中拿走信息。 **把复用规则写进根 `AGENTS.md`。** 否决。这条规则只管 `packages/client`,而根文件正好处在 `scripts/doc-budgets.manifest.json` 给它的 1950 词上限上,写在那里就必须挤掉一条不相干的仓库级规则。 @@ -60,15 +59,15 @@ Status: implemented `Tag`、`Switch` 与扩展后的 `StateDot` 在 `packages/client/ui-primitives/tests` 中各有组件测试,处于每文件 100% 覆盖率门禁之内。`StateDot` 的配色通过读取其样式表来钉住:CSS Modules 在组件测试里解析为类名映射,因此某个状态缺了配色规则时会落到继承色上,而任何渲染断言都不会察觉。 -迁移后的渲染点保留各自包内原有的测试,未作改动。web e2e 的 golden 是 ARIA 快照,完整的 replay web 套件无需重录即可通过,因为这次迁移保住了每一个 role、无障碍名称与状态——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 +迁移后的渲染点保留包级测试,覆盖 role、无障碍名称、状态映射与交互。Web ARIA 快照覆盖装配后的呈现——`Switch` 保留带 `aria-checked` 的 `role="switch"`,而由于 `StateDot` 是 `aria-hidden`,插件清单的相位点把 `role="img"` 名称保留在外层包裹元素上。 -这同时也是自动化证据的边界。本仓库没有任何门禁比较像素,因此胶囊几何、状态点光晕与字重变化,只能由评审对照 PR 中的明暗两套截图来确认。 +组件与样式表测试固定胶囊几何、StateDot 图稿、动画、token 与字重;Web 快照固定无障碍结构。明暗主题下的最终渲染仍由人工视觉检查负责。 ## Consequences - 新的客户端控件现在有一处可查、一处可加,而目录把这次查询变成读一个文件,而不是在四十多个导出上 `grep`。 - `TagTone` 有八个成员,因为发布了八种外观。加第九个需要一个真的需要它的渲染点,而不是一个对称性论证。 -- 插件清单的标签从 5px 矩形变为胶囊,其中"已停用"标签的底色从 `--dsw-alias-bg-layer-1` 变为 `neutral` 色调的 `--dsw-alias-bg-module-platform`,由近乎透明变成可见的灰底。相位点获得光晕,并在 `loading` 与 `unloading` 两个相位动画。插件设置字段中"未配置"徽章的字重从 400 改为基准的 500。这些都是有意的视觉变更,记录在此,以免后来的读者把它们当成回归。 -- 本次迁移移除了一个 `#b45309` 字面量。插件清单的 `conditional` 标签写的是 `var(--dsw-alias-state-warning-primary, #b45309)`,而这个别名并不存在——真正的 token 是 `--dsw-alias-state-warn-primary`——所以明暗两套主题一直在绘制那个被 [docs/web-styling.md](../../../../docs/web-styling.zh.md) 禁止的硬编码兜底色。 +- 插件清单使用胶囊标签;"已停用"标签使用 `neutral` 色调下可见的 `--dsw-alias-bg-module-platform` 填充。相位标记使用共享 `StateDot`,活动转换采用旋转 loading。插件设置字段中的"未配置"徽章使用基准 500 字重。 +- 插件清单调色板不含 `#b45309` 字面量。`conditional` 标签使用真实的 `--dsw-alias-state-warn-primary` token,而不是不存在的 `--dsw-alias-state-warning-primary` 别名及其硬编码兜底色。 - 这条规则无法被机械检查。将来的作者仍然可以复制一个控件,只有评审能拦住。这是不设门禁所接受的代价:另一条路会拒绝正当的工作,而上面那份保留在原包的清单就是正当工作确实存在的证据。 -- `ui-primitives` 多出两个各自只有一个消费方的组件。开关尤其如此,提升它是因为它是一个通用控件,也因为正在进行中的插件管理工作会采用它,而不是再加两份拷贝。 +- `Tag` 与 `Switch` 由多个设置和预设包共享;只有现有 prop 无法表达明确差异时,后续消费方才扩展这些原语。 diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml new file mode 100644 index 0000000000..758bca04da --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md +2026-09-16-size-neutral-product-icon-weights.md: 3850fcbddfef6d096f648d5b3efa73790f40f8c1 +2026-09-16-size-neutral-product-icon-weights.zh.md: 84031677e9b13b8dbf3b8e5d959359824e0c94fc diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md new file mode 100644 index 0000000000..3850fcbddf --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md @@ -0,0 +1,29 @@ +# Agent Note: Size-neutral product icon weights + +Status: implemented + +English | [中文](2026-09-16-size-neutral-product-icon-weights.zh.md) + +## Problem + +Shared product-icon exports encoded one historical render size in each symbol name even though every component accepted a `size` prop. Consumers also thickened selected icons through local CSS or separate drawings, so emphasis had no named library choice and could drift between features. + +## Decision + +Every shared product glyph exports a size-neutral `Regular` and `Medium` component. `Regular` renders the supplied one-pixel artwork, while `Medium` renders the same paths with a 1.3px inherited stroke; filled regions remain unchanged. The `size` prop controls rendered dimensions, and the former numeric suffix survives only as the default size when a glyph has no 16px source. + +`LinkIconRegular` and `LinkIconMedium`, `ReferenceIconRegular` and `ReferenceIconMedium`, and the three `PermissionIcon*Regular`/`PermissionIcon*Medium` pairs follow the same rule. Repository consumers use a named weight instead of a numeric export. + +Medium weight is reserved for deliberate emphasis: new-Session controls, Settings trigger and navigation icons, Appearance choices, the composer add button, and clickable artifact-link glyphs. Other existing consumers use Regular. The composer command menu uses `PermissionIconFullAccessRegular` for the permission command because that row names permission elevation rather than the current mode. + +## Alternatives considered + +**Keep pixel sizes in export names.** Rejected because the suffix duplicated the runtime `size` prop and created separate names for identical geometry. + +**Expose one component with a `weight` prop.** Rejected because call sites would hide the chosen weight inside props; explicit component names keep visual emphasis searchable and reviewable. + +**Override stroke width in consumer CSS.** Rejected because source SVG elements can carry their own stroke widths, and local overrides would recreate inconsistent weighting outside the library. + +## Consequences + +The icon API is intentionally breaking while pre-stable: every consumer names `Regular` or `Medium`, and former 14px call sites pass an explicit size when they share geometry with a 16px export. New product glyphs add both weights from one geometry definition. Fill-only artwork exposes both names for API consistency even when stroke weight does not change its appearance. The hierarchy choices above remain explicit at their render sites rather than becoming global size or opacity overrides. diff --git a/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md new file mode 100644 index 0000000000..84031677e9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 尺寸无关的产品图标线重 + +Status: implemented + +[English](2026-09-16-size-neutral-product-icon-weights.md) | 中文 + +## 问题 + +共享产品图标的导出名称包含一个历史渲染尺寸,但每个组件已经接受 `size` prop。消费方还会通过本地 CSS 或独立图稿加粗部分图标,视觉强调没有具名的图标库选项,因而可能在不同功能间漂移。 + +## 决策 + +每个共享产品图形都导出尺寸无关的 `Regular` 和 `Medium` 组件。`Regular` 渲染提供的 1px 图稿,`Medium` 使用相同路径并继承 1.3px 描边;填充区域保持不变。`size` prop 控制渲染尺寸;仅当图稿没有 16px 来源时,原数字后缀才作为默认尺寸保留。 + +`LinkIconRegular` 与 `LinkIconMedium`、`ReferenceIconRegular` 与 `ReferenceIconMedium`,以及三组 `PermissionIcon*Regular`/`PermissionIcon*Medium` 遵循同一规则。仓库内消费方使用具名线重,不再使用带数字的导出名。 + +Medium 线重只用于明确强调的场景:新建会话控件、设置入口与导航图标、外观选项、输入框加号按钮,以及可点击产物链接的图标。其他既有消费方使用 Regular。编辑器加号菜单中的权限命令使用 `PermissionIconFullAccessRegular`,因为该行表示权限提升,而不是当前权限模式。 + +## 考虑过的替代方案 + +**在导出名中保留像素尺寸。** 否决,因为后缀重复了运行时 `size` prop,并为相同几何创建了多个名称。 + +**只导出一个带 `weight` prop 的组件。** 否决,因为调用点会把线重选择隐藏在 prop 中;显式组件名便于搜索和审查视觉强调。 + +**在消费方 CSS 中覆盖描边宽度。** 否决,因为源 SVG 元素可能自带描边宽度,本地覆盖会在图标库外重新产生不一致线重。 + +## 后果 + +图标 API 在预稳定阶段有意进行破坏性调整:每个消费方明确选择 `Regular` 或 `Medium`;原 14px 调用点与 16px 导出共享几何时会显式传入尺寸。新增产品图形从同一份几何定义同时提供两种线重。仅填充的图稿也为 API 一致性导出两个名称,即使线重不会改变其外观。上述层级选择继续由对应渲染点显式声明,不会变成全局尺寸或透明度覆盖。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 1ef5c7c433..e678639894 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: fd2ff912a83f2535e33472e2b5755b9d059ba674 -2026-07-27-web-subagent-conversations.zh.md: 0124ce4143d65fe79dc0f5c66a42058984f5182e +2026-07-27-web-subagent-conversations.md: aacb52eca5680534b74abd42ca07c2b568c23e21 +2026-07-27-web-subagent-conversations.zh.md: 30c41f03703004e3175cee8fad9d11e0cf93f06a diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index fd2ff912a8..aacb52eca5 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -39,7 +39,7 @@ The Figma [subagent list](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5J8/Ha ## Product contract -The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies its blue activity indicator and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. With neither present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. +The descendant-count control is absent only when a complete empty direct-catalog response agrees with the session-summary projection that no subagent descendants are known. On an ordinary session, a slash separates the current title from that count control. Every subagent breadcrumb combines its compact 12px title with a fixed double chevron: the current breadcrumb uses primary color and weight 500, while ancestors use tertiary color and weight 400. Hovering a combined control for 150ms opens its direct-parent catalog, with a short crossing grace for the portaled menu; ArrowDown remains the keyboard entry path. Clicking an ancestor cancels pending hover, closes an open catalog, and only navigates upward. Each direct-parent catalog enables sibling switching and bolds its selected row; its label overrides the session-summary title, and every missing switcher catalog refreshes automatically. Only the current subagent appends its own descendant-count control. Long titles truncate before the fixed chevron. The count trigger counts every known session-summary descendant reached through an uninterrupted `origin: 'subagent'` lineage, stops at ordinary forks, and shows ongoing activity when any counted descendant is running. Because ordinary sidebar rows hide subagent-origin sessions, the Workspace browser indexes the same uninterrupted lineage onto each visible ordinary row: any running descendant supplies the shared tertiary-grey ongoing loader and exact count in hover and assistive text without describing an idle parent as running. An ordinary fork starts a separate aggregation subtree. Pending interaction outranks parent running; either remains primary while descendant activity becomes a second hover and assistive status. An ordinary Workspace row with a pending approval, plan review, or question replaces its relative time with the compact label `Approval`, `Plan review`, or `Answer`; hover and assistive details retain the full status and relative time. With neither primary state present, descendant activity outranks an unviewed completion reminder, which returns after the last running descendant stops. Every healthy direct-catalog row carries a read-time `hasChildren` hint derived only from direct lineage headers with durable `origin: 'subagent'`; normal healthy and diagnostic subagent candidates carry that marker, while ordinary forks do not. This lookahead reads no descendant event log, and the descriptor-backed catalog loaded after disclosure remains authoritative. When summaries establish descendants before that catalog exists or after a stale empty response, the action stays visible and exposes only disabled loading rows until opening it refreshes the catalog; summary-only rows never grant navigation. The UI omits disclosure for a known leaf before interaction; the hint does not promise that the child will remain a leaf. While an expanded direct catalog is loading, known lineage reserves one disabled loading row per direct descendant without recursively fetching descendant catalogs. The tree then presents continuable and one-shot rows, falling back to the session id when an optional one-shot label is absent. Corrupt, unsupported, and unavailable candidates remain visible as disabled diagnostic rows. `running` means the exact child Agent driver is draining work at the Host sampling boundary; `inactive` means that driver is idle or absent. The UI does not translate either value into success, failure, cancellation, completeness, or resumability. `subagent.list` supplies the current driver-status baseline, `host/session-status` updates known activity in place, request-local replay prevents an older in-flight list response from overwriting a newer transition, and `host/session-removed` returns a known row to `inactive`; reconnect reads a fresh baseline. A `host/session-added` frame for a direct subagent immediately flips any loaded parent row to `hasChildren: true`, and that positive hint survives an older in-flight catalog response; membership, labels, mode, diagnostics, and the authoritative snapshot still require a debounced `subagent.list` refresh while the affected branch is open. A prompt response remains delivery-time authority. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0124ce4143..30c41f0370 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -39,7 +39,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5 ## 产品约定 -只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示蓝色活动指示器,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 +只有当完整的直接目录空响应与会话摘要投影相符,二者均表明没有已知的 subagent 后代时,才不显示后代数量控件。普通会话会用斜杠分隔当前 title 和该数量控件。每一级 subagent 面包屑都将紧凑的 12px title 与固定显示的双向箭头组合成一个控件:当前面包屑使用主标签颜色和 500 字重,祖先则使用三级标签颜色和 400 字重。悬停组合控件 150ms 后会打开其直接 parent 目录;浮层菜单具有短暂的跨越宽限时间,ArrowDown 保留为键盘入口。点击祖先会取消待触发的悬停、关闭已打开的目录,并且只向上导航。每个直接 parent 目录都可切换 sibling,并会加粗其选中行;目录 label 优先于会话摘要 title,缺失的每个切换器目录都会自动刷新。只有当前 subagent 会追加自己的后代数量控件。过长的 title 会在固定箭头之前截断。数量触发器会统计经不间断的 `origin: 'subagent'` 谱系可达的每个已知会话摘要后代,在普通 fork 处停止,并在任一计入统计的后代处于 `running` 时显示活动仍在进行。由于普通侧边栏行会隐藏 origin 为 subagent 的会话,Workspace 浏览器会在每个可见的普通行上索引同一条不间断谱系:任何运行中的后代都会让该行显示共享的三级灰色 ongoing loading,并在悬停与无障碍文本中给出确切数量,同时不会把空闲 parent 描述为正在运行。普通 fork 会开启单独的聚合子树。待处理交互优先于 parent 的运行中状态;二者无论哪一项存在都会保持为主要状态,而后代活动则成为悬停与无障碍状态中的第二项。普通 Workspace 行存在待处理的审批、计划审阅或问题时,会用紧凑的「待批准」「计划待审」或「待回答」替换相对时间;悬停与无障碍详情仍保留完整状态和相对时间。两者均不存在时,后代活动优先于未查看的完成提醒;最后一个运行中的后代停止后,该提醒会恢复。每个健康的直接目录行都携带读取时的 `hasChildren` 提示,该值只根据持久化 `origin: 'subagent'` 的直接谱系 header 派生;正常的健康与 diagnostic subagent 候选都会携带该标记,而普通 fork 不会。该预查不读取任何后代事件日志,展开后仍以描述符支撑的目录为权威依据。当摘要在该目录尚不存在时或在一次陈旧的空响应后确认已有后代时,该操作会保持可见,并且在打开它以刷新目录之前仅显示禁用的加载行;仅由摘要支撑的行绝不会提供导航能力。UI 会在交互前就省略已知叶子节点的展开控件;该提示不承诺 child 会一直是叶子。已展开的直接目录加载期间,已知谱系会为每个直接后代预留一行禁用的加载行,而不会递归获取后代目录。随后树会呈现可继续与 one-shot 行;one-shot 的可选 label 缺失时,回退到其会话 id。损坏、不受支持或不可用的候选仍以禁用的 diagnostic 行显示。 `running` 表示在 Host 采样边界,确切 child Agent driver 正在处理工作;`inactive` 表示该 driver 空闲或不存在。UI 不会把任一值解释为成功、失败、取消、完成状态或可恢复性。`subagent.list` 提供当前 driver 状态基线,`host/session-status` 会就地更新已知活动状态,请求内回放会阻止更早发起但尚未完成的列表响应覆盖较新的状态转换,`host/session-removed` 则会使已知行恢复为 `inactive`;重连时会读取新的基线。直接 subagent 的 `host/session-added` 帧会立即把任何已加载的 parent 行翻转为 `hasChildren: true`,并使这项正向提示不被更早发起但尚未完成的目录响应覆盖;受影响分支打开期间,成员、label、mode、diagnostic 与权威快照仍需要通过去抖动的 `subagent.list` 刷新来更新。消息投递时仍以提示词响应为权威依据。 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml index 6009b3c1ad..11220477c5 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md -2026-09-04-web-clickable-link-styles.md: c4e506294c87c6b959cb449403edce09daa11062 -2026-09-04-web-clickable-link-styles.zh.md: 01258674b4b8194ad5e0597cde61cc2186406d49 +2026-09-04-web-clickable-link-styles.md: eddceb8af8b39b24ebf85870b893ecd4d7939c96 +2026-09-04-web-clickable-link-styles.zh.md: dbb2c9ac34605e45ef0f404628ac710384983b57 diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md index c4e506294c..eddceb8af8 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md @@ -13,13 +13,13 @@ Clickable artifact links in the chat transcript wore four different costumes: ma One link language across the transcript's clickable-link surfaces — markdown anchors (including reference links, mailto, and URL-promoted inline code), prose file mentions, web search source links and the fetch URL, produced-file chips, and workflow member links: - Color comes through a dedicated `--dsw-alias-link` alias in `design-platform.css` (light `deepseek-500`, dark `deepseek-400`), decoupled from `state-business-primary`; links render at `font-weight: 500` with no underline at rest and `underline dotted` at 3px offset on hover/focus. -- A leading category glyph — the new `LinkIcon` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, and code, web, and data extensions share the code glyph by design. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. +- A leading category glyph — `LinkIconMedium` in ui-primitives with kinds `url`, `folder`, `code`, `image`, `document`, and `other` (paper) — renders `currentColor` only; the `url` glyph is the globe, or the destination site's own mark when its host is a known site ([known-site link marks](2026-09-16-known-site-link-marks.md)); `classifyLinkPath` folds the [shared detailed file-type classification](2026-09-08-shared-file-type-icons.md) into those six link categories, code and web extensions share the code glyph, and spreadsheet extensions use the document glyph. Two anchor shapes carry no glyph: workflow member links (an in-app member view fits no file or URL category) and anchors wrapping only images (a badge or thumbnail — a dangling globe beside the picture leads no text). Inline glyphs sit at 1.1em with a −0.25em baseline offset; the flex-centered produced-file glyphs instead nudge 1.2px down because the 22px text box carries its glyphs below box center. - Produced-file chips drop the grey pill and the 96px cap: plain link-blue text at natural width that shrinks with ellipsis only when the row overflows; the container-query bands still budget 96px per chip when choosing how many chips to show. - [Compact Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.md) retains tertiary text color and a resting dotted underline to preserve secondary emphasis. - Deliberately untouched: ToolRow's grey dotted file links, and the grey "Show in folder" action (it gains the folder glyph but keeps its grey style). - In the same pass, the inline-code chip tint moved from `neutral-bluish-100` to `neutral-50` (dark: `neutral-800`) and gained a 0.5px l1 border. -Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. +Coverage: a link-icon unit spec (one distinct glyph per kind, both stroke weights, known-site marks, and the classification table), refreshed markdown-dom fixtures, and the `clickable-links-gallery` web e2e — one settled keyless turn rendering every clickable link form — registered in the host compiler face (`tsconfig.host.json`) like its other scaffold-importing siblings. ## Alternatives considered @@ -30,6 +30,6 @@ Coverage: a LinkIcon unit spec (one distinct glyph per kind, classification tabl ## Consequences -- A new clickable artifact surface should consume `--dsw-alias-link` and the LinkIcon vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). +- A new clickable artifact surface should consume `--dsw-alias-link` and the `LinkIconMedium` vocabulary rather than introduce another color or underline form; the rule lives in [docs/web-styling.md](../../../../docs/web-styling.md). - Long produced-file names take their natural width; when a row overflows, flex shrinks all chips proportionally, so several long names shrink together instead of the last one yielding first. - mailto links currently share the `url` globe; a distinct mail category is a one-line addition if ever wanted. diff --git a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md index 01258674b4..dbb2c9ac34 100644 --- a/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md @@ -13,13 +13,13 @@ Status: implemented 会话记录的可点击链接表面——Markdown 锚点(含引用式链接、mailto、被提升为链接的 inline code)、正文文件引用、网页搜索来源链接与抓取 URL、产物 chips、workflow 成员链接——统一为一套链接语言: - 颜色经由 `design-platform.css` 中专用的 `--dsw-alias-link` 别名(亮色 `deepseek-500`,暗色 `deepseek-400`),与 `state-business-primary` 解耦;链接以 `font-weight: 500` 呈现,默认无下划线,hover/focus 时为 3px offset 的 `underline dotted`。 -- 前置分类图标——ui-primitives 新增的 `LinkIcon`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码、网页、数据扩展名按设计共用 code 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 +- 前置分类图标——ui-primitives 的 `LinkIconMedium`,kind 为 `url`、`folder`、`code`、`image`、`document`、`other`(纸张)——只渲染 `currentColor`;`url` 图形为地球,目的地主机属于已知站点时则为该站点自己的标记([已知站点链接标记](2026-09-16-known-site-link-marks.zh.md));`classifyLinkPath` 把[共享精细文件类型分类](2026-09-08-shared-file-type-icons.zh.md)折叠进这六种链接类别,代码和网页扩展名共用 code 图形,表格扩展名使用 document 图形。两类锚点不带图标:workflow 成员链接(应用内成员视图不属于任何文件或 URL 类别)和只包图片的锚点(徽章或缩略图——图片旁悬着的地球没有可引导的文字)。行内图标为 1.1em、基线偏移 −0.25em;flex 居中的产物图标则下移 1.2px,因为 22px 文字盒的字形低于盒中心。 - 产物 chips 去掉灰色药丸和 96px 上限:纯链接蓝文字按自然宽度展示,仅当整行溢出时才收缩出省略号;容器查询档位在决定展示几个 chip 时仍按每个 96px 预算。 - [紧凑 Thinking Markdown](../bug-fix/2026-09-17-thinking-markdown.zh.md) 保留 tertiary 文字色和默认点状下划线,以保持次级强调。 - 刻意不动:ToolRow 的灰色点线文件链接,以及灰色的「在文件夹中显示」操作(它获得文件夹图标但保持灰色样式)。 - 同一批次中,inline code 底色从 `neutral-bluish-100` 换到 `neutral-50`(暗色:`neutral-800`),并新增 0.5px l1 描边。 -覆盖:LinkIcon 单测(每个 kind 一个独立图形、分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 +覆盖:link-icon 单测(每个 kind 一个独立图形、两种描边字重、已知站点标记和分类表)、刷新后的 markdown-dom 夹具,以及 `clickable-links-gallery` web e2e——一个 settled 的 keyless 回合渲染全部可点击链接形态——像其他引 scaffold 的同类一样注册进 host 编译面(`tsconfig.host.json`)。 ## 备选方案 @@ -30,6 +30,6 @@ Status: implemented ## 后果 -- 新的可点击产物表面应消费 `--dsw-alias-link` 和 LinkIcon 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 +- 新的可点击产物表面应消费 `--dsw-alias-link` 和 `LinkIconMedium` 词汇,而不是引入另一种颜色或下划线形态;规则记录在 [docs/web-styling.md](../../../../docs/web-styling.zh.md)。 - 长产物文件名按自然宽度展示;整行溢出时 flex 按比例收缩所有 chip,几个长名字一起收缩,而不是最后一个先让位。 - mailto 链接目前共用 `url` 地球图形;若将来需要独立的邮件类别,一行即可加上。 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml index a84c4d396a..44e214ff4c 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md -2026-09-07-composer-session-stats-pills.md: 22f78dfdde854baaf1ced8c20c55042952df8c26 -2026-09-07-composer-session-stats-pills.zh.md: 6b71c6e7cf03cbd32fa4da9edf38f6aa4814310c +2026-09-07-composer-session-stats-pills.md: 49976439e6c30603423278a86dba80c3c48eb39b +2026-09-07-composer-session-stats-pills.zh.md: 09b956c6607ebebb10a525161090f434b735d233 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md index 22f78dfdde..49976439e6 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md @@ -12,7 +12,8 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co `StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed. -- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutline16`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission. +- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [Guide start page and stat pill refinements](2026-09-10-guide-start-page-and-stat-pill-refinements.md) owns the zero cache-write omission. +- **Shared information tier.** Composer stats and per-turn tail metadata use the secondary font size minus one pixel. Assistant-tail metadata uses the action icons' tertiary label color and starts after an extra 8px separation from the action cluster. - **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy remains owned by ui-conversation's `ContextMeter`, with its ring and percentage after the two stats pills below the input card. The common dock groups statistics together and leaves the toolbar for input actions; ui-chat does not import the context component. The context panel renders through a portal and uses ui-primitives for viewport-clamped positioning and outside-pointer dismissal, including when the statistics contribution is absent. - **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing. - **The composer owns dock spacing.** `InputBar` places slot contributions and `ContextMeter` in one centered flex row, supplies 4px above the dock and 4px below it even when the slot has no visible contribution. The hero keeps no bottom padding and hides its empty dock. `StatsPills` supplies shrinkable time and billing content; it does not claim the full row width or add outer padding. diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md index 6b71c6e7cf..09b956c660 100644 --- a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md @@ -12,7 +12,8 @@ Status: implemented `StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。 -- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。 +- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[引导起始页与统计 pill 的细化](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)负责缓存写入为零时省略该行的规则。 +- **统一信息层级。** 输入框底部统计与单轮回答末尾信息都使用次级字号减 1px。回答末尾信息使用与操作图标一致的 tertiary 文字颜色,并与操作图标组额外拉开 8px。 - **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍归 ui-conversation 的 `ContextMeter` 所有,在输入卡片下方、两个统计 pill 之后显示圆环和百分比。共用 dock 将统计信息放在一起,工具栏保留给输入操作;ui-chat 不导入上下文组件。上下文面板通过 portal 渲染,复用 ui-primitives 的视口内定位与外部指针关闭工具,统计贡献项缺席时也不会越界。 - **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。 - **输入框负责 dock 间距。** `InputBar` 将 slot 贡献项和 `ContextMeter` 放在同一个居中的 flex 行中,在 dock 上下各提供 4px 留白,即使 slot 没有可见贡献项也保持该间距。hero 保持无底部留白,并隐藏空 dock。`StatsPills` 提供可收缩的时间与计费内容,不占满整行宽度,也不添加外部 padding。 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml index a42e0ee0bd..93a617d08b 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md -2026-09-08-composer-menu-sections-and-localized-rows.md: 3cccb3c038ae4c358860f09533e966e9121b987e -2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 31c879b17ac6788bfade50a3245903c3e2e56224 +2026-09-08-composer-menu-sections-and-localized-rows.md: 50b97ce2c7e59d53bf5607fef74a799a359d27c8 +2026-09-08-composer-menu-sections-and-localized-rows.zh.md: 92bcb04bfe9b4f7b5581c80d7c0b843f98096e52 diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md index 3cccb3c038..50b97ce2c7 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.md @@ -18,9 +18,9 @@ A menu pick fills the locale's claim token: under Chinese, picking Plan fills `/ The File row is an `action` contribution: a bare invocation consumes the trigger token and runs a client callback without submitting a message. Conversation owns that registration, its live availability, and the hidden file input. The menu replaces the separate paperclip button; the `+` button's accessible name and tooltip read "Add files or run commands". -`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. +`ui-input-trigger` renders the new row anatomy: `InputTriggerCandidate.label` is the title and a second search key of the shared `rankByName`, the name renders as a trailing alias when the label differs from it, `icon` accepts an icon component beside the reference glyph tokens, and the description is right-aligned. Aliases and descriptions use a font one pixel below the primary labels. `ui-primitives` gains the Plan glyph from the design doc, a static ring for Compact, and the permission shield contour. -The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 10 px draggable WebKit rail around a 4 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. +The menu uses a 400 px border-box height cap, which fits both headings and the eight built-in rows before the viewport clamp reduces it. A real overflow keeps a 6 px draggable WebKit rail around a 2 px visible thumb, insets the track from the rounded ends, and shows a bottom fade until the viewport reaches the final row; Firefox keeps its standard thin scrollbar. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md index 31c879b17a..92bcb04bfe 100644 --- a/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-composer-menu-sections-and-localized-rows.zh.md @@ -18,9 +18,9 @@ composer 的 `+` 按钮与键入的 `/` 按宿主注册顺序把每个命令列 「文件」行是一个 `action` 贡献项:裸调用消费触发 token 后运行客户端回调,不提交消息。Conversation 负责该注册、实时可用性和隐藏文件输入框。菜单取代单独的回形针按钮,`+` 按钮的无障碍名称与提示为「添加文件或调用指令」。 -`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 +`ui-input-trigger` 渲染新的行结构:`InputTriggerCandidate.label` 是标题,也是共享 `rankByName` 的第二个搜索键;label 与名字不同时名字渲染为尾随别名;`icon` 在引用图标 token 之外接受图标组件;说明右对齐。别名与说明使用比主标签小 1px 的字号。`ui-primitives` 新增设计稿给出的计划图标、压缩用的静态环形,以及权限盾形轮廓。 -菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 10 px 的可拖动区域承载 4 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 +菜单采用 400 px 的 border-box 高度上限,在视口限制缩小高度前可容纳两个小节标题与八个内置入口。内容确实溢出时,WebKit 系浏览器用 6 px 的可拖动区域承载 2 px 的可见滑块,轨道避开圆角两端,视口抵达最后一行前显示底部渐隐;Firefox 保留标准细滚动条。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml index bf3876efca..8b9255eec8 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md -2026-09-08-shared-file-type-icons.md: 1e3fe7267961a8525bbcdf946eacaa933bcf3e4f -2026-09-08-shared-file-type-icons.zh.md: fd1f38ebb33f80c73d17cc48f6f9640c309f97f2 +2026-09-08-shared-file-type-icons.md: f0d3fbdb3a86b5f2c67a438de96348b30b27319f +2026-09-08-shared-file-type-icons.zh.md: d7542592804bc384e4ae5d7359145e70f4d98527 diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md index 1e3fe72679..f0d3fbdb3a 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.md @@ -6,25 +6,25 @@ English | [中文](2026-09-08-shared-file-type-icons.zh.md) ## Problem -Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. `LinkIcon` owned the only extension table and deliberately collapsed paths into six link categories, while attachment cards, sent-message attachments, queued files, and workspace file rows used one generic document glyph. Two of those consumers also carried separate extension-display helpers. Adding a precise file icon anywhere else would have required another extension table or a runtime import between feature plugins. +Client feature plugins can share React components only through `@deepseek-ai/dsh-client-ui-primitives`, but file cards had no shared file-type presentation. The package-owned classifier provides the only extension table and deliberately folds detailed file types into six link categories for `LinkIconMedium`, while attachment cards, sent-message attachments, queued files, and workspace file rows use detailed file glyphs. Adding a precise file icon anywhere else would require another extension table or a runtime import between feature plugins. ## Decision `ui-primitives` owns one Cordis-free file classification and rendering API. `fileExtension(path)` applies the shared basename and final-dot semantics to either path separator. `classifyFileType(path)` matches without case sensitivity and returns the closed `FileType` union: the traditional `code`, `excel`, `folder`, `html`, `image`, `markdown`, `other`, `pdf`, `ppt`, `video`, and `word` categories plus the detailed `CodeFileType` categories rendered by `CodeFileIcon`. Exact filenames, filename prefixes, filename suffixes, optional project context, and extensions run in that order. The path classifier returns every member except `folder`; callers use the explicit `FileTypeIcon` `kind` override when they know an entry is a directory. Unknown extensions, missing extensions, and trailing dots resolve to `other`, except the shared table recognizes named files such as `Dockerfile`, `Makefile`, `package.json`, `.gitignore`, `README`, and `CHANGELOG`. -`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. Excel, Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center; the remaining marked traditional glyphs use 112%, while the file body and folded corner retain their source geometry and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for Excel, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. +`FileTypeIcon` accepts a path, shared `IconProps`, the explicit `kind`, and an optional project-file snapshot. Traditional file types render the supplied 28px document and folder contours as inline SVG. The spreadsheet glyph uses the supplied unscaled 1.2px table grid; Markdown, PDF, PPT, and Word foreground marks scale to 122% around their visual center, and the remaining marked traditional glyphs use 112%. The file body and folded corner retain their source geometry, and the generic file has no invented center mark. The sheet is a solid category color, the foreground mark and ordinary folded corner are white, and the generic file has a darker grey corner. CSS assigns the supplied category palette through static design tokens: DeepSeek blue for code/HTML/Markdown, the lighter DeepSeek blue for Word, green for spreadsheets, two amber steps for folder/PPT, red for PDF, and neutral grey for unknown files. Image and video share the supplied violet through a component-local variable because the design platform has no matching violet token. A caller may override a traditional sheet through `--dsh-file-type-icon-color`. -Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV and TSV use the code glyph in file cards, rows, and preview titles; their clickable links also use code. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. +Recognized code and configuration files render the corresponding 20px square artwork scaled to the requested icon size. The embedded static table contains exactly the 48 established `CodeFileType` entries; archive-only artwork does not add a category, and an adjacent manifest records the responsible design owner and source digests. Tests reject scripts, event attributes, external references, and duplicate ids in that table. `CodeFileIcon` replaces local SVG ids with a per-component prefix before insertion so repeated gradients and clip paths remain independent. These technology marks retain their embedded multicolor fills and are the explicit exception to the ordinary current-color icon rule. The map selects React before TypeScript/JavaScript, Angular filename suffixes before their base extension, Docker/Node/Git/Make/CMake by filename rules, and Flutter only when the optional project snapshot contains a `pubspec.yaml` whose text includes `flutter:`. Markdown and SVG remain owned by the traditional Markdown and image categories. CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers use the spreadsheet glyph in file cards, rows, and preview titles; their clickable links use the document category. Both `.env` and names ending in `.env` use the environment glyph. Every traditional and technology SVG is `aria-hidden`, and the card, row, or button that owns the file identity supplies the accessible name. -`LinkIcon` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. +`LinkIconMedium` delegates extension classification to `classifyFileType` and folds the detailed result into its existing link vocabulary: code and HTML use `code`, images use `image`, PDF/Word/Excel/PPT use `document`, and Markdown/video/unknown files use `other`. Extensionless names remain `other` in link contexts, so the 14px clickable-link appearance defined by the [clickable-link decision](2026-09-04-web-clickable-link-styles.md) does not change. -Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIcon` because they are link surfaces. +Attachment upload cards, sent-message file cards, queued-file rows, and workspace file rows render `FileTypeIcon`. The Files tab title renders its explicit `folder` kind at 16px. Explicit delivery cards use `FileTypeIcon` at 20px and `fileExtension` for their fallback metadata. The two metadata rows use `fileExtension` rather than local parsers; a leading-dot basename such as `.env` therefore displays `ENV`, while an absent or trailing suffix displays no extension label. Image content continues to render as a preview rather than a file-type glyph, and produced-file links and Markdown file mentions continue to use `LinkIconMedium` because they are link surfaces. ## Alternatives considered -**Use `LinkIcon` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. +**Use `LinkIconMedium` for every file surface.** Rejected. Its six categories and 14px outline drawings communicate link destinations at text size; a 28px file card has room for the supplied HTML, Markdown, PDF, Word, Excel, PPT, and video identities. -**Keep a second extension table beside `LinkIcon`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. +**Keep a second extension table beside `LinkIconMedium`.** Rejected. The same path could drift to different categories as either list grows. One detailed table plus an explicit detailed-to-link adapter preserves both consumers' semantics. **Put the supplied traditional-file colors directly in each SVG path.** Rejected. Traditional SVG geometry stays reusable and follows the icon set's `currentColor` rule; the component stylesheet owns the default category palette, and render sites retain one CSS-variable override instead of rewriting path fills. The technology artwork is exempt because its embedded multicolor marks identify the language or tool rather than decorating a generic file silhouette. @@ -32,7 +32,7 @@ Attachment upload cards, sent-message file cards, queued-file rows, and workspac ## Testing -The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The existing `LinkIcon` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. +The `ui-primitives` specs cover case-insensitive suffixes, both path separators, leading-dot files, missing and trailing suffixes, common named files, unknown fallback, all detailed code mappings, rule priority, Flutter context, the exact 48-key artwork set, rejected archive-only categories, static-markup safety, instance-safe SVG ids and references, `aria-hidden`, sizing/class forwarding, distinct artwork, the supplied spreadsheet grid, the 112% and 122% foreground-mark transforms, and the traditional solid-sheet/contrast-mark layers without literal SVG colors. A stylesheet spec pins every traditional category-to-color mapping, the caller override, and the local violet value. The `LinkIconMedium` classification table pins its coarse output, including `Makefile` remaining `other`. Attachment, chat, queue, and sidebar component suites exercise the migrated render paths; their accessibility output does not change because the glyphs remain decorative. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md index fd1f38ebb3..d754259280 100644 --- a/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-shared-file-type-icons.zh.md @@ -6,25 +6,25 @@ Status: implemented ## 问题 -客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。`LinkIcon` 拥有唯一一份扩展名表,并有意把路径折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行则统一使用一个通用文档图形。其中两个消费方还各自带着一份扩展名展示辅助函数。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 +客户端功能插件只能通过 `@deepseek-ai/dsh-client-ui-primitives` 共享 React 组件,但文件卡片没有共享的文件类型呈现。本包拥有唯一一份扩展名分类表,并为 `LinkIconMedium` 将细分文件类型有意折叠为六种链接类别;附件卡片、已发送消息附件、排队文件和工作区文件行使用细分文件图形。在其他位置增加精细文件图标,需要再复制一份扩展名表或让功能插件之间产生运行时 import。 ## 决策 `ui-primitives` 统一拥有一套 Cordis-free 的文件分类与渲染 API。`fileExtension(path)` 对两种路径分隔符采用共享的 basename 与最终点号语义。`classifyFileType(path)` 不区分大小写,并返回闭合的 `FileType` 联合:传统的 `code`、`excel`、`folder`、`html`、`image`、`markdown`、`other`、`pdf`、`ppt`、`video`、`word` 类别,以及由 `CodeFileIcon` 渲染的细分 `CodeFileType` 集合。解析按完整文件名、文件名前缀、文件名后缀、可选项目上下文、扩展名的顺序执行。路径分类器返回 `folder` 之外的全部成员;调用方确认条目是目录时,通过 `FileTypeIcon` 的显式 `kind` 覆盖指定目录。未知扩展名、无扩展名和末尾点号回退到 `other`,但共享表识别 `Dockerfile`、`Makefile`、`package.json`、`.gitignore`、`README`、`CHANGELOG` 等具名文件。 -`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。Excel、Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%;文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,Excel 使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 +`FileTypeIcon` 接受路径、共享 `IconProps`、显式 `kind`和可选的项目文件快照。传统文件类型把所提供的 28px 文档与文件夹轮廓渲染为 inline SVG。表格图形使用所提供的未缩放 1.2px 表格网格;Markdown、PDF、PPT、Word 的前景标记围绕自身视觉中心缩放至 122%,其余带标记的传统图形使用 112%。文件底板与折角保持源图几何,通用文件不凭空增加中心标记。底板使用实色分类颜色,前景标记与普通折角使用白色,通用文件使用较深的灰色折角。CSS 通过静态设计 token 分配所提供的分类调色板:code/HTML/Markdown 使用 DeepSeek 蓝,Word 使用较浅的 DeepSeek 蓝,表格使用绿色,folder/PPT 使用两档琥珀色,PDF 使用红色,未知文件使用中性灰。image 与 video 通过组件本地变量共用所提供的紫色,因为设计平台没有匹配的紫色 token。调用方可通过 `--dsh-file-type-icon-color` 覆盖传统底板颜色。 -已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV 和 TSV 在文件卡片、文件行及预览标题中使用 code 图标,其可点击链接也使用 code。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 +已识别的代码与配置文件把对应的 20px 方形图稿缩放到请求的图标尺寸。内嵌静态表只包含现有 48 个 `CodeFileType` 条目;资源包中额外的图稿不会新增类别,相邻 manifest 记录负责的设计归属方与来源摘要。测试会拒绝该表中的脚本、事件属性、外部引用与重复 id。`CodeFileIcon` 在插入前为本地 SVG id 加上组件实例前缀,使重复渐变与裁剪路径互不干扰。这些技术标记保留自身内嵌的多色填充,是普通 current-color 图标规则的明确例外。映射让 React 优先于 TypeScript/JavaScript、Angular 文件名后缀优先于基础扩展名,并按文件名识别 Docker/Node/Git/Make/CMake;只有可选项目快照包含内容带 `flutter:` 的 `pubspec.yaml` 时才选择 Flutter。Markdown 与 SVG 仍由传统 Markdown 和图片类别拥有。CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers 在文件卡片、文件行及预览标题中使用表格图标,其可点击链接使用文档类别。`.env` 和以 `.env` 结尾的文件名均使用环境配置图标。所有传统与技术 SVG 都是 `aria-hidden` 的,拥有文件身份的卡片、行或按钮提供无障碍名称。 -`LinkIcon` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 +`LinkIconMedium` 委托 `classifyFileType` 做扩展名分类,再把精细结果折叠进原有链接词汇:code 与 HTML 使用 `code`,图片使用 `image`,PDF/Word/Excel/PPT 使用 `document`,Markdown、video 与未知文件使用 `other`。无扩展名文件在链接语境中仍是 `other`,因此[可点击链接决策](2026-09-04-web-clickable-link-styles.zh.md)定义的 14px 外观不变。 -附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行使用 `fileExtension`,不再保留本地解析器;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIcon`,因为它们属于链接表面。 +附件上传卡片、已发送消息文件卡片、排队文件行和工作区文件行渲染 `FileTypeIcon`。Files 标签页标题使用显式的 `folder` 类别,尺寸为 16px。显式交付卡片使用 20px 的 `FileTypeIcon`,并通过 `fileExtension` 提供默认元数据。两处元数据行统一使用 `fileExtension`;`.env` 这样的前导点 basename 会显示 `ENV`,无后缀或末尾点号则不显示扩展名 label。图片内容继续渲染为预览而不是文件类型图形,产物文件链接与 Markdown 文件提及继续使用 `LinkIconMedium`,因为它们属于链接表面。 ## 备选方案 -**所有文件表面都使用 `LinkIcon`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 +**所有文件表面都使用 `LinkIconMedium`。** 否决。它的六种类别和 14px outline 图形用于在文字尺寸下表达链接目标;28px 文件卡片有空间表达所提供的 HTML、Markdown、PDF、Word、Excel、PPT 与 video 身份。 -**在 `LinkIcon` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 +**在 `LinkIconMedium` 旁保留第二份扩展名表。** 否决。任一列表增长后,同一路径可能漂移到不同类别。一份精细表加一个显式的精细到链接适配,能同时保住两类消费方的语义。 **把所提供的传统文件颜色直接写入每一条 SVG path。** 否决。传统 SVG 几何保持可复用,并遵守图标集的 `currentColor` 规则;组件样式表拥有默认分类调色板,渲染点只需一个 CSS 变量即可覆盖颜色,不需要重写 path 填充。技术图稿是例外,因为其内嵌的多色标记用于识别语言或工具,而不是装饰通用文件轮廓。 @@ -32,7 +32,7 @@ Status: implemented ## 测试 -`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。既有 `LinkIcon` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 +`ui-primitives` 测试覆盖不区分大小写的后缀、两种路径分隔符、前导点文件、无后缀与末尾点号、常见具名文件、未知回退、全部细分代码映射、规则优先级、Flutter 上下文、精确的 48 键图稿集、被拒绝的资源包额外类别、静态 markup 安全性、实例安全的 SVG id 与引用、`aria-hidden`、尺寸/class 转发、不同图稿、所提供的表格网格、112% 与 122% 前景标记变换,以及不含 SVG 字面颜色的传统实色底板/对比标记层。样式表测试钉住每一项传统类别到颜色的映射、调用方覆盖变量与本地紫色值。`LinkIconMedium` 分类表钉住它的粗粒度输出,包括 `Makefile` 仍为 `other`。附件、聊天、队列和侧边栏组件测试覆盖迁移后的渲染路径;由于图形仍是装饰性的,其无障碍输出不变。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml index 7d4eb35fa1..992a5d55e9 100644 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md -2026-09-10-connection-indicator-refinements.md: 3b6b4c7fd0962edbb87be17c2fa45eded46ee86b -2026-09-10-connection-indicator-refinements.zh.md: 0bd5dc3e0b5b69cf2d9491e2d3ce5711c96898f5 +2026-09-10-connection-indicator-refinements.md: 7c918b6f9ee65612c627e0406230a99e20cde2af +2026-09-10-connection-indicator-refinements.zh.md: e6a3fcdec50bdd209c73679b47d4b05fde88a9a4 diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md index 3b6b4c7fd0..7c918b6f9e 100644 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.md @@ -10,7 +10,7 @@ The sidebar connection pill hid its affordance behind a hover swap: outage and r ## Decision -**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutline14`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. +**The disconnected pill shows its action statically.** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) renders a permanent retry glyph (`IconRefreshOutlineRegular`) beside the outage copy (`连接异常,刷新重试` / `Disconnected`; the Chinese copy also names the retry action); clicking the pill still reconnects immediately. The hover label swap and the hidden widest-label size-reservation spans are gone, so the pill sizes to its current label. The connecting state shows a rotating-arc spinner instead of the exclamation glyph. Appearance and removal fade over 150ms — swaps between visible states replace content in place: `EXIT_MS` delays unmount to match the stylesheet's `.leaving` transition, and `prefers-reduced-motion` disables every animation and transition. Chrome settles at 28px height, 8px horizontal padding, 4px icon gap, 13px radius, and a 1px border of the label color at 20% alpha. **The shell owns attempt pacing.** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) keeps the connecting pill visible for at least `CONNECTING_MIN_VISIBLE_MS` (800ms) so sub-second retries do not flicker; every attempt, manual or automatic, reads the one label `重新连接中` (`connection.connecting`). The two-second recovery confirmation (`RECOVERY_CONFIRMATION_MS`) starts when the recovered pill becomes visible, so a hold that delays its appearance never shortens the confirmation. Both timings are built-in presentation constants of their owners, not configuration. diff --git a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md index 0bd5dc3e0b..e6a3fcdec5 100644 --- a/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md +++ b/.agents/notes/implemented/feature/2026-09-10-connection-indicator-refinements.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutline14`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 +**断连药丸静态地展示其动作。** [ConnectionIndicator.tsx](../../../../packages/client/ui-primitives/src/ConnectionIndicator.tsx) 在断连文案旁常驻渲染重试图形(`IconRefreshOutlineRegular`),文案为 `连接异常,刷新重试` / `Disconnected`(中文文案同时点明重试动作);点击药丸仍会立即重连。悬停换文案和隐藏的最宽 label 占位 span 全部移除,药丸宽度随当前 label 自适应。连接中状态改用旋转圆弧 spinner 取代感叹号图形。出现与移除以 150ms 淡入淡出——可见状态之间的切换则原地替换内容:`EXIT_MS` 延迟卸载以匹配样式表的 `.leaving` 过渡,`prefers-reduced-motion` 会禁用全部动画与过渡。外观定为高 28px、水平内边距 8px、图标间距 4px、圆角 13px,以及 label 颜色 20% 透明度的 1px 边框。 **外壳拥有尝试节奏。** [SettingsRoot.tsx](../../../../packages/client/ui-settings-general/src/client/SettingsRoot.tsx) 让连接中药丸至少可见 `CONNECTING_MIN_VISIBLE_MS`(800ms),亚秒级重试不再闪动;无论手动还是自动,每次尝试都显示同一个文案`重新连接中`(`connection.connecting`)。2 秒恢复确认(`RECOVERY_CONFIRMATION_MS`)从恢复药丸实际可见时起算,驻留推迟其出现也不会缩短确认时长。两个时长是各自持有方的内置展示常量,不是配置。 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml index 513a59328a..d6e17b52fa 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md -2026-09-16-known-site-link-marks.md: 9d029af06f9ca0f3fa8290112c7023ec1b9e0b84 -2026-09-16-known-site-link-marks.zh.md: 2c7630d122d1576ba41cb653cf0b86e9c5a2ddb4 +2026-09-16-known-site-link-marks.md: 5e4bae0349f28d823c78854a435061de7872b358 +2026-09-16-known-site-link-marks.zh.md: 7b57ab0bbc4491d0db597a31f5077db07c4eb142 diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md index 9d029af06f..5e4bae0349 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md +++ b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md @@ -10,7 +10,7 @@ Every transcribed anchor led with the same globe, so a transcript full of GitHub ## Decision -`LinkIcon` takes an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site. +`LinkIconRegular` and `LinkIconMedium` take an optional `href`. For the `url` category, a destination whose host is a well-known site draws that site's own mark; every other `url` destination keeps the globe, and the file categories ignore `href` because their destination is a path, not a site. `SiteGlyph.tsx` in ui-primitives owns the mapping: forty host suffixes resolve to thirty-four marks. The developer sites a transcript usually cites: GitHub (`github.com`, `github.io`, `raw.githubusercontent.com`), GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News (`news.ycombinator.com`), YouTube (`youtube.com`, `youtu.be`), X (`x.com`, `twitter.com`), Bilibili, Zhihu, Juejin, CSDN. The mainstream sites a general audience links: search (Google, Baidu, DuckDuckGo), video and audio (TikTok, Netflix, Spotify), social and messaging (Facebook, Instagram, Reddit, Telegram with its `t.me` short links, WhatsApp with `wa.me`, WeChat via `weixin.qq.com`, QQ, Weibo), shopping (Taobao, AliExpress, eBay), reference and community (Quora, V2EX), and Apple. A host matches a suffix when it equals it or is a subdomain of it, and the longest matching suffix wins, so `gist.github.com` and `en.wikipedia.org` need no entry while `weixin.qq.com` keeps WeChat rather than the QQ mark its `qq.com` suffix would also select. Only absolute `http:` and `https:` destinations can match; anything else falls back to the globe. diff --git a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md index 2c7630d122..7b57ab0bbc 100644 --- a/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`LinkIcon` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。 +`LinkIconRegular` 与 `LinkIconMedium` 接受可选的 `href`。对于 `url` 类别,目的地主机属于已知站点时改画该站点自己的标记;其余 `url` 目的地仍使用地球,文件类别忽略 `href`,因为它们的目的地是路径而不是站点。 ui-primitives 的 `SiteGlyph.tsx` 持有该映射:四十个主机后缀解析为三十四个标记。转写内容常引用的开发者站点:GitHub(`github.com`、`github.io`、`raw.githubusercontent.com`)、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News(`news.ycombinator.com`)、YouTube(`youtube.com`、`youtu.be`)、X(`x.com`、`twitter.com`)、Bilibili、知乎、掘金与 CSDN。普通用户常访问的主流站点:搜索(Google、百度、DuckDuckGo)、视频与音频(TikTok、Netflix、Spotify)、社交与通讯(Facebook、Instagram、Reddit、Telegram 及 `t.me` 短链、WhatsApp 及 `wa.me`、`weixin.qq.com` 上的微信、QQ、微博)、购物(淘宝、速卖通、eBay)、参考资料与社区(Quora、V2EX)以及 Apple。主机等于某后缀或为其子域即匹配,且最长匹配后缀优先,因此 `gist.github.com` 与 `en.wikipedia.org` 无需单独登记,而 `weixin.qq.com` 得到微信标记而不是其 `qq.com` 后缀同样会选中的 QQ 标记。只有绝对 `http:` 与 `https:` 目的地能够匹配,其余一律回退到地球。 diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml new file mode 100644 index 0000000000..ce956d1852 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md +2026-09-17-compact-translucent-menu-surfaces.md: 8421aa6d5430400bbdf7df19c0f5ef9edee6874e +2026-09-17-compact-translucent-menu-surfaces.zh.md: 7b2d8b78ad989f3c6f6f737e182107d40d225a75 diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md new file mode 100644 index 0000000000..8421aa6d54 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md @@ -0,0 +1,29 @@ +# Agent Note: Compact translucent menu surfaces + +Status: implemented + +English | [中文](2026-09-17-compact-translucent-menu-surfaces.zh.md) + +## Problem + +Shared dropdowns and feature-owned menu panels used the same elevated color token but retained unrelated padding, row height, corner radius, and scrollbar geometry. The opaque menu fill also made nested and portalled surfaces read as solid cards rather than one elevation family, while narrowing individual menus locally would keep those differences distributed across packages. + +## Decision + +`ui-theme` defines `--dsw-specific-menu` as a translucent light or dark fill and defines `--dsw-menu-backdrop-filter` as `blur(40px) saturate(150%)`. Every elevated package surface that paints the menu fill also applies that backdrop filter, uses `border: 0`, and takes an elevation shadow with its rebindable hairline stroke. Descendant sticky rows may repaint the inherited fill without another filter. Browsers without backdrop filtering still render the theme-owned translucent fill. + +The shared `Menu` and the composer input-trigger menu use the same compact baseline: a 16px outer radius, 3px frame padding, 34px ordinary rows, 13px primary text on a 20px line, 6px icon/text gaps, and 8px row radii. Dense and compact variants reduce from that baseline instead of retaining the former ordinary geometry. The composer menu keeps its feature-owned grouping and 400px height cap; its aliases and descriptions use 12px text on an 18px line. + +The global WebKit scrollbar width is 5px. The composer menu overrides it with a 6px draggable rail and a 2px visible thumb through the existing scrollbar geometry variables; Firefox continues to use its standard thin scrollbar path. Track insets and elevated l2 thumb colors remain surface-owned rebindings. + +## Alternatives considered + +**Keep an opaque shared menu fill.** Rejected because every consumer would need a separate translucent override to share one material, recreating the theme branch outside `ui-theme`. + +**Compact only the composer command menu.** Rejected because the shared `Menu` renders the same action, settings, and navigation vocabulary; keeping its former 40px rows would preserve two densities for the same control family. + +**Use the 6px padded rail for every scrollbar.** Rejected because ordinary scroll regions need the direct 5px thumb, while the command menu needs a wider hit area around a visually quieter thumb. The existing geometry variables express that difference without another scrollbar implementation. + +## Consequences + +Elevated menu-fill consumers must pair the fill and backdrop-filter tokens in the same rule; the stylesheet gate rejects a surface that omits the filter. Shared menu geometry changes affect every `Menu` render site, while the composer menu retains its own content and interaction rules. Browser and component snapshots cover assembled structure, and theme specs pin the tokens, compact metrics, scrollbar geometry, and complete menu-filter pairing. diff --git a/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md new file mode 100644 index 0000000000..7b2d8b78ad --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 紧凑半透明菜单表面 + +Status: implemented + +[English](2026-09-17-compact-translucent-menu-surfaces.md) | 中文 + +## Problem + +共享下拉菜单与功能包自有菜单面板使用同一个高层级颜色 token,却保留了不同的内边距、行高、圆角和滚动条几何。不透明菜单填充也让嵌套与 portal 表面像彼此独立的实色卡片;如果只在各功能菜单中局部收紧尺寸,这些差异仍会分散在多个包中。 + +## Decision + +`ui-theme` 将 `--dsw-specific-menu` 定义为明暗主题各自的半透明填充,并将 `--dsw-menu-backdrop-filter` 定义为 `blur(40px) saturate(150%)`。每个绘制菜单填充的包级高层级表面同时应用该 backdrop filter、设置 `border: 0`,并使用带可重绑发丝描边的 elevation 投影。后代 sticky 行可以再次绘制继承的填充,无需重复 filter。不支持 backdrop filtering 的浏览器仍渲染主题持有的半透明填充。 + +共享 `Menu` 与 composer 输入触发菜单使用同一套紧凑基准:16px 外圆角、3px 边框内距、34px 普通行、13px 主文字配 20px 行高、6px 图文间距,以及 8px 行圆角。dense 与 compact 变体在该基准上继续缩小,不再保留旧的普通几何。composer 菜单保留其功能包持有的分组与 400px 高度上限;别名和说明使用 12px 字号与 18px 行高。 + +WebKit 系全局滚动条宽度为 5px。composer 菜单通过既有滚动条几何变量覆盖为 6px 可拖动轨道与 2px 可见滑块;Firefox 继续走标准 thin scrollbar 路径。轨道内缩与高层级 l2 滑块颜色仍由表面自行重绑。 + +## Alternatives considered + +**保留不透明的共享菜单填充。** 否决,因为每个消费方都需要单独覆盖成半透明,导致同一种材质再次在 `ui-theme` 之外分叉。 + +**只收紧 composer 指令菜单。** 否决,因为共享 `Menu` 渲染的是同一套操作、设置与导航词汇;保留原 40px 行会让同一个控件家族继续存在两种密度。 + +**所有滚动条都使用 6px 带内边距轨道。** 否决,因为普通滚动区域需要直接的 5px 滑块,而指令菜单需要在更安静的可见滑块外保留更宽点击区域。现有几何变量可以表达该差异,无需第二套滚动条实现。 + +## Consequences + +高层级菜单填充消费方必须在同一规则中同时使用填充与 backdrop-filter token;样式表门禁会拒绝漏掉 filter 的表面。共享菜单几何影响每个 `Menu` 渲染点,composer 菜单仍保留自身内容与交互规则。浏览器与组件快照覆盖装配结构,主题测试固定 token、紧凑尺寸、滚动条几何与完整的菜单 filter 配对。 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml new file mode 100644 index 0000000000..6416f116c0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md +2026-09-17-state-dot-visual-language.md: 0b87afd83cc175f21b19e8589e7644a17f27900d +2026-09-17-state-dot-visual-language.zh.md: a98cc66475779d0b3d943e0afa3e2e4145435d13 diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md new file mode 100644 index 0000000000..0b87afd83c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot solid marks and loading spinner + +Status: implemented + +English | [中文](2026-09-17-state-dot-visual-language.zh.md) + +## Problem + +`StateDot` mixed two drawing languages: settled states used a translucent halo around a solid core, while `ongoing` used an eight-cell pixel chase. + +## Decision + +`idle`, `done`, `warning`, and `error` render as one solid 6px circle inside the existing 10px layout slot, with no halo. `idle` uses the l1 scrollbar hover grey, `done` remains success green, `warning` remains amber, and `error` remains red. + +`ongoing` is the sole non-dot member. Its default edge is 14px, while solid states retain their 10px layout slot. It renders a 25%-opacity complete ring behind a grey arc using the tertiary label token. The glyph rotates continuously over 1.5 seconds while the arc grows from 12 to 24 dash units around its center and returns to 12; the arc offset is zero at both ends, so the browser does not reset a second circular movement at the loop boundary. Reduced-motion environments retain an intermediate static arc. The explicit size override, `data-state`, and `aria-hidden` behavior do not change. + +Compact status-only presentations use this shared mapping instead of local dots or spinners: waiting or blocked is `warning`, active work is `ongoing`, successful completion is `done`, failure is `error`, and inactive or not-yet-started work is `idle`. Tool rows whose leading slot is a business icon retain the ordinary glyph in every lifecycle state. The framework-free boot page remains separate because its arc reports aggregate loader progress before React and the shared primitive are available. + +## Alternatives considered + +**Keep the pixel chase.** Rejected because its block animation did not share the circular language of the settled markers and read as a decorative activity glyph rather than a conventional loading state. + +**Fill the complete 10px slot.** Rejected because the former visible core was 6px; retaining that diameter preserves row density while removing only the halo. + +## Testing + +The component specs pin the four solid-state elements, the two-circle loading artwork, both animation tracks, both size paths, the no-halo stylesheet, and every state token. Workspace, Job, Workflow, Subagent, Deliverables, terminal, plugin, Schedule, Todo, Team, approval, document-preview, Trajectory, and connection suites pin their state mappings and accessible labels. + +## Consequences + +Consumers keep their accessibility labels while status-only presentations share one visual vocabulary. Every settled marker becomes visually quieter and more compact, completion remains green, and every active marker uses the same tertiary-grey rotating loader. Action glyphs such as reconnect and business glyphs such as file or tool types remain available when the icon itself communicates the operation rather than only its state. diff --git a/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md new file mode 100644 index 0000000000..a98cc66475 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-17-state-dot-visual-language.zh.md @@ -0,0 +1,31 @@ +# Agent Note: StateDot 纯色标记与旋转 loading + +Status: implemented + +[English](2026-09-17-state-dot-visual-language.md) | 中文 + +## 问题 + +`StateDot` 混用了两套绘制语言:已结算状态在实心核外带半透明光晕,`ongoing` 则使用八格像素追逐动画。 + +## 决策 + +`idle`、`done`、`warning` 与 `error` 在既有 10px 布局槽内渲染一个 6px 纯色圆点,不再带光晕。`idle` 使用滚动条 hover-l1 灰色,`done` 保持成功绿色,`warning` 保持琥珀色,`error` 保持红色。 + +`ongoing` 是唯一不是圆点的成员。它的默认边长为 14px,纯色状态仍保留 10px 布局槽。它在透明度为 25% 的完整圆环上方,使用 tertiary label token 渲染灰色圆弧。图形以 1.5 秒周期持续旋转,圆弧围绕中心从 12 个 dash 单位增长到 24 个、再回到 12 个;圆弧偏移在首尾都为零,因此浏览器不会在循环边界重置第二段圆周运动。减少动态效果的环境保留中间长度的静态圆弧。显式 size 覆盖、`data-state` 与 `aria-hidden` 行为均不改变。 + +紧凑的纯状态展示使用这套共享映射,不再自绘圆点或 spinner:等待或阻塞为 `warning`,活动工作为 `ongoing`,成功完成为 `done`,失败为 `error`,未活动或尚未开始为 `idle`。前置槽为业务图标的工具行在所有生命周期状态中都保留普通图标。无框架的启动页保持独立,因为它会在 React 与共享原语可用前用圆弧表达 Loader 总体进度。 + +## 考虑过的替代方案 + +**保留像素追逐动画。** 否决,因为方块动画没有沿用已结算标记的圆形语言,看起来更像装饰性活动图形,而不是常规 loading 状态。 + +**填满完整 10px 槽位。** 否决,因为原来的可见实心核就是 6px;保留该直径只移除光晕,同时维持行密度。 + +## 测试 + +组件测试固定四种纯色状态元素、双圆环 loading 图稿、两条动画轨道、两条尺寸路径、无光晕样式和每个状态 token。Workspace、Job、Workflow、Subagent、Deliverables、终端、插件、Schedule、Todo、Team、审批、文档预览、Trajectory 与连接测试固定各自的状态映射和无障碍 label。 + +## 后果 + +消费方保留无障碍 label,纯状态展示共享同一套视觉词汇。所有已结算标记在视觉上更安静、更紧凑,完成态保持绿色,所有活动态使用同一种 tertiary 灰色旋转 loading。重连等动作图标和文件或工具类型等业务图标在图形本身表达操作而不只表达状态时继续保留。 diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml index 5c2380b3d6..6f8e8d20c4 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md -2026-09-17-plugin-inventory-phase-dot.md: e90d49b6a3d2d8e03f662b3de531ef180d350cad -2026-09-17-plugin-inventory-phase-dot.zh.md: d344ede75c7e0dd77a0d57720e0c1366f22e75b8 +2026-09-17-plugin-inventory-phase-dot.md: 5ba614c61a390bbd39a49c17239d0410012b325d +2026-09-17-plugin-inventory-phase-dot.zh.md: a01d172c6b6f3cb461b79ef456b37a17b9c324c8 diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md index e90d49b6a3..5ba614c61a 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.md @@ -32,4 +32,4 @@ Row enablement, tag tones, grouping, search and list behavior are unchanged, as The package spec pins the rule in both scopes: `loading` and `pending` (global) and `unloading` (preset) keep a dot, while `active`, `failed` and a row without a live fiber render none. `apps/web/tests/settings-chrome.e2e.ts` asserts the assembled global group contains enabled rows and no `role="img"` element named `运行中`. -The remaining dots' palette, halo and animation, and the tag capsule itself, stay owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md). +The remaining dots' palette and animation are owned by [the StateDot visual language decision](../feature/2026-09-17-state-dot-visual-language.md); the tag capsule remains owned by [the shared client control decision](../architecture/2026-09-05-shared-client-control-primitives.md). diff --git a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md index d344ede75c..a01d172c6b 100644 --- a/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md +++ b/.agents/notes/implemented/simplification/2026-09-17-plugin-inventory-phase-dot.zh.md @@ -32,4 +32,4 @@ Status: implemented 包级用例在两个作用域钉住规则:`loading` 与 `pending`(全局)、`unloading`(预设)保留圆点,`active`、`failed` 与没有存活 fiber 的条目都不渲染。`apps/web/tests/settings-chrome.e2e.ts` 断言装配后的全局分组含有已启用条目,且没有名为 `运行中` 的 `role="img"` 元素。 -其余圆点的配色、光晕与动画,以及标签胶囊本身,仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)拥有。 +其余圆点的配色与动画由 [StateDot 视觉语言决策](../feature/2026-09-17-state-dot-visual-language.zh.md)负责;标签胶囊仍由[共享客户端控件决策](../architecture/2026-09-05-shared-client-control-primitives.zh.md)负责。 diff --git a/apps/web/tests/access-confirmation.e2e.ts b/apps/web/tests/access-confirmation.e2e.ts index 2b2ba0e135..b1c57c60e7 100644 --- a/apps/web/tests/access-confirmation.e2e.ts +++ b/apps/web/tests/access-confirmation.e2e.ts @@ -73,7 +73,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { const currentMenuBox = await currentMenu.boundingBox() expect(currentTriggerBox).not.toBeNull() expect(currentMenuBox).not.toBeNull() - expect(Math.abs(currentMenuBox!.width - 218)).toBeLessThan(1) + expect(Math.abs(currentMenuBox!.width - 144)).toBeLessThan(1) expect(currentMenuBox!.width).toBeGreaterThan(currentTriggerBox!.width) expect(Math.abs(currentTriggerBox!.y - currentMenuBox!.y - currentMenuBox!.height - 4)).toBeLessThan(1) @@ -183,7 +183,7 @@ describe('web e2e: experimental Auto and Full access confirmation', () => { await menu.waitFor() await captureAutoReviewState(page, 'experimental-narrow-menu') const menuBox = (await menu.boundingBox())! - expect(menuBox.width).toBeGreaterThanOrEqual(218) + expect(menuBox.width).toBeGreaterThanOrEqual(144) expect(menuBox.width).toBeLessThanOrEqual(360) expect(menuBox.x).toBeGreaterThanOrEqual(0) expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(420) diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index 17a741dac4..46115eff44 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -91,6 +91,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn expect(waitingRow.querySelector('[data-state="warning"]')).not.toBeNull() expect(waitingRow.querySelector('[data-state="ongoing"]')).toBeNull() within(waitingRow).getByText('Waiting for answer') + within(waitingRow).getByText('Answer') // Opening a session reaches chat content through the RemoteMock transport. fireEvent.click(waitingTitle) @@ -103,7 +104,12 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn for (let index = 0; index < 3; index += 1) { fireEvent.click(await screen.findByRole('button', { name: 'Skip' })) } - fireEvent.click(await screen.findByRole('button', { name: 'Allow once' })) + const allowOnce = await screen.findByRole('button', { name: 'Allow once' }) + await waitFor(() => { + within(waitingRow).getByText('Waiting for approval') + within(waitingRow).getByText('Approval') + }) + fireEvent.click(allowOnce) // The fixture mirrors all three token-meter projections, so the assembled // ContextMeter reaches its composition panel instead of only the occupancy diff --git a/apps/web/tests/clickable-links-gallery.e2e.ts b/apps/web/tests/clickable-links-gallery.e2e.ts index 5f210b0bab..21e4f78bae 100644 --- a/apps/web/tests/clickable-links-gallery.e2e.ts +++ b/apps/web/tests/clickable-links-gallery.e2e.ts @@ -436,8 +436,8 @@ describe('web e2e: clickable links gallery', () => { // globe in the same seat. const repoLink = markdown.locator(`a[href="${REPO_URL}"]`) expect(await repoLink.count()).toBe(1) - const repoMark = await repoLink.locator('svg path').getAttribute('d') - const globeMark = await guideLink.locator('svg path').getAttribute('d') + const repoMark = await repoLink.locator('svg path').first().getAttribute('d') + const globeMark = await guideLink.locator('svg path').first().getAttribute('d') expect(repoMark).not.toBe(globeMark) await guideLink.hover() expect(await styleOf(guideLink, 'text-decoration-line')).toBe('underline') diff --git a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md index 19b2bcb58b..9fcc346ad2 100644 --- a/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md +++ b/apps/web/tests/expected/access-confirmation/auto-confirmation.expected.md @@ -1,8 +1,6 @@ - dialog "确认启用 Auto review(实验)?": - heading "确认启用 Auto review(实验)?" [level=2] - - button "关闭": - - img - - img + - button "关闭" - paragraph: Auto review 不使用沙箱。每次原生工具调用和 PTC 内层调用前,都会由与当前 agent 相同的模型进行审查。此功能仍属实验性,可能误放行或误拒绝,并会消耗额外 token。 - checkbox "我已了解这些风险,并愿意继续" - text: 我已了解这些风险,并愿意继续 diff --git a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md index 6553ea113a..9e942e44b5 100644 --- a/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/current-session-picker.expected.md @@ -1,8 +1,6 @@ - menu: - menuitem "仅可查看" - - menuitem "工作区内修改": - - text: 工作区内修改 - - img + - menuitem "工作区内修改" - menuitem "完全权限" - menuitem "Auto review EXP": - text: Auto review diff --git a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md index ffb1c8bc36..f1b12d28b0 100644 --- a/apps/web/tests/expected/access-confirmation/slash-picker.expected.md +++ b/apps/web/tests/expected/access-confirmation/slash-picker.expected.md @@ -2,9 +2,7 @@ - /placeholder: 搜索… - listbox "/permission 匹配项": - option "仅可查看" - - option "工作区内修改" [selected]: - - text: 工作区内修改 - - img + - option "工作区内修改" [selected] - option "完全权限" - option "Auto review EXP": - text: Auto review diff --git a/apps/web/tests/expected/access-confirmation/ui.expected.md b/apps/web/tests/expected/access-confirmation/ui.expected.md index d554a96a72..af85499602 100644 --- a/apps/web/tests/expected/access-confirmation/ui.expected.md +++ b/apps/web/tests/expected/access-confirmation/ui.expected.md @@ -1,8 +1,6 @@ - dialog "确认启用完全权限?": - heading "确认启用完全权限?" [level=2] - - button "关闭": - - img - - img + - button "关闭" - paragraph: 启用完全权限后,智能体将减少确认步骤,并且可以直接执行更多操作,包括敏感操作、文件修改或外部命令。仅建议在你信任当前任务时使用。 - checkbox "我已了解风险,并愿意继续" - text: 我已了解风险,并愿意继续 diff --git a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md index dc4045c5cc..6e0cbf8c14 100644 --- a/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/copy-dialog.expected.md @@ -1,7 +1,6 @@ - dialog "复制预设 · 复制自 极简模式": - heading "复制预设 · 复制自 极简模式" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。 - text: 标识符 - textbox "标识符": diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index e443599032..033bf298ae 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - text: 允许切换agent模式 beta @@ -31,60 +19,36 @@ - 'button "新任务默认: 标准模式" [disabled] [pressed]': - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - - 'button "查看: 标准模式"': - - img - - text: 查看 - - 'button "复制: 标准模式"': - - img - - text: 复制 + - 'button "查看: 标准模式"': 查看 + - 'button "复制: 标准模式"': 复制 - listitem: - 'button "设为默认: PTC 模式"': - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 - code: ptc - - 'button "查看: PTC 模式"': - - img - - text: 查看 - - 'button "复制: PTC 模式"': - - img - - text: 复制 + - 'button "查看: PTC 模式"': 查看 + - 'button "复制: PTC 模式"': 复制 - listitem: - 'button "设为默认: 极简模式"': - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - - 'button "查看: 极简模式"': - - img - - text: 查看 - - 'button "复制: 极简模式"': - - img - - text: 复制 + - 'button "查看: 极简模式"': 查看 + - 'button "复制: 极简模式"': 复制 - listitem: - 'button "设为默认: 创造模式"': - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 - code: cordis - - 'button "查看: 创造模式"': - - img - - text: 查看 - - 'button "复制: 创造模式"': - - img - - text: 复制 + - 'button "查看: 创造模式"': 查看 + - 'button "复制: 创造模式"': 复制 - heading "自定义" [level=3] - list: - listitem: - 'button "设为默认: 我的模式"': - text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。 - code: my-agent - - 'button "查看路径: 我的模式"': - - img - - text: 查看路径 - - 'button "复制: 我的模式"': - - img - - text: 复制 - - 'button "删除: 我的模式"': - - img - - text: 删除 + - 'button "查看路径: 我的模式"': 查看路径 + - 'button "复制: 我的模式"': 复制 + - 'button "删除: 我的模式"': 删除 - paragraph: - text: 预设文件: - code: {{presetRoot}}/my-agent - - button "用「创造模式」创作自定义预设": - - img - - text: 用「创造模式」创作自定义预设 + - button "用「创造模式」创作自定义预设" diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index f06fa3d070..c28c733b10 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - text: 允许切换agent模式 beta @@ -31,42 +19,26 @@ - 'button "新任务默认: 标准模式" [disabled] [pressed]': - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - - 'button "查看: 标准模式"': - - img - - text: 查看 - - 'button "复制: 标准模式"': - - img - - text: 复制 + - 'button "查看: 标准模式"': 查看 + - 'button "复制: 标准模式"': 复制 - listitem: - 'button "设为默认: PTC 模式"': - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 - code: ptc - - 'button "查看: PTC 模式"': - - img - - text: 查看 - - 'button "复制: PTC 模式"': - - img - - text: 复制 + - 'button "查看: PTC 模式"': 查看 + - 'button "复制: PTC 模式"': 复制 - listitem: - 'button "设为默认: 极简模式"': - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - - 'button "查看: 极简模式"': - - img - - text: 查看 - - 'button "复制: 极简模式"': - - img - - text: 复制 + - 'button "查看: 极简模式"': 查看 + - 'button "复制: 极简模式"': 复制 - listitem: - 'button "设为默认: 创造模式"': - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 - code: cordis - - 'button "查看: 创造模式"': - - img - - text: 查看 - - 'button "复制: 创造模式"': - - img - - text: 复制 + - 'button "查看: 创造模式"': 查看 + - 'button "复制: 创造模式"': 复制 - heading "自定义" [level=3] - list: - listitem: @@ -74,29 +46,15 @@ - text: broken-yaml 加载失败 自定义 暂无描述。 - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" - code: broken-yaml - - 'button "查看路径: broken-yaml"': - - img - - text: 查看路径 - - 'button "复制: broken-yaml" [disabled]': - - img - - text: 预设加载失败,不能复制 - - 'button "删除: broken-yaml"': - - img - - text: 删除 + - 'button "查看路径: broken-yaml"': 查看路径 + - 'button "复制: broken-yaml" [disabled]': 预设加载失败,不能复制 + - 'button "删除: broken-yaml"': 删除 - listitem: - 'button "加载失败: 幽灵预设" [disabled]': - text: 幽灵预设 加载失败 自定义 composition 已被手动删除。 - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file - code: ghost - - 'button "查看路径: 幽灵预设"': - - img - - text: 查看路径 - - 'button "复制: 幽灵预设" [disabled]': - - img - - text: 预设加载失败,不能复制 - - 'button "删除: 幽灵预设"': - - img - - text: 删除 - - button "用「创造模式」创作自定义预设": - - img - - text: 用「创造模式」创作自定义预设 + - 'button "查看路径: 幽灵预设"': 查看路径 + - 'button "复制: 幽灵预设" [disabled]': 预设加载失败,不能复制 + - 'button "删除: 幽灵预设"': 删除 + - button "用「创造模式」创作自定义预设" diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index 358f72e593..471f6bbada 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "Agent 预设" [level=2] - paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 - text: 允许切换agent模式 beta @@ -31,43 +19,25 @@ - 'button "新任务默认: 标准模式" [disabled] [pressed]': - text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。 - code: standard - - 'button "查看: 标准模式"': - - img - - text: 查看 - - 'button "复制: 标准模式"': - - img - - text: 复制 + - 'button "查看: 标准模式"': 查看 + - 'button "复制: 标准模式"': 复制 - listitem: - 'button "设为默认: PTC 模式"': - text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。 - code: ptc - - 'button "查看: PTC 模式"': - - img - - text: 查看 - - 'button "复制: PTC 模式"': - - img - - text: 复制 + - 'button "查看: PTC 模式"': 查看 + - 'button "复制: PTC 模式"': 复制 - listitem: - 'button "设为默认: 极简模式"': - text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。 - code: minimal - - 'button "查看: 极简模式"': - - img - - text: 查看 - - 'button "复制: 极简模式"': - - img - - text: 复制 + - 'button "查看: 极简模式"': 查看 + - 'button "复制: 极简模式"': 复制 - listitem: - 'button "设为默认: 创造模式"': - text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。 - code: cordis - - 'button "查看: 创造模式"': - - img - - text: 查看 - - 'button "复制: 创造模式"': - - img - - text: 复制 + - 'button "查看: 创造模式"': 查看 + - 'button "复制: 创造模式"': 复制 - heading "自定义" [level=3] - - button "用「创造模式」创作自定义预设": - - img - - text: 用「创造模式」创作自定义预设 + - button "用「创造模式」创作自定义预设" diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index e81602eb6a..c6b9f141bf 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,12 +1,7 @@ - navigation "Session hierarchy": - button "Seeded turn" [disabled] - text: / - - button "1 subagent": - - text: 1 subagent - - img -- img + - button "1 subagent" - text: Minimal mode -- button "More actions": - - img -- button "Open right sidebar": - - img +- button "More actions" +- button "Open right sidebar" diff --git a/apps/web/tests/expected/agent-preset-selection/hero.expected.md b/apps/web/tests/expected/agent-preset-selection/hero.expected.md index a320fa3e2f..2fe7ad2c8f 100644 --- a/apps/web/tests/expected/agent-preset-selection/hero.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/hero.expected.md @@ -1,8 +1,2 @@ -- button "Choose workspace": - - img - - text: workspace - - img -- button "Standard mode": - - img - - text: Standard mode - - img +- button "Choose workspace": workspace +- button "Standard mode" diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 3d7c7bebb2..d6dfa09aad 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -1,7 +1,5 @@ - menu: - - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": - - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - - img + - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows." - menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Single-tool coding agent with a persistent shell." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance." diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index f23d5c693e..1634cf19fe 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -1,68 +1,47 @@ - banner: - navigation "Session hierarchy": - button "Clickable links gallery" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}" -- button "Copy": - - img -- button "16 tool calls" [expanded]: - - text: 16 tool calls - - img +- button "Copy" +- button "16 tool calls" [expanded] - button "Write site/report.html +1 -0": - - img - - img - text: Write - button "site/report.html" - text: +1 -0 - button "Write a/style.css +1 -0": - - img - - img - text: Write - button "a/style.css" - text: +1 -0 - button "Write b/style.css +1 -0": - - img - - img - text: Write - button "b/style.css" - text: +1 -0 - button "Write site/index.html +1 -0": - - img - - img - text: Write - button "site/index.html" - text: +1 -0 - button "Write site/app.js +1 -0": - - img - - img - text: Write - button "site/app.js" - text: +1 -0 - button "Edit src/tokens.css +1 -1" [expanded]: - - img - text: Edit - button "src/tokens.css" - text: +1 -1 - button "Copy" - text: "src/tokens.css - --inline-code: #EBEEF2; + --inline-code: #F5F5F5; └ +1 -1 · 1 file" - button "Inspect" -- button "Tool call str_replace_editor · create" [expanded]: - - img - - text: Tool call str_replace_editor · create +- button "Tool call str_replace_editor · create" [expanded] - text: "IN { \"command\": \"create\", \"path\": \"docs/press.md\", \"file_text\": \"# Press kit\\n\" } OUT Created docs/press.md" - button "Inspect" - text: Failed -- 'button "Write permission denied: c/broken.css"': - - img - - text: "Write permission denied: c/broken.css" +- 'button "Write permission denied: c/broken.css"' - button "Read docs/guide.md" [expanded]: - - img - text: Read - button "docs/guide.md" - text: docs/guide.md @@ -71,9 +50,7 @@ - button "Expand 4 more lines": … 4 more lines - text: guide line 9 guide line 10 guide line 11 guide line 12 - button "Inspect" -- button "Grep linkColor" [expanded]: - - img - - text: Grep linkColor +- button "Grep linkColor" [expanded] - text: 9 matches · 3 files - button "Copy" - button "a/style.css 3" [expanded] @@ -82,24 +59,14 @@ - button "src/tokens.css 3" [expanded] - text: "3: color: var(--linkColor); 7: color: var(--linkColor); 11: color: var(--linkColor);" - button "Inspect" -- button "Glob **/*.css": - - img - - img - - text: Glob **/*.css -- button "Bash List the built site": - - img - - img - - text: Bash List the built site -- button "Failed Bash Run the lint gate" [expanded]: - - img - - text: Failed Bash Run the lint gate +- button "Glob **/*.css" +- button "Bash List the built site" +- button "Failed Bash Run the lint gate" [expanded] - text: Failed site pnpm run lint exit code 1 - button "Copy" - text: "style.css: unexpected hex literal" - button "Inspect" -- button "Search clickable link styles, produced files ui" [expanded]: - - img - - text: Search clickable link styles, produced files ui +- button "Search clickable link styles, produced files ui" [expanded] - paragraph: - text: Unify links per - link "the guide": @@ -112,16 +79,12 @@ - text: One cursor token, one focus ring. - listitem: Mirror spec (non-http) A non-http source renders inert. - button "Inspect" -- button "Fetch https://docs.example.test/tokens" [expanded]: - - img - - text: Fetch https://docs.example.test/tokens +- button "Fetch https://docs.example.test/tokens" [expanded] - link "https://docs.example.test/tokens": - /url: https://docs.example.test/tokens - text: HTTP 200 - button "Inspect" -- button "Tool call design_tokens_sync · design-platform.css" [expanded]: - - img - - text: Tool call design_tokens_sync · design-platform.css +- button "Tool call design_tokens_sync · design-platform.css" [expanded] - text: "IN { \"source\": \"design-platform.css\", \"dryRun\": false } OUT {\"synced\":true,\"tokens\":12}" - button "Inspect" - heading "Link gallery" [level=2] @@ -179,28 +142,16 @@ - list: - listitem: - paragraph: Footnote references stay inert superscripts. ↩ -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} -- button "Back to bottom": - - img +- button "Back to bottom" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 2 steps": - - img - - text: 1 turns 2 steps +- button "1 turns 2 steps" diff --git a/apps/web/tests/expected/cold-blank-session/queue.expected.md b/apps/web/tests/expected/cold-blank-session/queue.expected.md index 9fcb11cde6..c8aded928a 100644 --- a/apps/web/tests/expected/cold-blank-session/queue.expected.md +++ b/apps/web/tests/expected/cold-blank-session/queue.expected.md @@ -1,9 +1,6 @@ - list: - listitem: - text: Accepted before the Host restarted - - button "Edit queued message": - - img - - button "Remove queued message": - - img - - button "Steer queued message" [disabled]: - - img + - button "Edit queued message" + - button "Remove queued message" + - button "Steer queued message" [disabled] diff --git a/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md b/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md index 19a61c0c94..34a0eb6663 100644 --- a/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md +++ b/apps/web/tests/expected/composer-tab-geometry/geometry.expected.md @@ -4,7 +4,7 @@ - Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true -- Chat reserved band: 8px +- Chat reserved band: 5px - Trajectory: scrollbar-gutter auto, overflow hidden/auto - Trajectory scroller scrolls: false - Trajectory reserved band: 0px @@ -16,7 +16,7 @@ - Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true -- Chat reserved band: 8px +- Chat reserved band: 5px - Trajectory: scrollbar-gutter auto, overflow hidden/auto - Trajectory scroller scrolls: false - Trajectory reserved band: 0px @@ -28,10 +28,10 @@ - Chat: scrollbar-gutter stable, overflow auto/auto - Chat scroller scrolls: true -- Chat reserved band: 8px +- Chat reserved band: 5px - Trajectory: scrollbar-gutter auto, overflow hidden/auto - Trajectory scroller scrolls: false - Trajectory reserved band: 0px -- input card left edge moves between tabs: 4px -- input card right edge moves between tabs: 4px +- input card left edge moves between tabs: 2.5px +- input card right edge moves between tabs: 2.5px - input card width changes between tabs: 0px diff --git a/apps/web/tests/expected/cordis-history/ui.expected.md b/apps/web/tests/expected/cordis-history/ui.expected.md index 4ed654fa1a..6e35dfef96 100644 --- a/apps/web/tests/expected/cordis-history/ui.expected.md +++ b/apps/web/tests/expected/cordis-history/ui.expected.md @@ -1,10 +1,8 @@ - banner: - navigation "Session hierarchy": - button "Use only Cordis tools. First" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -12,35 +10,15 @@ - button "Jump to turn 1" - button "Jump to turn 2" - button "Jump to turn 3" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}" -- button "Copy": - - img -- button "3 tool calls" [expanded]: - - text: 3 tool calls - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package.": - - img - - img - - text: Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package. -- 'button "Tool call cordis_inspect_self · {}"': - - img - - img - - text: "Tool call cordis_inspect_self · {}" -- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package.": - - img - - img - - text: Think No dynamic Plugins are present, so I will define the requested Host and Client Package. -- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded]: - - img - - text: Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready +- button "Copy" +- button "3 tool calls" [expanded] +- button "Context injection @deepseek-ai/dsh-system-prompt" +- button "Think I will inspect the current Session's dynamic Cordis Plugins before defining the snapshot Package." +- 'button "Tool call cordis_inspect_self · {}"' +- button "Think No dynamic Plugins are present, so I will define the requested Host and Client Package." +- button "Register Cordis Plugin snapshot noop does nothing, for the snapshot Ready" [expanded] - tablist "Plugin source": - tab "Client" - tab "Host" [selected] @@ -50,83 +28,44 @@ - code: "return { name: \"snapshot-noop\", apply(ctx) {} }" - text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings - button "Inspect" -- button "Think The Host returned snap-1/pkg-1, so I will request its first activation.": - - img - - img - - text: Think The Host returned snap-1/pkg-1, so I will request its first activation. -- img +- button "Think The Host returned snap-1/pkg-1, so I will request its first activation." - text: Run Cordis Plugin snap-1 · pkg-1 Ready - button "Inspect" - text: snap-1/pkg-1 is awaiting user approval (run-1). -- button "Think The activation request has been submitted, so I will return the requested readiness marker.": - - img - - img - - text: Think The activation request has been submitted, so I will return the requested readiness marker. +- button "Think The activation request has been submitted, so I will return the requested readiness marker." - paragraph: CORDIS_UI_READY -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 9/1 {{clock}} -- button "Thought for a while": - - text: Thought for a while - - img +- button "Thought for a while" - paragraph: The Cordis Plugin is running. -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}} -- button "Copy": - - img -- button "1 tool call" [expanded]: - - text: 1 tool call - - img -- img +- button "Copy" +- button "1 tool call" [expanded] - text: Stop Cordis Plugin snap-1 - button "Inspect" - text: Dynamic Plugin snap-1 is stopped; its definition and versions remain. - paragraph: CORDIS_UI_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 9/1 {{clock}} -- button "Back to bottom": - - img +- button "Back to bottom" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "3 turns 7 steps": - - img - - text: 3 turns 7 steps -- button "66.8K tok · Cache hit 77%": - - img - - text: 66.8K tokCache hit 77% +- button "3 turns 7 steps" +- button "66.8K tok · Cache hit 77%": 66.8K tokCache hit 77% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/declared-reasoning/ui.expected.md b/apps/web/tests/expected/declared-reasoning/ui.expected.md index 810a6bf8b5..2b18a73716 100644 --- a/apps/web/tests/expected/declared-reasoning/ui.expected.md +++ b/apps/web/tests/expected/declared-reasoning/ui.expected.md @@ -1,7 +1,5 @@ - menu "模型与推理等级": - - menuitemradio "Default" [checked]: - - text: Default - - img + - menuitemradio "Default" [checked] - menuitemradio "Off" - menuitemradio "High" - menuitemradio "Max" diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 15732b9c03..2a879d6b31 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -43,28 +31,18 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: DeepSeek-V41-Flash - - button "模型选项 1": - - img - - button "删除模型 1": - - img + - button "模型选项 1" + - button "删除模型 1" - textbox "模型 ID 2": - /placeholder: 模型 ID - text: deepseek-v4-pro - textbox "显示名称 2": - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - button "模型选项 2": - - img - - button "删除模型 2": - - img - - button "添加模型": - - img - - text: 添加模型 + - button "模型选项 2" + - button "删除模型 2" + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md index bee6603b9b..37066e17e2 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/picker.expected.md @@ -1,7 +1,5 @@ - menu "模型与推理等级": - group "DeepSeek": - text: DeepSeek - - menuitemradio "Messages Flash" [checked]: - - text: Messages Flash - - img + - menuitemradio "Messages Flash" [checked] - menuitemradio "DeepSeek-V4-Pro" diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index af3b3a5753..138f34efad 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -1,62 +1,34 @@ - tree "Sessions": - - treeitem "{{workspace}}" [expanded]: - - img - - text: {{workspace}} + - treeitem "{{workspace}}" [expanded] - treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]: - text: Review deepseek-ai/deepseek-harness#314 - - button "Session actions for Review deepseek-ai/deepseek-harness#314": - - img + - button "Session actions for Review deepseek-ai/deepseek-harness#314" --- - banner: - navigation "Session hierarchy": - button "Review deepseek-ai/deepseek-harness#314" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt -- button "Thought for a while" [expanded]: - - text: Thought for a while - - img -- button "Context injection webhook github webhook handled by review-pr-when-ready": - - img - - img - - text: Context injection webhook github webhook handled by review-pr-when-ready -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt +- button "System prompt" +- button "Thought for a while" [expanded] +- button "Context injection webhook github webhook handled by review-pr-when-ready" +- button "Context injection @deepseek-ai/dsh-system-prompt" - paragraph: "Review complete: no actionable findings." -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only -- button "Select model, current github-webhook-review-test/reply": - - text: github-webhook-review-test/reply - - img +- button "Select model, current github-webhook-review-test/reply": github-webhook-review-test/reply - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 77b217510e..a5be99f8ee 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -1,54 +1,32 @@ - tree "Sessions": - - treeitem "{{workspace}}" [expanded]: - - img - - text: {{workspace}} + - treeitem "{{workspace}}" [expanded] - treeitem "Review deepseek-ai/deepseek-harness#314 Session actions for Review deepseek-ai/deepseek-harness#314" [selected]: - text: Review deepseek-ai/deepseek-harness#314 - - button "Session actions for Review deepseek-ai/deepseek-harness#314": - - img + - button "Session actions for Review deepseek-ai/deepseek-harness#314" --- - banner: - navigation "Session hierarchy": - button "Review deepseek-ai/deepseek-harness#314" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt -- button "Thought for a while": - - text: Thought for a while - - img +- button "System prompt" +- button "Thought for a while" - paragraph: "Review complete: no actionable findings." -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Read Only"': Read Only -- button "Select model, current github-webhook-review-test/reply": - - text: github-webhook-review-test/reply - - img +- button "Select model, current github-webhook-review-test/reply": github-webhook-review-test/reply - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/goal-bar/active.expected.md b/apps/web/tests/expected/goal-bar/active.expected.md index e6146d8a54..cf3227ee51 100644 --- a/apps/web/tests/expected/goal-bar/active.expected.md +++ b/apps/web/tests/expected/goal-bar/active.expected.md @@ -1,8 +1,4 @@ -- img - text: Ongoing Goal guard rapid clear clicks -- button "Pause goal": - - img -- button "Edit goal": - - img -- button "Clear goal": - - img +- button "Pause goal" +- button "Edit goal" +- button "Clear goal" diff --git a/apps/web/tests/expected/goal-bar/inactive.expected.md b/apps/web/tests/expected/goal-bar/inactive.expected.md index 457765ef78..729dbd1dd9 100644 --- a/apps/web/tests/expected/goal-bar/inactive.expected.md +++ b/apps/web/tests/expected/goal-bar/inactive.expected.md @@ -1,8 +1,4 @@ -- img - text: Inactive Goal guard rapid clear clicks -- button "Resume goal": - - img -- button "Edit goal": - - img -- button "Clear goal": - - img +- button "Resume goal" +- button "Edit goal" +- button "Clear goal" diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index e7fbb563ba..dd44da4896 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -1,25 +1,16 @@ - banner: - navigation "Session hierarchy": - button "workspace" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - group "Command input": /goal -- 'button "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]"': - - img - - img - - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" +- 'button "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]"' - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index c600eb94a8..bc95d13b0e 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -1,16 +1,13 @@ - banner: - navigation "Session hierarchy": - button "CJK strong emphasis" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Render adjacent CJK strong emphasis. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: @@ -37,26 +34,15 @@ - strong: Warning! - text: 继续 - paragraph: CJK_STRONG_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 7db76e3b44..3794be0c0c 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -1,16 +1,13 @@ - banner: - navigation "Session hierarchy": - button "Markdown image policy" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Show the Markdown image policy. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" @@ -23,26 +20,15 @@ - paragraph: Missing image - paragraph: {{cwd}}/corrupt.png - paragraph: REMOTE_IMAGE_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 12ff65dd6a..1428ede808 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -1,14 +1,12 @@ - banner: - navigation "Session hierarchy": - button "Inline code links" [disabled] - - button "More actions": - - img + - button "More actions" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Show the local preview URL. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" @@ -26,26 +24,15 @@ - text: "Unsafe:" - code: javascript:alert(1) - paragraph: INLINE_CODE_LINK_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 8afbbbb723..df043015fa 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -1,16 +1,13 @@ - banner: - navigation "Session hierarchy": - button "Math rendering" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Render this mathematical proof. {{clock}} -- button "Copy": - - img +- button "Copy" - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar @@ -32,26 +29,15 @@ - cell: - math: 1 5 - paragraph: MATH_RENDERING_DONE -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps": - - img - - text: 1 turns 1 steps +- button "1 turns 1 steps" diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 75f37005ed..782e523ddb 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -37,9 +25,5 @@ - button "编辑 acme-gateway": 编辑 - button "删除 acme-gateway": 删除 - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index db8230f9d7..aa803a766c 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -49,10 +37,8 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: GPT-6 Astra - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 256K @@ -67,18 +53,12 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 - - button "添加模型": - - img - - text: 添加模型 + - button "添加模型" - button "取消" - button "保存" - listitem: - text: Acme 网关 自定义 - button "编辑 Acme 网关 (acme-gateway)": 编辑 - button "删除 Acme 网关 (acme-gateway)": 删除 - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 1166b2cd12..3758c2af62 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - status: 已保存 minimax-cn。 @@ -29,9 +17,5 @@ - img "API 密钥已配置" - button "编辑 minimax-cn": 编辑 - button "删除 minimax-cn": 删除 - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 18a38f9843..e8b2579203 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -58,10 +46,8 @@ - text: acme-large - textbox "显示名称 1": - /placeholder: 显示名称 - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 256K @@ -74,14 +60,8 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 - - button "添加模型": - - img - - text: 添加模型 + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index 97173d9c20..4844a60cfa 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -32,9 +20,5 @@ - text: Acme Gateway 自定义 - button "编辑 Acme Gateway (acme-gateway)": 编辑 - button "删除 Acme Gateway (acme-gateway)": 删除 - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/models-settings/delete.expected.md b/apps/web/tests/expected/models-settings/delete.expected.md index 5757ca52ca..ae7d02879b 100644 --- a/apps/web/tests/expected/models-settings/delete.expected.md +++ b/apps/web/tests/expected/models-settings/delete.expected.md @@ -1,7 +1,6 @@ - dialog "删除 minimax-cn?": - heading "删除 minimax-cn?" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 删除 minimax-cn 会移除其配置和存储的 API 密钥。 - button "取消" - button "删除 minimax-cn" diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index b0e01b614d..6a369b9267 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index 7f42a844d7..e02c9b2529 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,6 @@ - dialog "选择要添加的模型": - heading "选择要添加的模型" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 以下是模型提供方的可用模型,勾选要添加的模型。 - searchbox "搜索模型" - button "取消全选" diff --git a/apps/web/tests/expected/models-settings/native-delete.expected.md b/apps/web/tests/expected/models-settings/native-delete.expected.md index 6ff480db12..6d4a90ab32 100644 --- a/apps/web/tests/expected/models-settings/native-delete.expected.md +++ b/apps/web/tests/expected/models-settings/native-delete.expected.md @@ -1,7 +1,6 @@ - dialog "删除 minimax-cn?": - heading "删除 minimax-cn?" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 删除 minimax-cn 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。 - button "取消" - button "删除 minimax-cn" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index db14de42a9..4920e310b8 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -43,10 +31,8 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: DeepSeek-V41-Flash - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M @@ -66,18 +52,10 @@ - textbox "显示名称 2": - /placeholder: 显示名称 - text: DeepSeek-V4-Pro - - button "模型选项 2": - - img - - button "删除模型 2": - - img - - button "添加模型": - - img - - text: 添加模型 + - button "模型选项 2" + - button "删除模型 2" + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 6ce0338ab8..8bccdd9d12 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: @@ -44,10 +32,8 @@ - textbox "显示名称 1": - /placeholder: 显示名称 - text: Private Preview - - button "模型选项 1" [expanded]: - - img - - button "删除模型 1": - - img + - button "模型选项 1" [expanded] + - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - /placeholder: 1M @@ -62,14 +48,8 @@ - text: 文本 - checkbox "图片" [checked] - text: 图片 - - button "添加模型": - - img - - text: 添加模型 + - button "添加模型" - button "取消" - button "保存" - - button "添加提供方": - - img - - text: 添加提供方 - - button "添加自定义提供方": - - img - - text: 添加自定义提供方 + - button "添加提供方" + - button "添加自定义提供方" diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index aa5bb4ac1f..76ba608af1 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -1,25 +1,13 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - list: diff --git a/apps/web/tests/expected/plan-review/temporary.expected.md b/apps/web/tests/expected/plan-review/temporary.expected.md index 246480407c..f3c15ee8a3 100644 --- a/apps/web/tests/expected/plan-review/temporary.expected.md +++ b/apps/web/tests/expected/plan-review/temporary.expected.md @@ -1,6 +1,5 @@ - region "Temporary review": - - button "Copy": - - img + - button "Copy" - heading "Temporary review" [level=1] - paragraph: Review without a tool invocation. - heading "Implementation" [level=2] diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 7f69fe8231..bd0a2a97e9 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -1,9 +1,7 @@ - heading "插件" [level=1] - paragraph: 添加和管理插件 - button "刷新" -- button "添加插件": - - img - - text: 添加插件 +- button "添加插件" - heading "官方" [level=3] - text: "6" - list: diff --git a/apps/web/tests/expected/plugin-config/row.expected.md b/apps/web/tests/expected/plugin-config/row.expected.md index a68510932f..85b1d52b9e 100644 --- a/apps/web/tests/expected/plugin-config/row.expected.md +++ b/apps/web/tests/expected/plugin-config/row.expected.md @@ -1,6 +1,4 @@ -- button "返回 live-client": - - img - - text: live-client +- button "返回 live-client": live-client - heading "fixture-live-client" [level=3] - paragraph: - code: "@fixture/live-client" diff --git a/apps/web/tests/expected/plugin-config/subagent.expected.md b/apps/web/tests/expected/plugin-config/subagent.expected.md index 0ed952f6f1..e1214b7fd7 100644 --- a/apps/web/tests/expected/plugin-config/subagent.expected.md +++ b/apps/web/tests/expected/plugin-config/subagent.expected.md @@ -1,21 +1,17 @@ -- button "返回插件列表": - - img - - text: 插件列表 +- button "返回插件列表": 插件列表 - heading "Subagent" [level=3] - paragraph: 设置 Subagent 的递归层级、数量和模型。 - region "运行限制": - heading "运行限制" [level=3] - text: 最大递归深度 - - button "最大递归深度说明": - - img + - button "最大递归深度说明" - text: 已覆盖 - button "恢复默认" - textbox "最大递归深度": - /placeholder: "" - text: "2" - text: Subagent 并行数量上限 - - button "Subagent 并行数量上限说明": - - img + - button "Subagent 并行数量上限说明" - text: 已覆盖 - button "恢复默认" - textbox "Subagent 并行数量上限": diff --git a/apps/web/tests/expected/plugin-install-approve/blocked.expected.md b/apps/web/tests/expected/plugin-install-approve/blocked.expected.md index b166febaf1..5b753ab623 100644 --- a/apps/web/tests/expected/plugin-install-approve/blocked.expected.md +++ b/apps/web/tests/expected/plugin-install-approve/blocked.expected.md @@ -1,9 +1,6 @@ - dialog "插件安装失败": - - button "返回编辑": - - img - - text: 编辑 - - button "关闭": - - img + - button "返回编辑": 编辑 + - button "关闭" - alert: 插件安装失败 - paragraph: 有依赖的安装脚本需要你允许后才能继续 - paragraph: native-package @@ -17,6 +14,4 @@ - paragraph: 允许后,脚本会以你的权限在本机运行,授权保存在当前 profile,之后不再询问。 - paragraph: 只在信任这些包时允许。 - button "允许这些脚本并重试" - - button "查看安装详情": - - text: 查看安装详情 - - img + - button "查看安装详情" diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index aae03c2610..20815bb8fc 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -1,13 +1,10 @@ - dialog "添加插件": - heading "添加插件" [level=2] - - button "关闭": - - img + - button "关闭" - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 - text: 包名或地址 - textbox "包名或地址": - /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review - text: slow-package - - button "不知道该填什么?": - - img - - text: 不知道该填什么? + - button "不知道该填什么?" - button "安装" diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index 7302e8cc31..16fe2ceeab 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -1,9 +1,7 @@ - heading "插件" [level=1] - paragraph: 添加和管理插件 - button "刷新" -- button "添加插件": - - img - - text: 添加插件 +- button "添加插件" - heading "官方" [level=3] - text: "6" - list: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 023bd3b8bd..5c7e0abece 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -1,9 +1,7 @@ - heading "插件" [level=1] - paragraph: 添加和管理插件 - button "刷新" -- button "添加插件": - - img - - text: 添加插件 +- button "添加插件" - heading "官方" [level=3] - text: "6" - list: diff --git a/apps/web/tests/expected/reference-composer/menu.expected.md b/apps/web/tests/expected/reference-composer/menu.expected.md index ea65a91653..dca8f5c457 100644 --- a/apps/web/tests/expected/reference-composer/menu.expected.md +++ b/apps/web/tests/expected/reference-composer/menu.expected.md @@ -2,8 +2,7 @@ - text: Files & folders - option "folderx/ Browse folder" [selected]: - text: folderx/ - - button "Browse folder": - - img + - button "Browse folder" - option "reference.txt" - text: Sessions - option "reference-order-target-session {{cwd}} · {{age}}" diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 2e0920a20d..ac92c645bf 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -1,24 +1,16 @@ - banner: - navigation "Session hierarchy": - button "Reference order target" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" - text: Research notes what changed? Referenced session · Research notes {{clock}} -- button "Copy": - - img -- button "Session recall Research notes": - - img - - text: Session recall Research notes +- button "Copy" +- button "Session recall Research notes" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] diff --git a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md index f481cb6ddf..11dcbaea1a 100644 --- a/apps/web/tests/expected/settings-chrome/dialog-en.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog-en.expected.md @@ -1,53 +1,25 @@ - dialog "Settings": - navigation: - text: Settings - - button "General": - - img - - text: General - - button "Models": - - img - - text: Models - - button "Built-in plugins": - - img - - text: Built-in plugins - - button "Agent presets": - - img - - text: Agent presets - - button "Archived sessions": - - img - - text: Archived sessions + - button "General" + - button "Models" + - button "Built-in plugins" + - button "Agent presets" + - button "Archived sessions" - button "Open configuration file" - - button "Close": - - img - - text: Close + - button "Close" - text: Permission Choose the default permission mode for new sessions - - button "Workspace Write": - - text: Workspace Write - - img + - button "Workspace Write" - text: Language - - button "English": - - text: English - - img + - button "English" - text: Appearance - - button "Light": - - img - - text: Light - - button "Dark": - - img - - text: Dark - - button "System" [pressed]: - - img - - text: System + - button "Light" + - button "Dark" + - button "System" [pressed] - text: Font size Only affects conversation content 14 - - button "Increase font size": - - img - - button "Decrease font size": - - img + - button "Increase font size" + - button "Decrease font size" - text: px Conversation display Controls process content in completed turns - - button "Compact": - - text: Compact - - img + - button "Compact" - text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior - - button "Queue": - - text: Queue - - img + - button "Queue" diff --git a/apps/web/tests/expected/settings-chrome/dialog.expected.md b/apps/web/tests/expected/settings-chrome/dialog.expected.md index 581273fa84..3d89fa478b 100644 --- a/apps/web/tests/expected/settings-chrome/dialog.expected.md +++ b/apps/web/tests/expected/settings-chrome/dialog.expected.md @@ -1,53 +1,25 @@ - dialog "设置": - navigation: - text: 设置 - - button "通用设置": - - img - - text: 通用设置 - - button "模型": - - img - - text: 模型 - - button "内置插件": - - img - - text: 内置插件 - - button "Agent 预设": - - img - - text: Agent 预设 - - button "已归档会话": - - img - - text: 已归档会话 + - button "通用设置" + - button "模型" + - button "内置插件" + - button "Agent 预设" + - button "已归档会话" - button "打开配置文件" - - button "关闭": - - img - - text: 关闭 + - button "关闭" - text: 权限 选择新会话的默认权限模式 - - button "工作区内修改": - - text: 工作区内修改 - - img + - button "工作区内修改" - text: 语言 - - button "中文": - - text: 中文 - - img + - button "中文" - text: 外观 - - button "浅色": - - img - - text: 浅色 - - button "深色": - - img - - text: 深色 - - button "跟随系统" [pressed]: - - img - - text: 跟随系统 + - button "浅色" + - button "深色" + - button "跟随系统" [pressed] - text: 字号大小 仅影响会话内容的字号 14 - - button "增大字号": - - img - - button "减小字号": - - img + - button "增大字号" + - button "减小字号" - text: px 对话显示 控制已完成轮次的过程内容 - - button "紧凑": - - text: 紧凑 - - img + - button "紧凑" - text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为 - - button "排队发送": - - text: 排队发送 - - img + - button "排队发送" diff --git a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md index cbc7dfaa51..4c654a7971 100644 --- a/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugin-instances.expected.md @@ -3,35 +3,29 @@ - button "tool-subagent-control, tool-subagent-control, 已启用": - strong: tool-subagent-control - text: 已启用 - - img - code: tool-subagent-control - listitem: - button "tool-subagent-control/list-agents, tool-subagent-list-agents, 已启用": - strong: tool-subagent-control/list-agents - text: 已启用 - - img - code: tool-subagent-list-agents - listitem: - button "tool-subagent, tool-subagent, 已启用": - strong: tool-subagent - text: 已启用 - - img - code: tool-subagent - listitem: - button "tool-subagent, tool-subagent-fork, 已启用": - strong: tool-subagent - text: 已启用 - - img - code: tool-subagent-fork - listitem: - button "tool-subagent, tool-subagent-codex, 已停用": - strong: tool-subagent - text: 已停用 - - img - code: tool-subagent-codex - listitem: - button "tool-subagent, tool-subagent-claude-code, 已停用": - strong: tool-subagent - text: 已停用 - - img - code: tool-subagent-claude-code diff --git a/apps/web/tests/expected/settings-chrome/plugins.expected.md b/apps/web/tests/expected/settings-chrome/plugins.expected.md index 01108aa7bc..e8536c690a 100644 --- a/apps/web/tests/expected/settings-chrome/plugins.expected.md +++ b/apps/web/tests/expected/settings-chrome/plugins.expected.md @@ -2,5 +2,4 @@ - button "tool-subagent, tool-subagent, 已启用": - strong: tool-subagent - text: 已启用 - - img - code: tool-subagent diff --git a/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md b/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md index 62671cbb8b..e17e9cdfa1 100644 --- a/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md +++ b/apps/web/tests/expected/sidebar-subagent-activity/owner-running.expected.md @@ -1,6 +1,4 @@ - tree "Sessions": - - treeitem "workspace" [expanded]: - - img - - text: workspace + - treeitem "workspace" [expanded] - treeitem "New Session" [selected] - treeitem "1 subagent running Delegate a background job. now" diff --git a/apps/web/tests/expected/sidebar-terminal/guide.expected.md b/apps/web/tests/expected/sidebar-terminal/guide.expected.md index 4c38f09585..525fedae3f 100644 --- a/apps/web/tests/expected/sidebar-terminal/guide.expected.md +++ b/apps/web/tests/expected/sidebar-terminal/guide.expected.md @@ -1,3 +1,2 @@ - button "New terminal Run commands in the Session workspace" -- button "Choose shell" [expanded]: - - img +- button "Choose shell" [expanded] diff --git a/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md index 8636cb1da0..9612af2741 100644 --- a/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md +++ b/apps/web/tests/expected/sidebar-terminal/shell-menu.expected.md @@ -1,5 +1,3 @@ - menu: - - menuitem "bash": - - text: bash - - img + - menuitem "bash" - menuitem "sh" diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index a2a169cbdb..6be4b09786 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -1,62 +1,32 @@ - banner: - navigation "Session hierarchy": - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - button "/user-invoke-demo" -- button "meeting notes.md": - - img - - text: meeting notes.md +- button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} -- button "Copy": - - img -- button "Thought for a while" [expanded]: - - text: Thought for a while - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Context injection user-invoke-demo": - - img - - img - - text: Context injection user-invoke-demo +- button "Copy" +- button "Thought for a while" [expanded] +- button "Context injection @deepseek-ai/dsh-system-prompt" +- button "Context injection user-invoke-demo" - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps · {{throughput}} tok/s": - - img - - text: 1 turns 1 steps{{throughput}} tok/s -- button "272 tok · Cache hit 0%": - - img - - text: 272 tokCache hit 0% +- button "1 turns 1 steps · {{throughput}} tok/s": 1 turns 1 steps{{throughput}} tok/s +- button "272 tok · Cache hit 0%": 272 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index dd04059876..84934122b6 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -1,54 +1,30 @@ - banner: - navigation "Session hierarchy": - button "/user-invoke-demo @\"meeting notes.md\" an" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - button "/user-invoke-demo" -- button "meeting notes.md": - - img - - text: meeting notes.md +- button "meeting notes.md" - text: and confirm the fixture wiring {{clock}} -- button "Copy": - - img -- button "Thought for a while": - - text: Thought for a while - - img +- button "Copy" +- button "Thought for a while" - paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill. -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 1 steps · {{throughput}} tok/s": - - img - - text: 1 turns 1 steps{{throughput}} tok/s -- button "272 tok · Cache hit 0%": - - img - - text: 272 tokCache hit 0% +- button "1 turns 1 steps · {{throughput}} tok/s": 1 turns 1 steps{{throughput}} tok/s +- button "272 tok · Cache hit 0%": 272 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 171ce5ee7e..706dd17b41 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -1,10 +1,8 @@ - banner: - navigation "Session hierarchy": - button "{{workspace}}" [disabled] - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" @@ -38,436 +36,234 @@ - button "Jump to turn 27" - button "Jump to turn 28" - text: m1 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r1 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m2 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r2 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m3 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r3 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m4 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r4 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m5 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r5 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m6 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r6 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m7 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r7 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m8 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r8 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m9 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r9 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m10 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r10 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m11 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r11 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m12 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r12 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m13 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r13 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m14 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r14 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m15 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r15 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m16 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r16 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m17 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r17 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m18 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r18 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m19 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r19 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m20 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r20 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m21 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r21 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m22 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r22 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m23 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r23 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m24 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r24 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m25 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r25 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m26 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r26 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m27 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r27 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} m28 7/25 {{clock}} -- button "Copy": - - img +- button "Copy" - paragraph: r28 -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: 7/25 {{clock}} -- button "Back to bottom": - - img +- button "Back to bottom" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "28 turns 28 steps": - - img - - text: 28 turns 28 steps +- button "28 turns 28 steps" diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 78e31b7a34..53fd9f27dc 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -1,42 +1,24 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} -- button "Copy": - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": - - img - - img - - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. +- button "Copy" +- button "Context injection @deepseek-ai/dsh-system-prompt" +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." - status: Deep diving... - text: "Interjection: include the word BANANA in your final reply." -- button "Copy": - - img +- button "Copy" - text: "Interjection: include the word ORANGE in your final reply." -- button "Copy": - - img +- button "Copy" - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Stop generating" diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index a551fb4660..02f1062df9 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -1,68 +1,35 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} -- button "Copy": - - img -- button "1 tool call" [expanded]: - - text: 1 tool call - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt -- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that.": - - img - - img - - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. -- button "Ask question 1/1 answered": - - img - - img - - text: Ask question 1/1 answered +- button "Copy" +- button "1 tool call" [expanded] +- button "Context injection @deepseek-ai/dsh-system-prompt" +- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that." +- button "Ask question 1/1 answered" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - paragraph: "Got it: BANANA and ORANGE." -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 2 steps · {{throughput}} tok/s": - - img - - text: 1 turns 2 steps{{throughput}} tok/s -- button "40 tok · Cache hit 0%": - - img - - text: 40 tokCache hit 0% +- button "1 turns 2 steps · {{throughput}} tok/s": 1 turns 2 steps{{throughput}} tok/s +- button "40 tok · Cache hit 0%": 40 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 9b81819095..5efb1d79ff 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -1,56 +1,32 @@ - banner: - navigation "Session hierarchy": - button "Use the ask_user_question tool to" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} -- button "Copy": - - img -- button "1 tool call": - - text: 1 tool call - - img +- button "Copy" +- button "1 tool call" - text: "Interjection: include the word BANANA in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - text: "Interjection: include the word ORANGE in your final reply. {{clock}}" -- button "Copy": - - img +- button "Copy" - paragraph: "Got it: BANANA and ORANGE." -- button "Copy": - - img -- button "Good response": - - img -- button "Bad response": - - img -- button "Branch into a new conversation": - - img -- button "Ran for {{duration}}": - - img - - text: Ran for {{duration}} +- button "Copy" +- button "Good response" +- button "Bad response" +- button "Branch into a new conversation" +- button "Ran for {{duration}}" - text: {{clock}} - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current DeepSeek-V4-Flash": - - text: DeepSeek-V4-Flash - - img +- button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash - button "Send message" [disabled] -- button "1 turns 2 steps · {{throughput}} tok/s": - - img - - text: 1 turns 2 steps{{throughput}} tok/s -- button "40 tok · Cache hit 0%": - - img - - text: 40 tokCache hit 0% +- button "1 turns 2 steps · {{throughput}} tok/s": 1 turns 2 steps{{throughput}} tok/s +- button "40 tok · Cache hit 0%": 40 tokCache hit 0% - button "0% of context used": 0% diff --git a/apps/web/tests/expected/thinking-markdown/ui.expected.md b/apps/web/tests/expected/thinking-markdown/ui.expected.md index a94d4bbf3c..ba8e88404d 100644 --- a/apps/web/tests/expected/thinking-markdown/ui.expected.md +++ b/apps/web/tests/expected/thinking-markdown/ui.expected.md @@ -1,6 +1,4 @@ -- button "Think" [expanded]: - - img - - text: Think +- button "Think" [expanded] - heading "Compact reasoning with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary with a deliberately long summary" [level=2] - paragraph: - text: A paragraph with diff --git a/apps/web/tests/expected/workspace-management/grouping-options.expected.md b/apps/web/tests/expected/workspace-management/grouping-options.expected.md index 062fe37aff..4652128ef7 100644 --- a/apps/web/tests/expected/workspace-management/grouping-options.expected.md +++ b/apps/web/tests/expected/workspace-management/grouping-options.expected.md @@ -1,13 +1,9 @@ - menu: - text: Group by - - menuitem "WorkSpace": - - text: WorkSpace - - img + - menuitem "WorkSpace" - menuitem "Workspace Tree" - menuitem "In one list" - separator - text: Order by - menuitem "Manual" - - menuitem "Last updated": - - text: Last updated - - img + - menuitem "Last updated" diff --git a/apps/web/tests/expected/workspace-management/parent-folders.expected.md b/apps/web/tests/expected/workspace-management/parent-folders.expected.md index d8a6385a8f..b6e7907f10 100644 --- a/apps/web/tests/expected/workspace-management/parent-folders.expected.md +++ b/apps/web/tests/expected/workspace-management/parent-folders.expected.md @@ -1,11 +1,5 @@ -- treeitem "folder-group" [expanded]: - - img - - text: folder-group +- treeitem "folder-group" [expanded] - group: - - treeitem "project-two" [expanded]: - - img - - text: project-two + - treeitem "project-two" [expanded] - treeitem "New Session" [selected] - - treeitem "project-one" [expanded]: - - img - - text: project-one + - treeitem "project-one" [expanded] diff --git a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md index 95f2becd57..8896ca5c5c 100644 --- a/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md +++ b/apps/web/tests/expected/workspace-new-session-folding/sidebar.expected.md @@ -1,11 +1,8 @@ - tree "Sessions": - treeitem "{{workspace}} Workspace actions for {{workspace}} New session in {{workspace}}" [expanded]: - - img - text: {{workspace}} - - button "Workspace actions for {{workspace}}": - - img - - button "New session in {{workspace}}": - - img + - button "Workspace actions for {{workspace}}" + - button "New session in {{workspace}}" - treeitem "New Session" [selected] - treeitem "{{workspace}} 1min" - treeitem "{{workspace}} 1min" diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 193174dd51..8147022b05 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -391,14 +391,14 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro // the wrapper focused, and focus-visible also reveals the bar. await page.mouse.move(4, 4) await wide.evaluate((element) => { element.blur() }) - await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') + await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 5px') // Resting hidden overflow keeps the scroll position reachable and intact. expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0) await wide.hover() await expect.poll(overflowState, { timeout: 5_000 }).toBe('scroll 0px') // Pointer leaves: the bar rests hidden again. await page.mouse.move(4, 4) - await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') + await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 5px') expect(tripwire.pageErrors).toEqual([]) }, 120_000) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index cdca7a376b..1e1a94c1f4 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -96,6 +96,8 @@ describe('web e2e: plan review takeover round trip', () => { const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => selectedRow.getByText('Plan awaiting review', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Plan review', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await selectedRow.getByText('now', { exact: true }).count()).toBe(0) if (MODE !== 'record') { const snapshot = await captureStableAria(page, '[data-plan-review-key]', scaffold.workspaceCwd) diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index bd39a95bbd..260792f545 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -162,6 +162,8 @@ describe('web e2e: resident question composer round trip', () => { const selectedRow = page.locator('[role="treeitem"][aria-selected="true"]') await expect.poll(() => selectedRow.locator('[data-state="warning"]').count(), { timeout: 10_000 }).toBe(1) await expect.poll(() => selectedRow.getByText('Waiting for answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + await expect.poll(() => selectedRow.getByText('Answer', { exact: true }).count(), { timeout: 10_000 }).toBe(1) + expect(await selectedRow.getByText('now', { exact: true }).count()).toBe(0) if (MODE !== 'record') { // This golden owns the stable question surface; the answered-state diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index b8d8d36ebe..a45d2350a5 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -376,9 +376,9 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum // the hover token included. expect(light.standardWidth).toBe('auto') expect(light.standardColor).toBe('auto') - // The pseudo-element path is the one in force: the sheet's own 8px sizing + // The pseudo-element path is the one in force: the sheet's own 5px sizing // and transparent track reached a container it never names. - expect(light.width).toBe('8px') + expect(light.width).toBe('5px') expect(light.track).toBe('rgba(0, 0, 0, 0)') // The resting and the hover rule each read the rebindable indirection, and // the two resolve to DIFFERENT colours on this list: the l1 pair arrived diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index ce84e0b76e..7cdc11770c 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -77,6 +77,48 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { expect(tripwire.warnings).toEqual([]) }, 60_000) + it('keeps the collapsed Skill line on the global ToolCall font-size axis', async () => { + const call = page.locator('[data-tool="skill"]') + const toggle = call.getByRole('button', { name: 'Skill editing-cordis-compositions' }) + if (await toggle.getAttribute('aria-expanded') === 'true') await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-expanded')).toBe('false') + const row = call.locator(':scope > div').first() + const title = call.getByText('Skill', { exact: true }) + const summary = call.getByText('editing-cordis-compositions', { exact: true }) + + expect(await title.evaluate(element => getComputedStyle(element).fontSize)).toBe('13px') + + const previous = await page.evaluate(() => ({ + fontSize: { + value: document.body.style.getPropertyValue('--dsh-content-font-size-secondary'), + priority: document.body.style.getPropertyPriority('--dsh-content-font-size-secondary'), + }, + delta: { + value: document.body.style.getPropertyValue('--dsh-content-font-delta'), + priority: document.body.style.getPropertyPriority('--dsh-content-font-delta'), + }, + })) + await page.evaluate(() => { + document.body.style.setProperty('--dsh-content-font-size-secondary', '15px') + document.body.style.setProperty('--dsh-content-font-delta', '2px') + }) + try { + expect(await title.evaluate(element => getComputedStyle(element).fontSize)).toBe('15px') + expect(await summary.evaluate(element => getComputedStyle(element).fontSize)).toBe('15px') + expect(await row.evaluate(element => getComputedStyle(element).height)).toBe('26px') + expect(await row.locator('svg').first().evaluate(element => getComputedStyle(element).width)).toBe('16px') + } finally { + await page.evaluate((saved) => { + const restore = (name: string, entry: { value: string; priority: string }): void => { + if (entry.value === '') document.body.style.removeProperty(name) + else document.body.style.setProperty(name, entry.value, entry.priority) + } + restore('--dsh-content-font-size-secondary', saved.fontSize) + restore('--dsh-content-font-delta', saved.delta) + }, previous) + } + }) + it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) }) diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 7b7eecd5d0..691a99c8e4 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,18 +1,12 @@ -- button "Agent Team" [expanded]: - - img - - text: Agent Team +- button "Agent Team" [expanded] - dialog "Agent Team": - strong: Agent Team - - button "Refresh Team": - - img - - button "Close": - - img + - button "Refresh Team" + - button "Close" - heading "Members" [level=3] - 'button "lead Idle · Model: deepseek-v4-flash" [disabled]' - heading "Shared tasks" [level=3] - - button "New task": - - img - - text: New task + - button "New task" - article: - strong: Browser task - text: Pending @@ -21,9 +15,5 @@ - combobox "Owner": - option "Unowned" [selected] - option "lead" - - button "Edit": - - img - - text: Edit - - button "Delete": - - img - - text: Delete + - button "Edit" + - button "Delete" diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index d155ce5042..9564bc493e 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -1,37 +1,24 @@ - banner: - navigation "Session hierarchy": - button "Stream one TypeScript fence for" [disabled] - - img - text: Standard mode - - button "More actions": - - img - - button "Open right sidebar": - - img + - button "More actions" + - button "Open right sidebar" - tablist: - tab "Chat" [selected] - tab "Trajectory" -- button "System prompt": - - img - - img - - text: System prompt +- button "System prompt" - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} -- button "Copy": - - img -- button "Context injection @deepseek-ai/dsh-system-prompt": - - img - - img - - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Copy" +- button "Context injection @deepseek-ai/dsh-system-prompt" - text: ts - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... - textbox "Message or run a task, / commands, @ files or sessions" -- button "Add files or run commands": - - img +- button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write -- button "Select model, current streaming-fence-highlight-test/streaming-fence": - - text: streaming-fence-highlight-test/streaming-fence - - img +- button "Select model, current streaming-fence-highlight-test/streaming-fence": streaming-fence-highlight-test/streaming-fence - button "Stop generating" --- diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index efd3677f8e..9e3e33699f 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -539,8 +539,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect([ Math.round(clickAreaBox!.x - treeBox!.x), Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), - // Menu padding alone insets the rows now that the border is gone. - ]).toEqual([4, 4]) + // Compact menu padding alone insets the rows now that the border is gone. + ]).toEqual([3, 3]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index cc7799325f..0593441390 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -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/web-styling.md -web-styling.md: 1be0b629d2ccf54c5a00cfc8091a01e3d4d09bfd -web-styling.zh.md: 106880e964a399cba825e889b5e93048589d00e4 +web-styling.md: 741982543a41f1ea8f14443894fbf80f1065b493 +web-styling.zh.md: 59e2b5c73760c7617d0c24346416c1991195a802 diff --git a/docs/web-styling.md b/docs/web-styling.md index 1be0b629d2..741982543a 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -22,8 +22,9 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls. - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing. - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. +- An elevated surface using the translucent `--dsw-specific-menu` fill also applies `backdrop-filter: var(--dsw-menu-backdrop-filter)` in the same rule. Shared dropdowns use the compact `Menu` geometry; feature-owned menu content may vary without recreating the material or scrollbar implementation ([decision](../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.md)). - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. -- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIcon` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)). +- Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.md)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance ([clickable-link Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.md), [known-site mark Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.md)). ## Changing the system diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 106880e964..59e2b5c737 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -22,8 +22,9 @@ - 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。 - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 +- 使用半透明 `--dsw-specific-menu` 填充的高层级表面,必须在同一规则中应用 `backdrop-filter: var(--dsw-menu-backdrop-filter)`。共享下拉菜单采用紧凑 `Menu` 几何;功能包自有菜单内容可以不同,但不得重新实现材质或滚动条([决定](../.agents/notes/implemented/feature/2026-09-17-compact-translucent-menu-surfaces.zh.md))。 - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 -- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIcon` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。 +- 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../.agents/notes/implemented/bug-fix/2026-09-17-thinking-markdown.zh.md))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能([可点击链接 Agent Note](../.agents/notes/implemented/feature/2026-09-04-web-clickable-link-styles.zh.md)、[已知站点标记 Agent Note](../.agents/notes/implemented/feature/2026-09-16-known-site-link-marks.zh.md))。 ## 变更系统 diff --git a/packages/client/locale/src/client/LanguageRow.tsx b/packages/client/locale/src/client/LanguageRow.tsx index 12da219572..43d4a98471 100644 --- a/packages/client/locale/src/client/LanguageRow.tsx +++ b/packages/client/locale/src/client/LanguageRow.tsx @@ -6,7 +6,7 @@ */ import { useState } from 'react' import type { PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type { createLanguageRowStore } from './settings-store.ts' import css from './LanguageRow.module.css' @@ -58,7 +58,7 @@ export function LanguageRow({ t, setLocale, useStore }: LanguageRowComponentProp onClick={() => { setOpen(v => !v) }} > {activeLabel} - + )} /> diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index b40453e94a..34b3879a60 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -11,7 +11,7 @@ import { useEffect } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-agent-presets/types' @@ -61,7 +61,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 1d79ca5474..68d3cd4a44 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -16,7 +16,7 @@ import { useEffect, useRef, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - IconAgentPresetOutline16, IconChevronDownOutline14, IconWarningOutline16, Menu, Toast, + IconAgentPresetOutlineRegular, IconChevronDownOutlineRegular, IconWarningOutlineRegular, Menu, Toast, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -207,9 +207,9 @@ export function AgentPresetSeat({ disabled={state.busy} onClick={() => { setOpen(value => !value) }} > - + {shownLabel} - + )} /> @@ -217,7 +217,7 @@ export function AgentPresetSeat({ } + icon={} holdMs={REFUSAL_HOLD_MS} // The composer card, which is the content column this chip sits // above rather than inside — hence a page query, not `closest`. diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index d275913ff5..5738a1fa36 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -13,8 +13,8 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { - Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, - IconPlusOutline16, IconTrashOutline16, Modal, Switch, Tag, Tooltip, + Button, IconBrowseOutlineRegular, IconCopyOutlineRegular, IconFolderOpenOutlineRegular, + IconPlusOutlineRegular, IconTrashOutlineRegular, Modal, Switch, Tag, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -226,7 +226,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { props.close() }} > - + {t('creatorDraft')} ) @@ -369,7 +369,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { aria-label={`${t('view')}: ${text.name}`} onClick={() => { void props.view(row.id) }} > - + ) : null @@ -381,7 +381,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`} onClick={() => { void props.openLocation(row.id) }} > - + )} {row.trust === 'user' ? ( @@ -405,7 +405,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { aria-label={`${t('delete')}: ${text.name}`} onClick={() => { props.confirmDelete(row.id) }} > - + ) : null} diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index 048eb3d2f4..82b20b469e 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -28,14 +28,6 @@ line-height: 18px; } -.dot { - width: 8px; - height: 8px; - border-radius: 50%; - corner-shape: round; - background: var(--dsw-alias-state-warn-primary); -} - .body { display: flex; flex-direction: column; diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.tsx b/packages/client/ui-approval/src/client/ApprovalPanel.tsx index dca3692899..a7c0b33477 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.tsx +++ b/packages/client/ui-approval/src/client/ApprovalPanel.tsx @@ -1,6 +1,6 @@ /** Composer takeover for one pending approval waterfall. */ import { useState, type ReactNode } from 'react' -import { Button } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ApprovalComposerProps, PendingApproval } from './contract/slots.ts' import css from './ApprovalPanel.module.css' @@ -28,9 +28,9 @@ function ApprovalFlow({ pending, detail, t }: { void pending.answer(outcome).catch(() => { setAnswered(false) }) } return ( -
    +
    -
    {t('waiting')}
    +
    {t('waiting')}
    { render() expect(screen.getByText('Tool bash asks')).toBeTruthy() + expect(document.querySelector('[data-approval-key] [data-state="warning"]')).not.toBeNull() expect(screen.getByRole('group', { name: 'Approval details' })).toBeTruthy() expect(props.renderSlot).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('button', { name: 'Reject' })) + expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('true') await expect(pending.result).resolves.toBe('rejected') expect(screen.getByRole('button', { name: 'Reject' }).disabled).toBe(true) expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(true) @@ -353,6 +355,8 @@ describe('ApprovalPanel', () => { callId: 'call-1', }) fireEvent.click(screen.getByRole('button', { name: 'Allow once' })) + expect(document.querySelector('[data-approval-key] [data-state="ongoing"]')).not.toBeNull() + expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('true') await expect(pending.result).resolves.toBe('allowed-once') }) @@ -367,6 +371,7 @@ describe('ApprovalPanel', () => { await waitFor(() => { expect(screen.getByRole('button', { name: 'Allow once' }).disabled).toBe(false) }) + expect(document.querySelector('[data-approval-key]')?.getAttribute('aria-busy')).toBe('false') pending.abort(new Error('test cleanup')) await pending.result.catch(() => {}) }) diff --git a/packages/client/ui-attachment/src/AttachmentRail.tsx b/packages/client/ui-attachment/src/AttachmentRail.tsx index e2e8f97321..a5a3724803 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.tsx +++ b/packages/client/ui-attachment/src/AttachmentRail.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronLeftOutline14, IconChevronRightOutline14, + IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import css from './AttachmentRail.module.css' @@ -142,7 +142,7 @@ export function AttachmentRail({ items, labels, re aria-label={labels.scrollLeft} onClick={() => { page(-1) }} > - + )}
    ({ items, labels, re aria-label={labels.scrollRight} onClick={() => { page(1) }} > - + )}
    diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx index 3692302325..a8359cae61 100644 --- a/packages/client/ui-attachment/src/FileCard.tsx +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -1,4 +1,4 @@ -import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { fileExtension, FileTypeIcon, fileSizeText, IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' import css from './FileCard.module.css' /** Localized strings consumed by one pending-file card. */ @@ -66,7 +66,7 @@ export function FileCard({ aria-label={labels.remove} onClick={onRemove} > - + {state === 'uploading' && ( diff --git a/packages/client/ui-attachment/src/ImageLightbox.tsx b/packages/client/ui-attachment/src/ImageLightbox.tsx index 1f1207e026..c2aff2eeb3 100644 --- a/packages/client/ui-attachment/src/ImageLightbox.tsx +++ b/packages/client/ui-attachment/src/ImageLightbox.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef } from 'react' import { createPortal } from 'react-dom' -import { IconCloseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import css from './ImageLightbox.module.css' /** Lightbox strings the owner resolves from its own locale namespace. */ @@ -56,7 +56,7 @@ export function ImageLightbox({ src, alt, labels, onClose }: { , document.body, diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index be35aa650c..caf8fe08f8 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { IconLoadingOutline16, IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { ImageLightbox } from './ImageLightbox.tsx' import type { ImageLightboxLabels } from './ImageLightbox.tsx' import css from './MessageImage.module.css' @@ -135,7 +135,7 @@ export function MessageImage({ image, load, variant, labels }: { onClick={request} > {variant === 'thumbnail' - ? + ? : labels.loadFailed} ) @@ -154,7 +154,7 @@ export function MessageImage({ image, load, variant, labels }: { {src === null ? ( - {loadingThumbnail ? : labels.loading} + {loadingThumbnail ? : labels.loading} ) : {label}} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index ca2e1e1aa7..7c9054890d 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' @@ -87,7 +87,7 @@ export function ComposerAttachments({ aria-label={t('image.remove', { name: attachment.file.name })} onClick={() => { onRemoveAttachment(attachment.id) }} > - +
    ) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 1d6df37e4d..78519947ae 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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-chat/README.md -README.md: e7d3efe37e089f4b7bcbb7d954e6791899cebfc0 -README.zh.md: 8b49332ad39d373fcc2aae66af2e7c284bfee9bb +README.md: 92c71663e9193e24657a31e57204a9bf076e19c3 +README.zh.md: 702042384b81d028b7db40275e5ee705dc949fb2 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index e7d3efe37e..92c71663e9 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -16,6 +16,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [Reference previews](#reference-previews) - [System prompt row](#system-prompt-row) +- [Command and failure rows](#command-and-failure-rows) - [Turn token usage](#turn-token-usage) - [Completed-turn footer](#completed-turn-footer) - [Turn Process Folding](#turn-process-folding) @@ -38,6 +39,13 @@ HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordi Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives. + +## Command and failure rows + +Generic command rows retain the ordinary command glyph in every lifecycle state; failure remains explicit through the row state and summary. A terminal Turn failure remains a separate red-dot notice; intermediate model retries do not create that notice, and an output-token limit uses the amber warning dot. + +----- + ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 8b49332ad3..702042384b 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -16,6 +16,7 @@ kind: "package-reference" - [引用预览](#reference-previews) - [系统提示词行](#system-prompt-row) +- [指令与失败行](#command-and-failure-rows) - [轮次 token 用量](#turn-token-usage) - [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) @@ -38,6 +39,11 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注 每个非空追加的 `system/message` 都拥有一行折叠提示词,包括无 header 窗口起点的完整提示词;同一步骤的 header 不会重复它。Chat 也会为非空的初始请求、显式消息序列起点、文本发生变化的 `system/message` surface 节点替换(文本读取自 `request/header` 处 surface 顺序中最后一个非空存活系统节点),或前序 header 尚未进入已加载历史窗口的非初始请求显示一行默认折叠的 `系统提示词`。即使系统文本未变,恢复也会重复该行,包括分页补齐前序 header 和系统节点后;同一序列内仅配置或仅工具变化、工具步骤与重试不会重复,且 `system/message` 事件绝不会渲染为对话消息。该行位于请求的用户消息之前,与提供方 envelope 顺序一致;展开后显示模型所见的确切文本,并保留其原始换行。系统节点为空或位于已加载窗口之外的请求不创建该行,直到包含该节点的分页到达。 + +## 指令与失败行 + +通用指令行在所有生命周期状态中都保留普通指令图标;失败仍通过行状态与摘要明确表达。终止轮次的错误仍是独立的红点提示;模型的中间重试不会创建该提示,达到输出 token 上限时使用琥珀色警告点。 + ----- diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css index 106ac889db..43af8243e1 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.module.css @@ -73,6 +73,6 @@ /* Finalized footer offset (figma 43:32997); chrome lives in MessageIconActions. */ .actions { margin-top: 16px; - /* Optical align with 28px icon hit targets that pad 6px past the glyph. */ + /* Optical align with the 28px action hit targets. */ margin-left: -6px; } diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 4c407c8e8a..12a6f42546 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,7 +7,9 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import type { InboxState } from '@deepseek-ai/dsh-agent/types' -import { Button, IconChevronDownOutline14, MarkdownDelegateProvider, Modal } from '@deepseek-ai/dsh-client-ui-primitives' +import { + Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' @@ -838,7 +840,7 @@ export function ChatView({ if (local !== null) toBottom(scrollerOf(local)) }} > - +
    )} diff --git a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx index b6108daf1a..006b570557 100644 --- a/packages/client/ui-chat/src/client/chat/CompactionItem.tsx +++ b/packages/client/ui-chat/src/client/chat/CompactionItem.tsx @@ -3,9 +3,9 @@ import { memo, useMemo, useState } from 'react' import { - IconApiOutline14, - IconChevronDownOutline14, - IconChevronRightOutline14, + IconApiOutlineRegular, + IconChevronDownOutlineRegular, + IconChevronRightOutlineRegular, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' @@ -56,13 +56,13 @@ export const CompactionItem = memo(function CompactionItem({ > - + - {open ? : } + {open ? : } {title ?? t('message.compaction')} diff --git a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx index c905035119..49704cca03 100644 --- a/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ContextInjectionRow.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { DisclosureRow, IconContextInjectionOutline16, ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconContextInjectionOutlineRegular, ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ContextMessageNode } from '../contract/snapshot.ts' import { contextBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' @@ -38,8 +38,8 @@ export function ContextInjectionRow({ content, source, producer, form, t }: Cont - : } + ? + : } chevronClassName={css.chevron} title={t(producer.role === 'recall' ? 'message.contextRecall' : 'message.contextInjection')} collapsedContent={producer.label === null ? undefined : ( diff --git a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx index f12992a043..2864836301 100644 --- a/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx +++ b/packages/client/ui-chat/src/client/chat/GenericCommandCard.tsx @@ -1,6 +1,6 @@ -import { useState, type ReactNode } from 'react' +import { useState } from 'react' import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts' -import { DisclosureRow, IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconApiOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import a11yCss from './accessibility.module.css' import css from './GenericCommandCard.module.css' @@ -12,10 +12,6 @@ function stateOf(outcome: CommandRowOwnerProps['node']['outcome']): CommandRowSt return outcome.kind === 'error' ? 'error' : 'ok' } -function leadingFor(state: CommandRowState): ReactNode { - return state === 'error' ? : -} - /** Card props: the owner payload plus the render site's locale seat (plain prop). */ export interface GenericCommandCardProps extends CommandRowOwnerProps { t: ChatViewSlotProps['t'] @@ -44,7 +40,7 @@ export function GenericCommandCard({ node, t, runningSummary }: GenericCommandCa leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={leadingFor(state)} + icon={} title={title} open={open} expandable={body !== null} diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css index ad8f6edb6c..c5234236f6 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css @@ -9,9 +9,7 @@ height: calc(28px + var(--dsh-content-font-delta, 0px)); } -/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). - Both read the secondary tier so the user row's clock matches the assistant - tail's meta line. */ +/* The user clock keeps the secondary tier. */ .timeStart { padding-right: 12px; font-size: var(--dsh-content-font-size-secondary, 13px); @@ -20,16 +18,24 @@ white-space: nowrap; } -/* The assistant tail's clock sits at the row end after the stat pills; the - row's 8px flex gap spaces it, and it reads the secondary tier so it matches - the pill labels beside it. */ +/* Assistant-tail metadata keeps the smaller information size while sharing + the action icons' resting color. */ .timeEnd { - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); line-height: calc(24px + var(--dsh-content-font-delta, 0px)); - color: var(--dsw-alias-label-tertiary); + color: inherit; white-space: nowrap; } +.endInfo { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + margin-left: 8px; + color: var(--dsw-alias-label-tertiary); +} + /* Earlier user-authored flow rows and opted-in turn tails reveal the complete actions row on hover or focus-within. Opacity keeps layout stable, and devices without hover keep every row visible. */ @@ -68,13 +74,18 @@ cursor: pointer; } -/* The 15px action glyphs follow the same px delta as the text they serve; - the CSS edge overrides each svg's own width/height attributes. */ +/* Action glyphs follow the same px delta as the text they serve; the + assistant completion row gives them two extra pixels. */ .action svg { width: calc(15px + var(--dsh-content-font-delta, 0px)); height: calc(15px + var(--dsh-content-font-delta, 0px)); } +.actions[data-clock='end'] .action svg { + width: calc(17px + var(--dsh-content-font-delta, 0px)); + height: calc(17px + var(--dsh-content-font-delta, 0px)); +} + .action:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index 5476f1a5c0..fd2b674edd 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { - IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, + IconBranchOutlineRegular, IconCheckOutlineRegular, IconCopyOutlineRegular, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { formatMessageClock } from './message-chrome.ts' @@ -80,11 +80,11 @@ export function MessageIconActions({ ) return ( -
    +
    {clock === 'start' ? clockEl : null} {extraActions} @@ -100,15 +100,16 @@ export function MessageIconActions({ data-unavailable={branchUnavailable || undefined} onClick={branchUnavailable ? undefined : onBranch} > - + )} {onBranch !== undefined && branchUnavailable && ( {t('message.branchUnavailable')} )} - {usageAction} - {clock === 'end' ? clockEl : null} + {clock === 'end' + ? {usageAction}{clockEl} + : usageAction}
    ) } diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 73289e8e3f..4d484af95b 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -1,6 +1,6 @@ /** Assistant reasoning disclosure, independent of Tool-call presentation. */ import { useMemo, useState } from 'react' -import { DisclosureRow, IconThinkOutline14, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { markdownLabels } from '../markdown-labels.ts' import a11yCss from './accessibility.module.css' @@ -44,7 +44,7 @@ export function ReasoningRow({ text, running, t }: { text: string; running: bool leadingClassName={css.leading} titleClassName={css.title} chevronClassName={css.chevron} - icon={} + icon={} title={t('message.think')} open={expanded} expandable diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.module.css b/packages/client/ui-chat/src/client/chat/StatsPills.module.css index a7e307ccfc..988d1aa6cb 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.module.css +++ b/packages/client/ui-chat/src/client/chat/StatsPills.module.css @@ -1,4 +1,4 @@ -/* Two icon pills side by side under the composer, 13/20 tertiary text tier +/* Two icon pills side by side under the composer, 12/20 tertiary text tier with a hover-pill affordance; each pill's click-open panel wears the shared stat-dialog.module.css skin. */ @@ -9,7 +9,7 @@ min-width: 0; max-width: 100%; box-sizing: border-box; - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px)); } diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 0c4b7c1ad7..7141ab323e 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -7,7 +7,7 @@ import { memo, useMemo, useState } from 'react' import { createPortal } from 'react-dom' -import { IconDatabaseOutline16, IconGaugeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: merges the sessionStats key into SessionProjectionMap for useProjection. @@ -164,7 +164,7 @@ function TimePill({ stats, t, dialog }: { return ( - + {label} @@ -180,7 +180,7 @@ function TimePill({ stats, t, dialog }: { aria-label={tps === null ? counts : `${counts} · ${tps}`} onClick={() => { setOpen(!open) }} > - + {label} {open && createPortal( @@ -193,7 +193,7 @@ function TimePill({ stats, t, dialog }: { >
    - + {t('stats.dialog.title')}
    @@ -254,7 +254,7 @@ function UsagePill({ usage, t, dialog }: { aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`} onClick={() => { setOpen(!open) }} > - + {totalText} {cacheHitText !== null && ( @@ -275,7 +275,7 @@ function UsagePill({ usage, t, dialog }: { >
    - + {t('stats.dialog.usageTitle')} {exactCount(total, t)} diff --git a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx index 787c8fb7bf..0136acbfd9 100644 --- a/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx +++ b/packages/client/ui-chat/src/client/chat/SystemPromptRow.tsx @@ -1,6 +1,6 @@ import { memo, useState } from 'react' import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' -import { DisclosureRow, IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconBrowseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { OpaqueBody } from './ContextBody.tsx' import css from './ContextInjectionRow.module.css' @@ -27,7 +27,7 @@ export function SystemPromptRow({ text, update = false, t }: SystemPromptRowProp return ( } + icon={} chevronClassName={css.chevron} title={t(update ? 'message.systemPromptUpdate' : 'message.systemPrompt')} open={open} diff --git a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx index f9210e84db..4969b3f383 100644 --- a/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnProcessNodeView.tsx @@ -1,5 +1,5 @@ import { memo } from 'react' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps } from '../contract/slots.ts' import css from './TurnProcessNodeView.module.css' @@ -54,7 +54,7 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({ }} > {label} - + ) }) diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css index 52058f8be7..58fe2fe556 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css @@ -13,9 +13,7 @@ margin-left: -6px; } -/* Same pill rules as the sibling `.action` icon buttons (28px hit height, - 15px glyph, tertiary → secondary on hover with the interactive hover - background), widened to carry the turn-total label. */ +/* The information pills use the action icons' resting color. */ .trigger { display: inline-flex; align-items: center; @@ -27,7 +25,7 @@ border-radius: 28px; background: transparent; color: var(--dsw-alias-label-tertiary); - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); font-variant-numeric: tabular-nums; line-height: calc(24px + var(--dsh-content-font-delta, 0px)); white-space: nowrap; @@ -51,7 +49,7 @@ .trigger:hover, .trigger[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-tertiary); } /* Narrow viewport: the pills collapse to bare icons with the sibling diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index 0d64ff9e08..765650b5ca 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -4,7 +4,7 @@ // branch action in the tail's IconActions row, ahead of the plain clock text. import { createPortal } from 'react-dom' -import { IconClockOutline16, IconDatabaseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconClockOutlineRegular, IconDatabaseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTokenUsage } from '../contract/chat-nodes.ts' import type { ChatViewSlotProps } from '../contract/slots.ts' import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts' @@ -61,7 +61,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { aria-expanded={open} onClick={() => { setOpen(!open) }} > - + {t('message.turnUsage.consumed', { total })} {open && createPortal( @@ -74,7 +74,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) { >
    - + {t('message.turnUsage.title')} {formatExactCount(usage.totalTokens, t)} @@ -140,7 +140,7 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan aria-expanded={open} onClick={() => { setOpen(!open) }} > - + {t('message.ranFor', { duration: formatRunDuration(runMs, t) })} {open && createPortal( @@ -153,7 +153,7 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan >
    - + {t('message.turnTime.title')}
    diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css index cc30711a68..dda23ed176 100644 --- a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css @@ -21,6 +21,7 @@ border: 0; border-radius: 12px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); font-size: 12px; diff --git a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx index f2ab902852..b3b188c69c 100644 --- a/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx +++ b/packages/client/ui-chat/src/client/settings/TranscriptViewRow.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { TranscriptViewMode } from '../../chat-settings.ts' import type { ChatKey } from '../locale.ts' @@ -54,7 +54,7 @@ export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: T onClick={() => { setOpen(value => !value) }} > {t(selectedLabel)} - + ) diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index 00e87bdcb6..4edce88336 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -48,14 +48,13 @@ describe('chat flow font-size axis', () => { it('the message clock and action glyphs scale with the text they serve', () => { const actions = read('MessageIconActions.module.css') - // Both clocks read the secondary tier: the assistant tail's meta line - // (the whole-line usage trigger) and the user row's clock stay one step - // under the body size so the two rows match. + // The user clock reads the secondary tier. The assistant tail is one + // further pixel down, matching its usage trigger and supporting metadata. expect(declarationsFrom(actions, '.timeStart')).toEqual(expect.arrayContaining([ 'font-size: var(--dsh-content-font-size-secondary, 13px)', ])) expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px)', ])) expect(declarationsFrom(actions, '.action svg')).toEqual(expect.arrayContaining([ 'width: calc(15px + var(--dsh-content-font-delta, 0px))', @@ -92,7 +91,7 @@ describe('chat flow font-size axis', () => { it('the usage-details trigger reads the secondary tier like its clock label', () => { const css = read('TurnUsagePanel.module.css') expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([ - 'font-size: var(--dsh-content-font-size-secondary, 13px)', + 'font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px)', 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))', ])) }) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index f974b064c0..d62466703f 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -2836,6 +2836,7 @@ describe('ChatView', () => { }) const fv = render() expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull() + expect(fv.container.querySelector('[data-state="error"] svg')).not.toBeNull() expect(fv.getByText('指令失败')).toBeTruthy() expect(fv.getByText('失败')).toBeTruthy() diff --git a/packages/client/ui-commands/src/client/PopupSelectView.module.css b/packages/client/ui-commands/src/client/PopupSelectView.module.css index f8ef1c15f0..85e8b6d30a 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.module.css +++ b/packages/client/ui-commands/src/client/PopupSelectView.module.css @@ -1,6 +1,4 @@ -/* Official popupSelect shell card: menu-surface tokens (same family as - * ui-primitives Menu.module.css — figma MenuDropdown r12 / hairline / - * elevation-prominent), anchored by the conversation.input.overlay slot. */ +/* popupSelect uses the same compact translucent card as the shared Menu. */ .card { /* The overlay anchor is a zero-height strip on the composer card's top @@ -9,7 +7,7 @@ bottom: calc(100% + 4px); left: 0; z-index: 100; - padding: 4px; + padding: 3px; display: flex; flex-direction: column; min-width: min(220px, 100%); @@ -25,8 +23,9 @@ --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); border: 0; - border-radius: 20px; + border-radius: 16px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); outline: none; @@ -42,11 +41,11 @@ .row { display: flex; align-items: center; - gap: 8px; - padding: 6px 8px; - border-radius: 8px; + gap: 6px; + padding: 5px 7px; + border-radius: 7px; cursor: pointer; - font-size: 13px; + font-size: 12px; color: var(--dsw-alias-label-primary); } @@ -83,7 +82,7 @@ .detail { flex: 1; min-width: 0; - font-size: 12px; + font-size: 11px; color: var(--dsw-alias-label-tertiary); white-space: nowrap; overflow: hidden; @@ -97,19 +96,24 @@ color: var(--dsw-alias-label-primary); } +.check svg { + width: 14px; + height: 14px; +} + .status { - padding: 8px 10px; - font-size: 13px; + padding: 7px 8px; + font-size: 12px; color: var(--dsw-alias-label-tertiary); } .search { - margin: 2px 2px 4px; - padding: 6px 8px; + margin: 2px 2px 3px; + padding: 5px 7px; border: 0.5px solid var(--dsw-alias-border-inverted); border-radius: 8px; background: transparent; - font-size: 13px; + font-size: 12px; color: var(--dsw-alias-label-primary); outline: none; } @@ -117,9 +121,9 @@ .error { display: flex; align-items: center; - gap: 8px; - padding: 6px 8px; - font-size: 12px; + gap: 6px; + padding: 5px 7px; + font-size: 11px; color: var(--dsw-alias-state-error-primary); } @@ -130,11 +134,11 @@ } .retry { - padding: 2px 8px; + padding: 2px 7px; border: 0.5px solid var(--dsw-alias-border-inverted); border-radius: 6px; background: transparent; - font-size: 12px; + font-size: 11px; color: var(--dsw-alias-label-primary); cursor: pointer; } diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 8ce2cc64a3..f148ff9392 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -13,7 +13,7 @@ import { useEffect, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutline16, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' import type { PopupSelectController } from './popup.ts' @@ -168,7 +168,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { {option.badge !== undefined && {option.badge}} {option.detail !== undefined && {option.detail}} - {option.active === true && } + {option.active === true && }
    ))}
    diff --git a/packages/client/ui-commands/src/client/presentation.ts b/packages/client/ui-commands/src/client/presentation.ts index 0bcee9cfc9..32d03930b2 100644 --- a/packages/client/ui-commands/src/client/presentation.ts +++ b/packages/client/ui-commands/src/client/presentation.ts @@ -2,8 +2,8 @@ import type { ComponentType } from 'react' import type { InputTriggerCandidate } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { - IconCompactOutline16, IconDownloadOutline16, IconGoalOutline16, IconPaperPlaneOutline14, IconPlanOutline14, - IconShieldOutline16, + IconCompactOutlineRegular, IconDownloadOutlineRegular, IconGoalOutlineRegular, IconPaperPlaneOutlineRegular, IconPlanOutlineRegular, + PermissionIconFullAccessRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' @@ -39,12 +39,12 @@ function hostFace(name: BuiltinCommandName, icon: ComponentType): rea /** Built-in Host commands whose client face this package owns. */ const HOST_FACES: ReadonlyMap = new Map([ - hostFace('goal', IconGoalOutline16), - hostFace('plan', IconPlanOutline14), - hostFace('feedback', IconPaperPlaneOutline14), - hostFace('compact', IconCompactOutline16), - hostFace('permission', IconShieldOutline16), - hostFace('export', IconDownloadOutline16), + hostFace('goal', IconGoalOutlineRegular), + hostFace('plan', IconPlanOutlineRegular), + hostFace('feedback', IconPaperPlaneOutlineRegular), + hostFace('compact', IconCompactOutlineRegular), + hostFace('permission', PermissionIconFullAccessRegular), + hostFace('export', IconDownloadOutlineRegular), ]) /** diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 9dab8a0307..10979ac26c 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -14,7 +14,7 @@ import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import { createScope, scopeOf } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, TestRemote } from '@deepseek-ai/dsh-client-test-runtime' -import { IconGoalOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconGoalOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ClientSessionContext, ConsumeTokenRequest, InputTriggerPick, InputTriggerSource, SubmitAttachment } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import type { CommandContribution, CommandDecoration, PopupSelectSpec, SelectOption } from '../src/client/contract.ts' import type { CommandDescriptor } from '../src/client/directory.ts' @@ -360,7 +360,7 @@ describe('candidates', () => { name: 'goal', label: 'command:label.goal', description: 'command:description.goal', - icon: IconGoalOutline16, + icon: IconGoalOutlineRegular, hint: '', section: 'command:section.add', }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 0bf28301d0..7d53cf613a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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-conversation/README.md -README.md: bc3b582f9d80e97c29e27dcaa680dc064ec30deb -README.zh.md: 21881e7933cdf46cf9e27a66f076fc40c48e4b1a +README.md: 3bd965a13402b37ef2f45380dc51facfef58e0f2 +README.zh.md: f348ed31200801e726ed5383a3e0c968f84ca2d6 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index bc3b582f9d..3bd965a134 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -48,7 +48,7 @@ Claimed commands retain their identity and highlight when only their arguments a Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft text and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. -The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. +The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the strict Session Header outside the optional-Session `conversation.content` Component Factory. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent. Sidebar entries retain their own data and execution prerequisites. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 21881e7933..f348ed3120 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -48,7 +48,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map, 工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿文字和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 -本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 +本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` shell 将 strict Session Header 保留在 optional-Session `conversation.content` Component Factory 外。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载。侧栏各入口仍遵循自身的数据与执行环境要求。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 79ffd97359..bb83fcdbef 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { ISessions, SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' -import { IconPaperclipOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconPaperclipOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import { createSnapshotStore, type BoundActions } from '@deepseek-ai/dsh-client-store' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -99,7 +99,7 @@ interface FileCommandRegistry { register(contribution: { name: string label(): string - icon: typeof IconPaperclipOutline16 + icon: typeof IconPaperclipOutlineRegular available(session: { sessionId: SessionId }): boolean ui: { kind: 'action'; run(session: { sessionId: SessionId }): void } }): () => void @@ -209,7 +209,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { scope.effect(() => commands.register({ name: 'file', label: () => t('input.file'), - icon: IconPaperclipOutline16, + icon: IconPaperclipOutlineRegular, available: session => inputHub.canPickFiles(session.sessionId), ui: { kind: 'action', run: (session) => { inputHub.pickFiles(session.sessionId) } }, }), 'ui-conversation: File action') diff --git a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx index e517bfbf3f..a4dd75dbce 100644 --- a/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx +++ b/packages/client/ui-conversation/src/client/input/editor/ReferenceChip.tsx @@ -5,7 +5,7 @@ */ import clsx from 'clsx' import type { ReactNode } from 'react' -import { ReferenceIcon } from '@deepseek-ai/dsh-client-ui-primitives' +import { ReferenceIconRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ReferenceIconKind } from '@deepseek-ai/dsh-client-ui-primitives' import css from './ReferenceChip.module.css' import referenceCss from './composer-editor.module.css' @@ -29,7 +29,7 @@ export function ReferenceChip({ label, appearance, invalid }: ReferenceChipProps {appearance === undefined ? @ - : } + : } {label} ) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index d61f52ef82..ceb15ad6f5 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -75,6 +75,9 @@ export const zh = { 'todo.progress.done': '{done} 已完成', 'todo.progress.active': '{active} 进行中', 'todo.progress.pending': '{pending} 待处理', + 'todo.status.completed': '已完成', + 'todo.status.inProgress': '进行中', + 'todo.status.pending': '待处理', 'todo.rowTitle': '更新任务清单', 'todo.completed': '{done}/{total} 已完成', 'command.attachmentsUnsupported': '/{command} 不接受附件,请先移除附件', @@ -237,6 +240,9 @@ export const en = { 'todo.progress.done': '{done} completed', 'todo.progress.active': '{active} in progress', 'todo.progress.pending': '{pending} pending', + 'todo.status.completed': 'Completed', + 'todo.status.inProgress': 'In progress', + 'todo.status.pending': 'Pending', 'todo.rowTitle': 'Update to-do list', 'todo.completed': '{done}/{total} completed', 'command.attachmentsUnsupported': '/{command} does not accept attachments; remove them first', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 04288d206f..5d5d929eb0 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -31,7 +31,8 @@ width: 100%; padding: 2px 0; border-radius: 12px 12px 0 0; - background: var(--dsw-specific-tip); + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index e9447afd69..22f0730795 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,9 +4,9 @@ import type { FileAttachmentRef, ImageAttachmentRef } from '@deepseek-ai/dsh-att import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - IconCheckOutline16, IconChevronDownOutline14, IconChevronUpOutline14, IconCloseOutline16, - FileTypeIcon, fileSizeText, IconEditOutline16, IconQueueOutline14, IconSendOutline14, - IconTrashOutline16, projectUserText, Tooltip, + IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconCloseOutlineRegular, + FileTypeIcon, fileSizeText, IconEditOutlineRegular, IconQueueOutlineRegular, IconSendOutlineRegular, + IconTrashOutlineRegular, projectUserText, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { QueueAction } from '@deepseek-ai/dsh-api-session-controller/types' @@ -175,13 +175,13 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load disabled={interactionActive} onClick={() => { setCollapsed(value => !value) }} > - + {t('queue.count', { n: rowCount })} {!listVisible && pendingQueue.length > 0 && ( {t('queue.sending')} )} - {expanded ? : } + {expanded ? : } )} @@ -192,7 +192,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load return (
  • {/* Single-item strip has no count header, so the row itself carries the queue glyph. */} - {rowCount === 1 && } + {rowCount === 1 && } {editing?.id === row.id ? ( { void saveEdit() }} > - + @@ -261,7 +261,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load disabled={busy !== null} onClick={() => { setEditing(null) }} > - + @@ -281,7 +281,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load if (text !== null) setEditing({ id: row.id, text: text }) }} > - + @@ -298,7 +298,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load ) }} > - + @@ -316,7 +316,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load ) }} > - + @@ -328,7 +328,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load {listVisible && pendingQueue.map((submission) => { return (
  • - {rowCount === 1 && } + {rowCount === 1 && } {submission.attachments.length > 0 && ( {submission.attachments.map((attachment, index) => attachment.type === 'image' @@ -359,7 +359,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load title={t('queue.sending')} disabled > - +
  • } diff --git a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx index 07ebd615b3..cf8588067f 100644 --- a/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx +++ b/packages/client/ui-conversation/src/client/settings/EnterBehaviorRow.tsx @@ -2,7 +2,7 @@ import { useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives' import type { BusyEnterBehavior } from '../contract/composer-submission.ts' import type { ConversationKey } from '../locales.ts' import css from './EnterBehaviorRow.module.css' @@ -67,7 +67,7 @@ export function EnterBehaviorRow({ useBusyEnter, setBusyEnter, t }: EnterBehavio onClick={() => { setOpen(value => !value) }} > {t(selectedLabel)} - + )} /> diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css index a6a3e4c189..989377497d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css @@ -52,6 +52,7 @@ border: 0; border-radius: 12px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); font-size: 12px; diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 22be826845..40df87269b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import type { ReactNode, RefObject } from 'react' import { - FISH_LOGO_PATH, FISH_LOGO_VIEWBOX, IconChevronDownOutline14, IconFolderClose16, IconFolderOpen16, + FISH_LOGO_PATH, FISH_LOGO_VIEWBOX, IconChevronDownOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { ConversationContentProps } from '../contract/slots.ts' @@ -53,10 +53,10 @@ export function WorkspaceChip({ buttonRef, label, menuOpen = false, onClick, t } onClick={onClick} > {label === undefined - ? - : } + ? + : } {label ?? t('hero.chooseWorkspace')} - + ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8d321137c3..2ee0f6d16d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -76,12 +76,9 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -/* No-workspace trigger state: dashed l4 stroke marks the card as a pick-a- - workspace affordance rather than a live composer; hover answers in the - business blue to invite the click. Native `dashed` has a fixed browser - pattern, so the stroke is an ::after overlay: theme-token background masked - by an SVG dash ring (stroke-width 2 centered on the box edge = 1px visible - inside), which keeps the 22px radius and both themes. */ +/* No-workspace trigger state: an inset 1px dashed outline marks the card as a + workspace picker rather than a live composer. Keeping the complete stroke + inside the rounded box prevents clipped corner pixels from reading heavier. */ .cardWorkspaceTrigger { --dsw-elevation-stroke-color: transparent; cursor: pointer; @@ -90,12 +87,12 @@ .cardWorkspaceTrigger::after { content: ''; position: absolute; - inset: -1px; + inset: 0; + box-sizing: border-box; + border: 1px dashed currentColor; border-radius: 22px; - background: var(--dsw-alias-border-l4); - transition: background-color 100ms ease; - -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); - mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + color: var(--dsw-alias-border-l4); + transition: color 100ms ease; pointer-events: none; } @@ -107,7 +104,7 @@ } .cardWorkspaceTrigger:hover::after { - background: var(--dsw-alias-state-business-primary); + color: var(--dsw-alias-state-business-primary); } .accessory { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 319628d266..6a4ea12740 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react' import clsx from 'clsx' import { - IconPlusOutline16, IconWarningOutline16, Toast, Tooltip, + IconPlusOutlineMedium, IconWarningOutlineRegular, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -347,7 +347,7 @@ export const InputBar = memo(function InputBar({ } + icon={} anchor={cardRef.current} onDone={dismissToast} /> @@ -418,7 +418,7 @@ export const InputBar = memo(function InputBar({ onMouseDown={keepFocus} onClick={onToggleCommandMenu} > - + - - - - ) -} - -/** In-progress: business-blue ring fading out; CSS spins the svg. */ -function ProgressGlyph() { - const gradientId = useId() - return ( - - ) -} - -/** Pending: dashed unstarted ring (figma dash 2.4 2.4). */ -function PendingGlyph() { - return ( - - ) -} - -function StatusGlyph({ status }: { status: TodoItem['status'] }) { +/** Map Todo lifecycle state onto the shared compact status language. */ +function statusDotState(status: TodoItem['status']): StateDotState { switch (status) { - case 'completed': return - case 'in_progress': return - case 'pending': return + case 'completed': return 'done' + case 'in_progress': return 'ongoing' + case 'pending': return 'idle' + /* v8 ignore next -- closed TodoItem status union */ + default: return assertNever(status) + } +} + +/** Return the localized status announced beside one decorative marker. */ +function statusLabel(status: TodoItem['status'], t: TodoPanelProps['t']): string { + switch (status) { + case 'completed': return t('todo.status.completed') + case 'in_progress': return t('todo.status.inProgress') + case 'pending': return t('todo.status.pending') /* v8 ignore next -- closed TodoItem status union */ default: return assertNever(status) } @@ -98,18 +75,20 @@ export function TodoPanel({ todos, t }: TodoPanelProps) { aria-expanded={!collapsed} onClick={() => { setCollapsed(v => !v) }} > - + {t('todo.title')} {progressLabel(todos, t)} - {collapsed ? : } + {collapsed ? : } {!collapsed && (
      {todos.map(item => (
    • - + + + {item.content}
    • ))} diff --git a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx index e79081b003..248c3cd92f 100644 --- a/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.client.spec.tsx @@ -64,8 +64,10 @@ describe('TodoPanel', () => { expect(items.map(li => li.getAttribute('data-status'))).toEqual(['completed', 'in_progress', 'pending']) expect(screen.getByText('搭骨架')).toBeTruthy() expect(screen.getByText('写组件')).toBeTruthy() - // Each status row carries an SVG glyph (not a text bullet). - expect(items.every(li => li.querySelector('svg') !== null)).toBe(true) + expect(items.map(li => li.querySelector('[data-state]')?.getAttribute('data-state'))) + .toEqual(['done', 'ongoing', 'idle']) + expect(screen.getAllByRole('img').map(node => node.getAttribute('aria-label'))) + .toEqual(['已完成', '进行中', '待处理']) }) it('collapse hides an expanded list; expand restores; header keeps the count summary', () => { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 041ec3fa6a..4544ccd159 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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: 46e289ddb572928baaee7ac1a370087fd789aebe -README.zh.md: 804ee5c7613510df96ac48cfe08f9b0fd39f7bbc +README.md: 8a2da4e763d3682dcf45c4a6f9529804c5d1bcc8 +README.zh.md: 7af5571e0fa5aeda323424fd54df9fa7e3878365 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 46e289ddb5..8a2da4e763 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -32,7 +32,7 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply. -The `present` tool row shows running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row retains the delivery-document glyph while showing running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 804ee5c761..7af5571e0f 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -32,7 +32,7 @@ kind: "package-reference" Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。 -`present` 工具行显示正在交付、已交付、失败或中断状态;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行在正在交付、已交付、失败或中断状态下都保留交付文档图标;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index cb0d5c1a05..6fd4e72ace 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,7 +1,7 @@ /** The changed-files card: a header and per-file rows that open the turn's review, and a three-row fold. */ import { useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' -import { IconChevronDownOutline14, IconChevronUpOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ChangesSummary } from '../changes.ts' import { IconCodeBracketsOutline16 } from './icons.tsx' @@ -66,7 +66,7 @@ export function ChangedFiles({ changes, cwd, openReview, t }: { aria-label={t(expanded ? 'changes.collapseAria' : 'changes.expandAria', { count: String(changes.files.length) })} onClick={() => { setExpanded(value => !value) }}> {t(expanded ? 'changes.collapse' : 'changes.all', { count: String(changes.files.length) })} - {expanded ? : } + {expanded ? : } }
    } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 112bc68070..e463734319 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -1,7 +1,7 @@ /** The changed-files card, shown only while the Host serves the turn's summary, and explicitly declared files for a closing turn. */ 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 { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' 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' @@ -110,7 +110,7 @@ export function Deliverables({ aria-label={t(expanded ? 'presented.collapseAria' : 'presented.expandAria', { count: matched.presented.length })} onClick={() => { setExpanded(value => !value) }}> {t(expanded ? 'presented.collapse' : 'presented.all', { count: matched.presented.length })} - {expanded ? : } + {expanded ? : } }
    } diff --git a/packages/client/ui-deliverables/src/client/PresentRow.tsx b/packages/client/ui-deliverables/src/client/PresentRow.tsx index 845d2b4dcc..04c37677fd 100644 --- a/packages/client/ui-deliverables/src/client/PresentRow.tsx +++ b/packages/client/ui-deliverables/src/client/PresentRow.tsx @@ -1,6 +1,6 @@ /** Present call status and expandable durable result text. */ import { useState } from 'react' -import { DisclosureRow, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { DisclosureRow, IconDeliverDocRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { NS } from './locales.ts' @@ -34,7 +34,7 @@ export function PresentRow({ block, inspect, t }: PresentRowProps) { const [expanded, setExpanded] = useState(false) return
    } + icon={} open={expanded && details !== ''} expandable={details !== ''} expandOnRowClick keepContentWhenOpen onToggle={() => { setExpanded(value => !value) }} collapsedContent={{t(`row.${state}`)}{fileNames(args)}}> diff --git a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx index 5466473999..97fd431e32 100644 --- a/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx +++ b/packages/client/ui-deliverables/src/client/PresentedFileCard.tsx @@ -2,8 +2,8 @@ import { useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { - Menu, FileTypeIcon, fileExtension, IconRightUpOutline16, - IconChevronDownOutline14, IconFolderOpenOutline16, + Menu, FileTypeIcon, fileExtension, IconRightUpOutlineRegular, + IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { PresentedAction, PresentedHost } from '../presented.ts' @@ -70,12 +70,12 @@ export function PresentedFileCard({ file, cwd, phase, host, onPreview, onAction, aria-haspopup="menu" aria-expanded={menuOpen && !menuDisabled} aria-label={t('presented.more', { name: file.path })} onClick={() => { setMenuOpen(value => !value) }}> - + } items={[ - { id: 'open', icon: , + { id: 'open', icon: , label: t('presented.defaultApp') }, - { id: 'reveal', icon: , + { id: 'reveal', icon: , label: t(`presented.${reveal}`) }, ]} onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 35a4350e6c..7ea29b8e45 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -6,7 +6,8 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, UIEvent } from 'react' import { - Button, IconChevronDownOutline14, IconCodeOutline16, IconPanelLeftOutline16, IconRightUpOutline16, IconWrapLinesOutline16, Menu, Tooltip, + Button, IconChevronDownOutlineRegular, IconCodeOutlineRegular, IconPanelLeftOutlineRegular, + IconRightUpOutlineRegular, IconWrapLinesOutlineRegular, Menu, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' @@ -207,7 +208,7 @@ export function ReviewTab({ aria-label={t('review.selectFile')} title={file.display} data-review-file={file.path} onClick={() => { setMenuOpen(value => !value) }}> {file.display} - + } items={files.map((entry, at) => ({ id: String(at), label: {entry.display} @@ -219,20 +220,20 @@ export function ReviewTab({ + onClick={() => { actions.toggledSplit(tab.id) }}> + onClick={() => { actions.toggledWrap(tab.id) }}> {file !== undefined && + onClick={() => { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}> } {file !== undefined && native && + onClick={() => { void openChanged(sessionId, seq, index) }}> }
    diff --git a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx index 05d6b99540..870d54df97 100644 --- a/packages/client/ui-deliverables/tests/present-row.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/present-row.client.spec.tsx @@ -20,6 +20,8 @@ it('discloses the saved result and offers call inspection', () => { const view = render() expect(view.getByText('Delivered')).toBeTruthy() expect(view.getByText('report.txt')).toBeTruthy() + expect(view.container.querySelector('[data-tool="present"] svg')).not.toBeNull() + expect(view.container.querySelector('[data-tool="present"] [data-state]')).toBeNull() expect(view.queryByText('Presented report.txt (4 bytes)')).toBeNull() const row = view.getByRole('button') fireEvent.keyDown(row, { key: 'Enter' }) @@ -37,6 +39,8 @@ it.each([ ] as const)('renders call lifecycle without claiming failed delivery', (block, state, label) => { const view = render() expect(view.container.querySelector('[data-tool="present"]')?.getAttribute('data-state')).toBe(state) + expect(view.container.querySelector('[data-tool="present"] svg')).not.toBeNull() + expect(view.container.querySelector('[data-tool="present"] [data-state]')).toBeNull() expect(view.getByText(label)).toBeTruthy() expect(view.queryByText('Delivered')).toBeNull() }) diff --git a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx index d96e513c34..56eb0bece3 100644 --- a/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/client/ui-directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -37,8 +37,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { - Button, IconCheckOutline16, IconChevronRightOutline14, IconEditOutline16, IconFolderClose16, IconFolderOpen16, - IconPlusOutline16, Modal, + Button, IconCheckOutlineRegular, IconChevronRightOutlineRegular, IconEditOutlineRegular, IconFolderCloseRegular, IconFolderOpenRegular, + IconPlusOutlineRegular, Modal, } from '@deepseek-ai/dsh-client-ui-primitives' import type { DirectoryEntry, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { Translate } from '@deepseek-ai/dsh-client-locale/client' @@ -253,10 +253,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr onClick={() => { onPick(entry) }} > {selected - ? - : } + ? + : } {entry.name} - + ) @@ -824,7 +824,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {crumbs.map((crumb, index) => ( - {index > 0 && } + {index > 0 && } ) @@ -964,7 +964,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
    diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index d929339953..a1efe4ce40 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -16,7 +16,7 @@ import { useState } from 'react' import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' import clsx from 'clsx' -import { IconCloseOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' @@ -135,7 +135,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, onPointerDown={(event) => { event.stopPropagation() }} onClick={() => { intents.unfloatPane(paneId) }} > - + {(canCloseTab?.(tab.id) ?? true) && ( @@ -148,7 +148,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, onPointerDown={(event) => { event.stopPropagation() }} onClick={() => { intents.closeTab(tab.id) }} > - + )} diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index 47f025dab5..ae7d2e6fa7 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -23,7 +23,7 @@ import { Fragment, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode, RefObject } from 'react' import clsx from 'clsx' -import { IconCloseFill14, IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular, IconPlusOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockZone, LayoutState, PaneNode, TabId } from '../contract/types.ts' import { getTab } from '../engine/tree.ts' import type { PaneCallbacks, SplitBlock } from './render.ts' @@ -38,11 +38,12 @@ import css from './dockkit.module.css' */ const PANEL_FRAME = 'M9.67272 0.522841C10.8339 0.522841 11.76 0.522714 12.4963 0.602493C13.2453 0.683657 13.8789 0.854248 14.4264 1.25197C14.7504 1.48739 15.0355 1.77247 15.2709 2.0965C15.6686 2.64394 15.8392 3.27758 15.9204 4.02655C16.0002 4.7629 16 5.68895 16 6.85014V9.14986C16 10.3111 16.0002 11.2371 15.9204 11.9735C15.8392 12.7224 15.6686 13.3561 15.2709 13.9035C15.0355 14.2275 14.7504 14.5126 14.4264 14.748C13.8789 15.1458 13.2453 15.3163 12.4963 15.3975C11.76 15.4773 10.8339 15.4772 9.67272 15.4772H6.3273C5.16611 15.4772 4.24006 15.4773 3.50371 15.3975C2.75474 15.3163 2.1211 15.1458 1.57366 14.748C1.24963 14.5126 0.964549 14.2275 0.729131 13.9035C0.331407 13.3561 0.160817 12.7224 0.0796529 11.9735C-0.000126137 11.2371 1.25338e-09 10.3111 1.25338e-09 9.14986V6.85014C1.25329e-09 5.68895 -0.000126137 4.7629 0.0796529 4.02655C0.160817 3.27758 0.331407 2.64394 0.729131 2.0965C0.964549 1.77247 1.24963 1.48739 1.57366 1.25197C2.1211 0.854248 2.75474 0.683657 3.50371 0.602493C4.24006 0.522714 5.16611 0.522841 6.3273 0.522841H9.67272ZM4.1828 14.0873L5.54303 14.1118C5.78636 14.1128 6.04709 14.1169 6.3273 14.1169H9.67272C10.8639 14.1169 11.7032 14.1164 12.3493 14.0465C12.9824 13.9779 13.3497 13.8494 13.6268 13.6482C13.8354 13.4966 14.0195 13.3125 14.1711 13.1039C14.3723 12.8268 14.5007 12.4595 14.5693 11.8264C14.6393 11.1803 14.6398 10.341 14.6398 9.14986V6.85014C14.6398 5.65896 14.6393 4.81967 14.5693 4.1736C14.5007 3.54048 14.3723 3.17318 14.1711 2.89609C14.0195 2.68747 13.8354 2.50337 13.6268 2.35179C13.3497 2.1506 12.9824 2.02212 12.3493 1.95353C11.7032 1.88358 10.8639 1.88307 9.67272 1.88307H6.3273C6.04709 1.88307 5.78636 1.8862 5.54303 1.88715L4.1828 1.91166C3.99125 1.9216 3.8148 1.93577 3.65076 1.95353C3.01764 2.02212 2.65034 2.1506 2.37325 2.35179C2.16463 2.50337 1.98052 2.68747 1.82895 2.89609C1.62776 3.17318 1.49928 3.54048 1.43069 4.1736C1.36074 4.81967 1.36023 5.65896 1.36023 6.85014V9.14986C1.36023 10.341 1.36074 11.1803 1.43069 11.8264C1.49928 12.4595 1.62776 12.8268 1.82895 13.1039C1.98052 13.3125 2.16463 13.4966 2.37325 13.6482C2.65034 13.8494 3.01764 13.9779 3.65076 14.0465C3.81478 14.0642 3.99127 14.0774 4.1828 14.0873Z' -/** The split control's glyph: the panel frame with its divider moved to the centre. */ +/** The split control's glyph from the shared product artwork. */ function SplitGlyph(): ReactNode { return ( ) } @@ -336,7 +337,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { callbacks.onCloseTab(tabId) }} > - + )} {menu?.tabId === tabId && ( @@ -366,7 +367,7 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { callbacks.onAddTab(pane.id) }} > - + )} diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index d1a95a74c3..55279ecbe1 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -450,10 +450,11 @@ display: flex; flex-direction: column; min-width: 96px; - padding: 4px; - background: var(--dsw-alias-bg-layer-3); + padding: 3px; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 6px; + border-radius: 8px; } .menu:not(:has([role='menuitem'], [role='menuitemcheckbox'], [role='menuitemradio'])) { @@ -461,13 +462,13 @@ } .menuItem { - padding: 5px 8px; + padding: 4px 7px; color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size-secondary, 13px); + font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px); text-align: left; background: transparent; border: none; - border-radius: 4px; + border-radius: 5px; cursor: pointer; } diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 62cc82fdc4..36f5e15909 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -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-goal/README.md -README.md: 4a2c0c18f41cd4965ee8170ec7a93843d7427769 -README.zh.md: 1d3d6dd7aef4a2b37c273a7a43e09bed05d33f9d +README.md: 7e6e0502db9720f788711fa3e98cab1581cc8738 +README.zh.md: 7e3fb604469ec73238f0843fdc95e6ea175c1d03 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 4a2c0c18f4..7e6e0502db 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -25,7 +25,7 @@ The Web GUI goal surface shows both the durable goal state and its current proce ## Use this package -Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective; clear removes the goal and suppresses the strip until the projection catches up. +Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective; clear removes the goal and suppresses the strip until the projection catches up. ### The command-input bubble diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 1d3d6dd7ae..7e3fb60446 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -25,7 +25,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激 ## 使用本包 -与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。 +与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。 ### 指令输入气泡 diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index 31e78aecd1..0ac60d6675 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -26,9 +26,12 @@ height: 36px; margin: 0 auto; padding: 4px 5px 4px 12px; - border: 0.5px solid var(--dsw-alias-border-l1); + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); border-radius: 12px; - background: var(--dsw-specific-tip); + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + box-shadow: var(--dsw-elevation-panel); } .goalGlyph { diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index 905bdb7631..e0ee681fd9 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -11,8 +11,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import type { GoalActivation, GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { - IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconGoalOutline16, - IconPauseOutline16, IconPlayOutline16, IconTrashOutline16, Tooltip, + IconCheckOutlineRegular, IconCloseOutlineRegular, IconEditOutlineRegular, IconGoalOutlineRegular, + IconPauseOutlineRegular, IconPlayOutlineRegular, IconTrashOutlineRegular, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import type { GoalActionResult, GoalBarActions, GoalBarInjected } from './slots.ts' @@ -111,7 +111,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, disabled={pending || draft.trim() === ''} aria-label={t('action.save')} > - + @@ -122,7 +122,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, disabled={pending} aria-label={t('action.cancel')} > - +
    @@ -138,7 +138,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, return (
    - + {label} {goal.objective} {actionError !== null && {actionError}} @@ -146,14 +146,14 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, {goal.phase === 'active' && activation === 'armed' && ( )} {showResume && ( )} @@ -165,12 +165,12 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, onClick={() => { setDraft(goal.objective); setEditing(true) }} aria-label={t('action.edit')} > - +
    diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css index cb6ff3ec3a..7b0a2818e4 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -15,17 +15,18 @@ (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); - /* A 10px draggable rail carries a quiet 4px thumb, inset from the card's + /* A 6px draggable rail carries a quiet 2px thumb, inset from the card's rounded ends. ui-theme's global scrollbar sheet renders these values. */ - --dsh-scrollbar-width: 10px; - --dsh-scrollbar-thumb-border: 3px; + --dsh-scrollbar-width: 6px; + --dsh-scrollbar-thumb-border: 2px; --dsh-scrollbar-track-margin: 12px; - padding: 4px; + padding: 3px; display: flex; flex-direction: column; border: 0; - border-radius: 20px; + border-radius: 16px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); } @@ -53,16 +54,16 @@ .item { display: flex; align-items: center; - gap: 8px; + gap: 6px; width: 100%; - min-height: 40px; - padding: 8px 10px; + min-height: 34px; + padding: 6px 8px; border: none; - border-radius: 10px; + border-radius: 8px; background: transparent; cursor: pointer; - font-size: 14px; - line-height: 22px; + font-size: 13px; + line-height: 20px; color: var(--dsw-alias-label-primary); text-align: left; } @@ -75,28 +76,33 @@ .sectionTitle { flex: none; - min-height: 26px; - padding: 6px 10px 2px; + min-height: 23px; + padding: 5px 8px 2px; color: var(--dsw-alias-label-tertiary); - font-size: 12px; + font-size: 11px; font-weight: 500; - line-height: 18px; + line-height: 16px; } .sectionTitle:not(:first-child) { - margin-top: 4px; + margin-top: 3px; } .itemIcon { display: inline-flex; flex: none; - width: 16px; - height: 16px; + width: 14px; + height: 14px; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); } +.itemIcon svg { + width: 14px; + height: 14px; +} + .itemName { flex: none; max-width: 40%; @@ -114,6 +120,8 @@ text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; } /* Right-aligned so every description ends at the same edge and the titles @@ -126,6 +134,8 @@ white-space: nowrap; text-align: right; color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; } /* Trailing seat on drillable rows (directories): the Tab keycap hint plus the @@ -134,7 +144,7 @@ flex: none; display: inline-flex; align-items: center; - gap: 4px; + gap: 3px; margin-left: auto; } @@ -144,20 +154,20 @@ .drillHintText { display: none; color: var(--dsw-alias-label-caption); - font-size: 11px; - line-height: 18px; + font-size: 10px; + line-height: 16px; white-space: nowrap; } .drillHint { display: none; - padding: 0 5px; + padding: 0 4px; border-radius: 4px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-caption); font-family: inherit; - font-size: 11px; - line-height: 18px; + font-size: 10px; + line-height: 16px; } .item.active .drillHintText, @@ -171,8 +181,8 @@ flex: none; display: inline-grid; place-items: center; - width: 20px; - height: 20px; + width: 18px; + height: 18px; border-radius: 4px; /* label-caption, the quiet tint the composer's access-mode chevron uses. */ color: var(--dsw-alias-label-caption); @@ -186,9 +196,9 @@ /* Heading row above a source group: non-interactive small grey text, * padding aligned with items (mirrors ui-primitives Menu .label). */ .groupTitle { - padding: 8px 10px; - font-size: 12px; - line-height: 16px; + padding: 6px 8px; + font-size: 11px; + line-height: 15px; color: var(--dsw-alias-label-tertiary); } @@ -198,15 +208,14 @@ .skeletonRow { display: flex; align-items: center; - /* border-box so min-height matches the .item buttons, whose UA default - box-sizing already counts the same 8px paddings inside their 40px. */ + /* border-box keeps the skeleton row aligned with the compact item buttons. */ box-sizing: border-box; - min-height: 40px; - padding: 8px 10px; + min-height: 34px; + padding: 6px 8px; } .skeletonBar { - height: 20px; + height: 18px; border-radius: 4px; background: var(--dsw-alias-bg-skeleton); animation: dsh-menu-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; @@ -219,16 +228,15 @@ } /* Breadcrumb header of a drilled source: pinned outside .viewport so the - descent stays reversible while the candidate list scrolls under it. - 4px sides + the crumb's own 6px = the first crumb's text sits at 10px, - flush with .sectionTitle and the item icons. */ + descent stays reversible while the candidate list scrolls under it. The + compact shell and crumb padding align its text with item content. */ .crumbs { flex: none; display: flex; align-items: center; flex-wrap: wrap; gap: 2px; - padding: 4px 4px 6px; + padding: 3px 3px 5px; margin-bottom: 2px; /* l1, not border-inverted (the menu frame's dark-only stroke): the divider needs to survive light mode too; in dark both resolve to white/0.06. */ @@ -240,15 +248,15 @@ flex: 0 1 auto; max-width: 40%; overflow: hidden; - padding: 2px 6px; + padding: 2px 5px; border: none; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: inherit; - font-size: 12px; - line-height: 18px; + font-size: 11px; + line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index fc2bdc7cd1..a6395aff8b 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -14,7 +14,7 @@ */ import { Fragment, useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconChevronRightOutline14, ReferenceIcon, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronRightOutlineRegular, ReferenceIconRegular, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import css from './MenuView.module.css' import type { MenuViewInjected } from './slots.ts' @@ -99,7 +99,7 @@ export function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t
    )} diff --git a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx index 42d6ee0a02..bf8f6061f6 100644 --- a/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx +++ b/packages/client/ui-open-in-app/tests/open-in-app-action.client.spec.tsx @@ -238,7 +238,7 @@ describe('OpenInAppAction launching', () => { if (img !== null) fireEvent.error(img) await waitFor(() => { expect(container.querySelector('img')).toBeNull() - expect(container.querySelector('svg rect')).not.toBeNull() + expect(container.querySelector('svg path[fill-opacity="0.05"]')).not.toBeNull() }) }) }) diff --git a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx index ac0913f6d6..2136974522 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionRow.tsx +++ b/packages/client/ui-permission-presets/src/client/PermissionRow.tsx @@ -8,7 +8,7 @@ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { - IconChevronDownOutline14, Menu, RiskConfirmation, + IconChevronDownOutlineRegular, Menu, RiskConfirmation, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PermissionSettingsState } from './settings-store.ts' import type { PermissionSettingsKey } from './locales.ts' @@ -97,7 +97,7 @@ export function PermissionRow({ load, select, usePermission, t }: PermissionRowP onClick={() => { setOpen(value => !value) }} > {label} - + )} /> diff --git a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx index 1736fd86a4..f34e2b16e9 100644 --- a/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx +++ b/packages/client/ui-permission-presets/src/client/PermissionSelect.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import { - IconChevronDownOutline14, Menu, RiskConfirmation, SHIELD_OUTLINE_PATH, SHIELD_OUTLINE_STROKE, + IconChevronDownOutlineRegular, Menu, PermissionIconFullAccessRegular, + PermissionIconReadOnlyRegular, PermissionIconWorkspaceWriteRegular, RiskConfirmation, } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { @@ -21,34 +22,13 @@ import { } from './presentation.ts' import css from './PermissionSelect.module.css' -/* Shield glyphs (design set 1556) over the ui-primitives shield contour: - check = read-only, pencil = workspace write, exclamation = full access. - currentColor so the trigger and menu rows tint them with their own text - color. */ +/* Permission glyphs follow currentColor so the trigger and menu rows tint the + shared product artwork with their own text color. */ const permissionGlyphs = new Map([ - ['read-only', ( - - - - - )], - ['workspace-write', ( - - - - - - - - )], - [FULL_ACCESS, ( - - - - - - )], + ['read-only', ], + ['workspace-write', ], + [FULL_ACCESS, ], ]) /** Glyph for a permission option value; host-configured names outside the design set get none. */ @@ -209,7 +189,7 @@ export function PermissionSelect({ {currentBadge} )} - + } diff --git a/packages/client/ui-plan/src/client/PlanCard.tsx b/packages/client/ui-plan/src/client/PlanCard.tsx index 74e65cdefe..3b2e03951b 100644 --- a/packages/client/ui-plan/src/client/PlanCard.tsx +++ b/packages/client/ui-plan/src/client/PlanCard.tsx @@ -1,6 +1,6 @@ /** Persistent transcript cards and pending-review sidebar navigation. */ import { useEffect } from 'react' -import { FileTypeIcon, IconChevronRightOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { FileTypeIcon, IconChevronRightOutlineRegular } 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' @@ -64,5 +64,5 @@ export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, ac actions.markOpened(identity) }, [identity, opened, openReview, review, requestKey, actions]) return + onClick={() => { openReview(review, requestKey) }}>{t('preview.full')} } diff --git a/packages/client/ui-plan/src/client/PlanModeControl.tsx b/packages/client/ui-plan/src/client/PlanModeControl.tsx index f5494f106c..8cbd71d878 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.tsx +++ b/packages/client/ui-plan/src/client/PlanModeControl.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the input.plan seat and // its {locked} owner share). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -60,7 +60,7 @@ export function PlanChip({ useProjection, locked, exitPlanMode, t }: PlanChipPro > {t('chip.label')} - + {error !== null && {t('chip.exitFailed')}} diff --git a/packages/client/ui-plan/src/client/PlanPreview.tsx b/packages/client/ui-plan/src/client/PlanPreview.tsx index b417507c77..141dcf8e3c 100644 --- a/packages/client/ui-plan/src/client/PlanPreview.tsx +++ b/packages/client/ui-plan/src/client/PlanPreview.tsx @@ -1,6 +1,6 @@ /** 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 { IconCopyOutlineRegular, IconPlanOutlineRegular, 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' @@ -35,7 +35,7 @@ export function PlanPreview({ useTabInfo, useResource, t }: PlanPreviewProps) { return (
    + onClick={() => { void writeClipboard(plan.markdown) }}>
    ) @@ -52,5 +52,5 @@ export function PlanTitle({ useTabInfo, useResource }: PropsRuntime<'sidebar.rig const params = tab.tab.navigation.params const plan = isReviewPreviewAddress(tab.tab.navigation.address) ? (params !== undefined && 'planReview' in params ? params.planReview : undefined) : resource.value - return <>{plan?.title ?? tab.tab.title} + return <>{plan?.title ?? tab.tab.title} } diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index e50cdb00de..dd15598bf9 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -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-plugin-manager/README.md -README.md: e7128dd2146cc0553ab36fdef5c31470be7877f2 -README.zh.md: ff7706f1d9747f553bf91f45a76fa5f50472330d +README.md: 73209cb946567abdae88e41debdaab996269f749 +README.zh.md: 8dabeb8f12d463051189cd93a9e66a2bc4cfe70b diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index e7128dd214..73209cb946 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -41,7 +41,7 @@ A bundle's page shows its full package name under the title, the spec that insta ### Switching one row of a bundle -A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows show their fiber phase as the Host runs them. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids. +A row's switch on the bundle's page calls `pluginManager.setPluginEnabled`, which writes the row's `disabled` override into the profile's `cordis.patch.yml`. The tree recomposes at once on a profile with HMR, so the row's host half unmounts or mounts while the rest of the bundle keeps running, and the page follows the client module graph without reloading. Rows use the shared status marker for their Host fiber phase: pending and disabled are idle, loading and unloading are ongoing, active is done, and failed is error. The switch appears only on a bundle that is on; a row without a live entry, or one the Host will not address through the profile patch, is locked with the Host's reason. A list longer than ten rows gets a filter over the row ids. ### Configuration pages @@ -73,7 +73,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th ### The store -`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. +`PluginManagerController` owns the bundle views, busy keys, notices, install progress and the uninstall confirmation. Each read asks the inventory whether the Host manages a profile, then joins `listBundles` with `listPlugins` into one view per bundle, whose rows carry the live entry's enablement and fiber phase. It coalesces overlapping reads, refreshes after operations, on `plugin-manager/changed`, and on reconnect, and ignores late results after disposal. Install output is grouped by job id. The install dialog moves `idle → checking → starting → running → done | failed`, with `cancelling` and `applying` as the Host reports them; checking and every active phase use ongoing, while the final screens use done or error. The check runs under an `AbortController` that going back or closing aborts, and its settlement is dropped; a run is stopped only through `pluginManager.cancelInstall`, whose answer the dialog waits for. A change the Host could not apply, a restart it waits for, and an override by a higher layer become toasts that retire on their own. ### Configuration slots diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index ff7706f1d9..8dabeb8f12 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -41,7 +41,7 @@ Agent Teams、Agent Teams Web UI 和 Auto Authorization Review 三个包使用 ### 切换组合包里的一行 -组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行按 Host 运行它们的 fiber 阶段显示状态。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。 +组合包页面上行的开关调用 `pluginManager.setPluginEnabled`,往 profile 的 `cordis.patch.yml` 写入该行的 `disabled` 覆盖。启用了 HMR 的 profile 的树随即重组,该行的宿主半区卸下或挂上,组合包其余部分照常运行,页面无需重载即跟随客户端模块图。行使用共享状态标记表示 Host fiber 阶段:pending 与 disabled 为 idle,loading 与 unloading 为 ongoing,active 为 done,failed 为 error。开关只出现在已打开的组合包上;没有存活条目的行,以及 Host 不通过 profile patch 寻址的行,带着 Host 的原因锁定。超过十行的列表带一个按行 id 筛选的输入框。 ### 配置页 @@ -73,7 +73,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({ ### store -`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 +`PluginManagerController` 拥有组合包视图、忙碌键、提示、安装进度和卸载确认。每次读取先问清单 Host 是否管理着 profile,再把 `listBundles` 与 `listPlugins` 合成每个组合包一份视图,其行携带存活条目的启停状态与 fiber 阶段。它合并重叠读取,在操作后、收到 `plugin-manager/changed` 时以及重连后刷新,并在销毁后忽略晚到结果。安装输出按 job id 分组。安装对话框沿 `idle → checking → starting → running → done | failed` 推进,`cancelling` 与 `applying` 按 Host 的报告呈现;checking 与所有活动阶段使用 ongoing,最终页面使用 done 或 error。检查在一个 `AbortController` 下运行,返回编辑或关闭会中止它并丢弃其结果;运行只能通过 `pluginManager.cancelInstall` 停止,对话框等待其答复。Host 无法应用的变更、要等重启的变更、被更高层覆盖的变更,都是会自行消失的 toast。 ### 配置 slot diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 2971a42af9..38875cb7c6 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -61,6 +61,12 @@ color: var(--dsw-alias-state-error-primary); } +.statusWithDot { + display: inline-flex; + align-items: center; + gap: 6px; +} + .banner { margin: 0; padding: 8px 12px; @@ -645,14 +651,6 @@ color: var(--dsw-alias-label-secondary); } -.wizardIcon[data-tone='done'] { - color: var(--dsw-alias-state-success-primary); -} - -.wizardIcon[data-tone='failed'] { - color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)); -} - .wizardTitle { margin: 0; font-size: 18px; @@ -731,39 +729,7 @@ min-width: 0; } -.spinnerLarge { - width: 28px; - height: 28px; - border: 3px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent); - border-top-color: var(--dsw-alias-label-primary); - border-radius: 50%; - corner-shape: round; - animation: spin 900ms linear infinite; -} - -.spinner { - width: 14px; - height: 14px; - flex: none; - border: 2px solid color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent); - border-top-color: var(--dsw-alias-label-primary); - border-radius: 50%; - corner-shape: round; - animation: spin 900ms linear infinite; -} - -@keyframes spin { - to { - transform: rotate(360deg); - } -} - @media (prefers-reduced-motion: reduce) { - .spinner, - .spinnerLarge { - animation: none; - } - .detailsChevron { transition: none; } diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index a7cec19607..65eece8d3f 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -12,9 +12,10 @@ import { useEffect, useId, useState, type ReactNode } from 'react' import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client' import { - Button, IconCheckOutline16, IconChevronDownOutline14, IconChevronLeftOutline14, IconChevronRightOutline14, IconCloseOutline16, - IconCordisPluginOutline14, IconPluginPinwheelOutline16, IconPlusOutline16, IconRefreshOutline16, IconTrashOutline16, - IconWarningOutline16, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, + Button, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, + IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCordisPluginOutlineRegular, + IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, + IconWarningOutlineRegular, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -74,13 +75,13 @@ const PHASE_KEYS = { unloading: 'rowPhaseUnloading', } satisfies Record -/** Status dot naming a live root-fiber phase: pending and unloading fibers do nothing; only loading is in progress. */ +/** Status dot naming a root-fiber phase; loading and unloading are live transitions. */ const PHASE_STATES = { pending: 'idle', loading: 'ongoing', active: 'done', failed: 'error', - unloading: 'idle', + unloading: 'ongoing', } satisfies Record /** The count line over a pack's components: the total, then only the states that occur. */ @@ -189,20 +190,20 @@ function RowsSection({ rows, t, toggle, configure }: { {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }} >
    - +
    {configure?.has(row) === true ? ( ) : {row.rowId}} {row.moduleName}
    - + {rowStateText(row, t)} {toggle === undefined @@ -256,7 +257,7 @@ function CardHead({ title, t, onOpen, tags, description, end }: { }): ReactNode { return (
    - +
    @@ -280,11 +281,11 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { return ( <>
    - + {actions}
    @@ -381,7 +382,7 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: { const key = rowConfigKey(pkg.name, row.rowId) return (
    - } /> + } />

    {row.rowId}

    @@ -438,7 +439,7 @@ function PackageDetail({ variant="outline" size="sm" className={css.danger} - icon={} + icon={} aria-label={t('uninstallLabel', { name: title })} disabled={busy || pkg.readOnlyReason !== undefined} onClick={onUninstall} @@ -628,7 +629,7 @@ function InstallDialog({ className={css.installDialog as string} footer={( )} @@ -657,7 +658,7 @@ function InstallDialog({ aria-controls={guideId} onClick={() => { setGuideOpen(open => !open) }} > -
    @@ -716,7 +717,7 @@ function InstallDialog({ ? : ( )} @@ -727,15 +728,13 @@ function InstallDialog({ disabled={pending && phase !== 'running'} onClick={phase === 'running' ? onCancelAndClose : onClose} > - +
    -
    ) @@ -80,8 +81,8 @@ export function ConnectionIndicator({ > {connecting diff --git a/packages/client/ui-primitives/src/DisclosureRow.tsx b/packages/client/ui-primitives/src/DisclosureRow.tsx index f04ad8986a..f79b7e4720 100644 --- a/packages/client/ui-primitives/src/DisclosureRow.tsx +++ b/packages/client/ui-primitives/src/DisclosureRow.tsx @@ -1,6 +1,6 @@ import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutline14 } from './icons/index.tsx' +import { IconChevronDownOutlineRegular } from './icons/index.tsx' import css from './DisclosureRow.module.css' /** Shared 24px disclosure chrome for compact flow rows. */ @@ -61,12 +61,12 @@ export function DisclosureRow({ ? ( <> {icon} - + ) : icon const leading = open - ? + ? : collapsedLeading return ( diff --git a/packages/client/ui-primitives/src/FileTypeIcon.tsx b/packages/client/ui-primitives/src/FileTypeIcon.tsx index 0a88317401..6c85d7ae17 100644 --- a/packages/client/ui-primitives/src/FileTypeIcon.tsx +++ b/packages/client/ui-primitives/src/FileTypeIcon.tsx @@ -53,8 +53,8 @@ const EXTENSION_TYPES: Readonly> = { astro: 'code', bat: 'code', cmd: 'code', - csv: 'code', - tsv: 'code', + csv: 'excel', + tsv: 'excel', html: 'html', htm: 'html', png: 'image', @@ -93,6 +93,13 @@ const EXTENSION_TYPES: Readonly> = { xls: 'excel', xlsx: 'excel', xlsm: 'excel', + xlsb: 'excel', + xlt: 'excel', + xltx: 'excel', + xltm: 'excel', + ods: 'excel', + ots: 'excel', + fods: 'excel', numbers: 'excel', } @@ -174,6 +181,28 @@ function FolderGlyph({ size, className }: IconProps): ReactNode { ) } +function SpreadsheetGlyph({ size, className }: IconProps): ReactNode { + return ( + + + + + + ) +} + function glyph(type: TraditionalFileType, size: number, className: string | undefined): ReactNode { switch (type) { case 'code': @@ -183,12 +212,7 @@ function glyph(type: TraditionalFileType, size: number, className: string | unde ) - case 'excel': - return ( - - - - ) + case 'excel': return case 'folder': return case 'html': return ( diff --git a/packages/client/ui-primitives/src/JsonTree.tsx b/packages/client/ui-primitives/src/JsonTree.tsx index 7a3f2e8b7f..c97008c3e8 100644 --- a/packages/client/ui-primitives/src/JsonTree.tsx +++ b/packages/client/ui-primitives/src/JsonTree.tsx @@ -6,7 +6,7 @@ import type { MouseEvent as ReactMouseEvent, ReactNode, } from 'react' -import { IconCheckOutline16, IconCopyOutline16, IconWrapLinesOutline16 } from './icons/index.tsx' +import { IconCheckOutlineRegular, IconCopyOutlineRegular, IconWrapLinesOutlineRegular } from './icons/index.tsx' import { Menu } from './Menu.tsx' import type { MenuEntry } from './Menu.tsx' import css from './JsonTree.module.css' @@ -148,8 +148,8 @@ function JsonCopyAction({ store, target, persistent, labels, onCopy, onClose }: }} > {state === 'copied' - ? - : } + ? + : } )} items={object ? objectCopyMenuItems(labels) : valueCopyMenuItems(labels)} @@ -436,7 +436,7 @@ function JsonString({ stringWrapping.setDefault(next) }} > - + )} {renderCopy?.(true)} diff --git a/packages/client/ui-primitives/src/LinkIcon.tsx b/packages/client/ui-primitives/src/LinkIcon.tsx index 22b41fdd4f..1708ca12a7 100644 --- a/packages/client/ui-primitives/src/LinkIcon.tsx +++ b/packages/client/ui-primitives/src/LinkIcon.tsx @@ -1,16 +1,6 @@ /** - * Leading category glyph for clickable artifact links (anchors, file - * mentions, produced-file chips). One component keyed by link category; - * every glyph renders fill="currentColor" and so rides the link's own - * color. Glyphs stay private to this module: the public surface is the - * category vocabulary, not the individual glyph components, so consumers - * cannot compose a glyph outside a link. Design sources: - * ic_globe_language_outline_20, ic_code_outline_20, ic_folder_outline_20, - * ic_photo_outline_20, ic_paper_doc_outline_20, ic_paper_outline_20. - * - * A `url` glyph whose destination names a well-known site is that site's own - * mark instead of the globe (`SiteGlyph.tsx`); every mark keeps the same - * currentColor-only rule. + * Leading current-color category glyphs for clickable artifact links. The + * public component keeps individual artwork private to the link vocabulary. */ import type { ReactNode } from 'react' import { classifyFileType, fileExtension } from './FileTypeIcon.tsx' @@ -18,25 +8,20 @@ import { isCodeFileType, isLinkCodeExtension } from './code-file-types.ts' import { siteGlyph } from './SiteGlyph.tsx' import type { IconProps } from './icons/props.ts' -/** - * Link categories with distinct leading glyphs. `url` and `folder` are - * destination categories the consumer states directly; the rest are file - * categories `classifyLinkPath` derives from a path. Code, web, and data - * files share the `code` glyph by design. - */ +/** Link categories with distinct leading glyphs. */ export type LinkIconKind = 'url' | 'folder' | 'code' | 'image' | 'document' | 'other' -/** Props for {@link LinkIcon}: the category plus the shared icon sizing seat. */ +/** Props for link icons: the category plus the shared icon sizing seat. */ export interface LinkIconProps extends IconProps { kind: LinkIconKind - /** - * Destination of a `url` link. A well-known site draws its own mark, so - * callers that know the destination should pass it; anything else keeps the - * globe. Other kinds ignore it — their destination is a path, not a site. - */ + /** Destination of a URL link; known HTTP(S) hosts render their site mark. */ href?: string | undefined } +interface WeightedLinkIconProps extends LinkIconProps { + strokeWidth: number +} + /** * Derive a file path's link-icon category from its extension. Unknown and * missing extensions fall to `other` (the plain-paper glyph). @@ -64,75 +49,51 @@ export function classifyLinkPath(path: string): LinkIconKind { } } -const GlobeGlyph = ({ size, className }: IconProps) => ( - - +const GlobeGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) -const CodeGlyph = ({ size, className }: IconProps) => ( - - - - +const FolderGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) -const FolderGlyph = ({ size, className }: IconProps) => ( - - - +const CodeGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) -const PhotoGlyph = ({ size, className }: IconProps) => ( - - - +const PhotoGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) -const PaperGlyph = ({ size, className }: IconProps) => ( - - +const PaperDocGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) -const PaperDocGlyph = ({ size, className }: IconProps) => ( - - - - +const PaperGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + ) @@ -143,21 +104,37 @@ function assertNever(value: never): never { } /** - * Render the leading glyph for one clickable artifact link. - * @param props - The link category, the optional destination that can select a - * site mark, optional size (default 14px — the inline link text size these - * glyphs sit beside), and optional CSS class. - * @returns The category's SVG glyph, riding currentColor. + * Render the leading glyph for one clickable artifact link at one stroke weight. + * @param props - The link category, optional size (default 14px), and optional CSS class. + * @returns The category's decorative current-color SVG glyph. */ -export function LinkIcon({ kind, href, size = 14, className }: LinkIconProps): ReactNode { +function LinkIconArtwork({ kind, href, size = 14, className, strokeWidth }: WeightedLinkIconProps): ReactNode { switch (kind) { - case 'url': return siteGlyph({ href, size, className }) ?? - case 'folder': return - case 'code': return - case 'image': return - case 'document': return - case 'other': return + case 'url': return siteGlyph({ href, size, className }) ?? + case 'folder': return + case 'code': return + case 'image': return + case 'document': return + case 'other': return /* v8 ignore next -- closed-union backstop; only reached if a kind is forged */ default: return assertNever(kind) } } + +/** + * Render a regular one-pixel link icon. + * @param props - Link category, size, and optional class. + * @returns The regular decorative link glyph. + */ +export function LinkIconRegular(props: LinkIconProps): ReactNode { + return +} + +/** + * Render a medium 1.3px link icon. + * @param props - Link category, size, and optional class. + * @returns The medium decorative link glyph. + */ +export function LinkIconMedium(props: LinkIconProps): ReactNode { + return +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 0525f2c6e4..eef5526a1b 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -3,19 +3,19 @@ display: inline-flex; } -/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface, - * r12, elevation-prominent (hairline stroke in shadow), 4px inset padding. */ +/* Compact translucent dropdown card shared by feature menus. */ .list, .submenu { /* min-widths below are the design's outer card widths — include the pad. */ box-sizing: border-box; - padding: 4px; + padding: 3px; display: flex; flex-direction: column; gap: 0; border: 0; - border-radius: 20px; + border-radius: 16px; background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); box-shadow: var(--dsw-elevation-prominent); /* Elevated surface: the scrollbar thumb takes the l2 elevation tokens. The @@ -27,13 +27,13 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -/* Primary card is 218 wide in the design across both hosts. */ +/* Primary cards grow with their content between compact and readable bounds. */ .list { position: absolute; top: calc(100% + 4px); left: 0; z-index: 100; - min-width: 218px; + min-width: 144px; max-width: 360px; } @@ -84,8 +84,8 @@ flex: none; display: flex; flex-direction: column; - margin-top: 4px; - padding-top: 4px; + margin-top: 3px; + padding-top: 3px; border-top: 0.5px solid var(--dsw-alias-border-l2); } @@ -93,21 +93,20 @@ position: relative; } -/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary, - * gap 8 between leading icon / label / trailing check. */ +/* Menu cell: compact text, icon, and selection spacing. */ .item { display: flex; align-items: center; - gap: 8px; + gap: 6px; width: 100%; - min-height: 40px; - padding: 8px 10px; + min-height: 34px; + padding: 6px 8px; border: none; - border-radius: 10px; + border-radius: 8px; background: transparent; cursor: pointer; - font-size: 14px; - line-height: 22px; + font-size: 13px; + line-height: 20px; color: var(--dsw-alias-label-primary); text-align: left; } @@ -125,33 +124,33 @@ } .denseList .item { - min-height: 34px; - padding-block: 5px; + min-height: 30px; + padding-block: 4px; } .denseList .label { - padding-block: 4px; + padding-block: 3px; } .list.compactList, .submenu.compactList { - min-width: 164px; + min-width: 156px; padding: 2px; border-radius: 7px; } .compactList .item { - min-height: 26px; - gap: 6px; - padding: 3px 7px; + min-height: 24px; + gap: 5px; + padding: 2px 6px; border-radius: 5px; - font-size: 12px; - line-height: 18px; + font-size: 11px; + line-height: 17px; } .compactList .itemIcon { - width: 14px; - height: 14px; + width: 12px; + height: 12px; } .compactList .separator { @@ -159,9 +158,9 @@ } .compactList .label { - padding: 4px 7px; - font-size: 11px; - line-height: 16px; + padding: 3px 6px; + font-size: 10px; + line-height: 15px; } .item:disabled { @@ -172,13 +171,25 @@ .itemIcon { display: inline-flex; flex: none; - width: 16px; - height: 16px; + width: 14px; + height: 14px; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary); } +.itemIcon svg, +.check { + width: 14px; + height: 14px; +} + +.compactList .itemIcon svg, +.compactList .check { + width: 12px; + height: 12px; +} + .itemLabel { flex: 1; min-width: 0; @@ -223,30 +234,29 @@ /* Heading row: non-interactive small grey text, padding aligned with items. */ .label { - padding: 8px 10px; - font-size: 12px; - line-height: 16px; + padding: 6px 8px; + font-size: 11px; + line-height: 15px; color: var(--dsw-alias-label-tertiary); } /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ .separator { height: 0.5px; - margin: 4px 2px; + margin: 3px 2px; background: var(--dsw-alias-border-l1); } -/* Nested card to the right of the parent row (figma 419:16920). +/* Nested card to the right of the parent row. * Bottom-aligned with the parent menu card (grows upward): itemWrap sits in - * .list's 4px pad, so bottom: -4px matches the list's outer bottom edge. - * Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain - * `100% + 6px` collapses to ~2px between outer card edges. + * .list's 3px pad, so bottom: -3px matches the list's outer bottom edge. + * Horizontal: list pad (3px) + 6px card gap = 9px past itemWrap. * ::before bridges the full gap so the pointer can cross without mouseLeave. */ .submenu { position: absolute; top: auto; - bottom: -4px; - left: calc(100% + 10px); + bottom: -3px; + left: calc(100% + 9px); z-index: 101; min-width: 163px; } @@ -256,6 +266,6 @@ position: absolute; top: 0; bottom: 0; - left: -10px; - width: 10px; + left: -9px; + width: 9px; } diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index bb04912293..bd56ee1309 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCheckOutline16 } from './icons/index.tsx' +import { IconCheckOutlineRegular } from './icons/index.tsx' import { usePointerGrace } from './pointer-grace.ts' import css from './Menu.module.css' @@ -374,7 +374,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o {entry.icon !== undefined && {entry.icon}} {entry.label} {/* Selection marker is a trailing check (figma .Menu_cell) unless the fill mode carries it. */} - {selected && selection === 'check' && } + {selected && selection === 'check' && } {subOpen && entry.submenu !== undefined && (
    diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx index 574b1f0f35..58438873a8 100644 --- a/packages/client/ui-primitives/src/Modal.tsx +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import clsx from 'clsx' -import { IconCloseOutline16 } from './icons/index.tsx' +import { IconCloseOutlineRegular } from './icons/index.tsx' import css from './Modal.module.css' interface ModalBaseProps { @@ -67,7 +67,7 @@ export function Modal({

    {title}

    {description !== undefined && description !== '' && ( diff --git a/packages/client/ui-primitives/src/OnboardingSurface.module.css b/packages/client/ui-primitives/src/OnboardingSurface.module.css index e019061c21..c492e538e1 100644 --- a/packages/client/ui-primitives/src/OnboardingSurface.module.css +++ b/packages/client/ui-primitives/src/OnboardingSurface.module.css @@ -14,8 +14,7 @@ top: 80px; bottom: 0px; background: rgba(0, 0, 0, 0.24); - /* Mask-blur */ - backdrop-filter: blur(2px); + backdrop-filter: var(--dsw-mask-blur); } .onboardingStage { diff --git a/packages/client/ui-primitives/src/PermissionIcon.tsx b/packages/client/ui-primitives/src/PermissionIcon.tsx new file mode 100644 index 0000000000..4b7c047933 --- /dev/null +++ b/packages/client/ui-primitives/src/PermissionIcon.tsx @@ -0,0 +1,92 @@ +import type { ReactNode } from 'react' +import { ICON_MEDIUM_STROKE, ICON_REGULAR_STROKE } from './icons/index.tsx' +import type { IconProps } from './icons/props.ts' + +interface PermissionIconArtworkProps extends IconProps { + strokeWidth: number +} + +function ReadOnlyArtwork({ size = 16, className, strokeWidth }: PermissionIconArtworkProps): ReactNode { + return ( + + ) +} + +function WorkspaceWriteArtwork({ size = 16, className, strokeWidth }: PermissionIconArtworkProps): ReactNode { + return ( + + ) +} + +function FullAccessArtwork({ size = 16, className, strokeWidth }: PermissionIconArtworkProps): ReactNode { + return ( + + ) +} + +/** + * Render the read-only permission icon with a one-pixel stroke. + * @param props - Size and optional class. + * @returns The regular decorative permission glyph. + */ +export function PermissionIconReadOnlyRegular(props: IconProps): ReactNode { + return +} + +/** + * Render the read-only permission icon with a 1.3px stroke. + * @param props - Size and optional class. + * @returns The medium decorative permission glyph. + */ +export function PermissionIconReadOnlyMedium(props: IconProps): ReactNode { + return +} + +/** + * Render the workspace-write permission icon with a one-pixel stroke. + * @param props - Size and optional class. + * @returns The regular decorative permission glyph. + */ +export function PermissionIconWorkspaceWriteRegular(props: IconProps): ReactNode { + return +} + +/** + * Render the workspace-write permission icon with a 1.3px stroke. + * @param props - Size and optional class. + * @returns The medium decorative permission glyph. + */ +export function PermissionIconWorkspaceWriteMedium(props: IconProps): ReactNode { + return +} + +/** + * Render the full-access permission icon with a one-pixel stroke. + * @param props - Size and optional class. + * @returns The regular decorative permission glyph. + */ +export function PermissionIconFullAccessRegular(props: IconProps): ReactNode { + return +} + +/** + * Render the full-access permission icon with a 1.3px stroke. + * @param props - Size and optional class. + * @returns The medium decorative permission glyph. + */ +export function PermissionIconFullAccessMedium(props: IconProps): ReactNode { + return +} diff --git a/packages/client/ui-primitives/src/ReferenceIcon.tsx b/packages/client/ui-primitives/src/ReferenceIcon.tsx index a225eba8fb..b598d7f906 100644 --- a/packages/client/ui-primitives/src/ReferenceIcon.tsx +++ b/packages/client/ui-primitives/src/ReferenceIcon.tsx @@ -1,35 +1,69 @@ import type { ReactNode } from 'react' -import { - IconBrowseOutline16, IconFolderClose16, -} from './icons/index.tsx' +import { ICON_MEDIUM_STROKE, ICON_REGULAR_STROKE } from './icons/index.tsx' +import type { IconProps } from './icons/props.ts' /** Reference domains with distinct composer and transcript glyphs. */ export type ReferenceIconKind = 'session' | 'file' | 'folder' /** Props shared by inline reference glyphs. */ -export interface ReferenceIconProps { +export interface ReferenceIconProps extends IconProps { kind: ReferenceIconKind - size?: number - className?: string | undefined } +interface WeightedReferenceIconProps extends ReferenceIconProps { + strokeWidth: number +} + +const SessionGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + +) + +const FileGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + +) + +const FolderGlyph = ({ size, className, strokeWidth }: IconProps & { strokeWidth: number }) => ( + +) + /** * Render the icon that identifies one inline reference domain. * @param props - Reference kind, optional size, and optional CSS class. - * @returns The corresponding current-color SVG glyph. + * @returns The corresponding decorative current-color SVG glyph. */ -export function ReferenceIcon({ kind, size = 16, className }: ReferenceIconProps): ReactNode { +function ReferenceIconArtwork({ kind, size = 16, className, strokeWidth }: WeightedReferenceIconProps): ReactNode { switch (kind) { - case 'session': - return ( - - - - ) - case 'file': return - case 'folder': return + case 'session': return + case 'file': return + case 'folder': return } } + +/** + * Render a regular one-pixel reference icon. + * @param props - Reference kind, size, and optional class. + * @returns The regular decorative reference glyph. + */ +export function ReferenceIconRegular(props: ReferenceIconProps): ReactNode { + return +} + +/** + * Render a medium 1.3px reference icon. + * @param props - Reference kind, size, and optional class. + * @returns The medium decorative reference glyph. + */ +export function ReferenceIconMedium(props: ReferenceIconProps): ReactNode { + return +} diff --git a/packages/client/ui-primitives/src/RiskConfirmation.tsx b/packages/client/ui-primitives/src/RiskConfirmation.tsx index 8990cb8e5b..7f5c6f49c2 100644 --- a/packages/client/ui-primitives/src/RiskConfirmation.tsx +++ b/packages/client/ui-primitives/src/RiskConfirmation.tsx @@ -3,7 +3,7 @@ * must gate a sensitive action behind an explicit checkbox. */ import { Button } from './Button.tsx' -import { IconWarningOutline16 } from './icons/index.tsx' +import { IconWarningOutlineRegular } from './icons/index.tsx' import { Modal } from './Modal.tsx' import css from './RiskConfirmation.module.css' @@ -65,7 +65,7 @@ export function RiskConfirmation({ )} >
    - +

    {description}