From 10029b995fc0236c8880e836c2853094ad36ce79 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Thu, 10 Sep 2026 17:18:38 +0800 Subject: [PATCH 01/44] 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 && (

    diff --git a/apps/desktop/src/mandatory-update-window.ts b/apps/desktop/src/mandatory-update-window.ts index a3ca6bcf8f..17438ab15e 100644 --- a/apps/desktop/src/mandatory-update-window.ts +++ b/apps/desktop/src/mandatory-update-window.ts @@ -49,6 +49,7 @@ const page = 'dsh-app://shell/mandatory-update.html' /** A modal child blocks the product window without cancelling work in the Host. */ export class DesktopMandatoryUpdateWindow { private window: BrowserWindow | undefined + private closing: ReturnType | undefined private disposed = false private error: string | undefined private action: Promise | undefined @@ -142,11 +143,20 @@ export class DesktopMandatoryUpdateWindow { this.clearNavigation() this.restart = undefined this.deferred = false - this.window?.destroy() - this.window = undefined + if (this.window !== undefined && this.closing === undefined) { + const window = this.window + window.webContents.send(MANDATORY_IPC.state, this.view()) + this.closing = setTimeout(() => { + this.closing = undefined + if (this.window === window) this.window = undefined + window.destroy() + }, 150) + } this.error = undefined return } + clearTimeout(this.closing) + this.closing = undefined if (this.navigationUrl !== this.options.policy().page) this.clearNavigation() if (this.options.update().phase === 'error') this.restart = undefined if (this.window === undefined) { @@ -186,6 +196,8 @@ export class DesktopMandatoryUpdateWindow { /** Detach IPC and release the modal during application shutdown. */ dispose(): void { this.disposed = true + clearTimeout(this.closing) + this.closing = undefined this.finishConfirmation(false) this.attention.reset() this.clearNavigation() diff --git a/apps/desktop/src/update-overlay.ts b/apps/desktop/src/update-overlay.ts index dc829fe4f3..2ff0b8c0d4 100644 --- a/apps/desktop/src/update-overlay.ts +++ b/apps/desktop/src/update-overlay.ts @@ -5,7 +5,7 @@ import { BrowserWindow } from 'electron' * @param parent - Product window whose content is blocked while the overlay is open. * @param preload - Isolated shell-only preload. * @param title - Localized window title. - * @param nativeModal - Use a native modal; false keeps ordinary confirmations out of macOS sheets. + * @param nativeModal - Use a native modal; false keeps overlays out of macOS sheets. * @returns A transparent child that follows its parent's content bounds and releases its listeners on close. */ export function createUpdateOverlay(parent: BrowserWindow, preload: string, title: string, nativeModal = true): BrowserWindow { @@ -51,7 +51,7 @@ export function createUpdateOverlay(parent: BrowserWindow, preload: string, titl /** A native Windows modal retains its own title bar while the product window remains blocked. */ export function createMandatoryUpdateWindow(parent: BrowserWindow, preload: string, title: string, platform: NodeJS.Platform = process.platform): BrowserWindow { - if (platform !== 'win32') return createUpdateOverlay(parent, preload, title) + if (platform !== 'win32') return createUpdateOverlay(parent, preload, title, false) const window = new BrowserWindow({ parent, modal: true, show: false, title, width: 640, height: 560, minWidth: 480, minHeight: 360, diff --git a/apps/desktop/tests/mandatory-update-window.spec.ts b/apps/desktop/tests/mandatory-update-window.spec.ts index 4df1f34c69..84f9195122 100644 --- a/apps/desktop/tests/mandatory-update-window.spec.ts +++ b/apps/desktop/tests/mandatory-update-window.spec.ts @@ -24,7 +24,7 @@ function fakeWindow() { loadURL: vi.fn(async () => {}), destroy: vi.fn(), isDestroyed: () => false, isFocused: () => true, isMinimized: () => false, focus: vi.fn(), show: vi.fn(), restore: vi.fn() }) } -afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear() }) +afterEach(() => { ui?.dispose(); vi.clearAllMocks(); native.handlers.clear(); vi.useRealTimers() }) function setup() { window = fakeWindow() let policy: DesktopPolicyState = { blocking: true, checking: false, page: 'https://downloads.example.com/desktop' } @@ -130,3 +130,25 @@ it('exits the application when the mandatory window is closed without clearing t expect(native.quit).toHaveBeenCalledOnce() expect(f.view().policy.blocking).toBe(true) }) + +it('fades on clearance, reuses a reblocked window, and cancels teardown on disposal', () => { + vi.useFakeTimers() + const f = setup() + f.policy({ blocking: false, checking: false }) + expect(window.webContents.send).toHaveBeenLastCalledWith(MANDATORY_IPC.state, + f.view()) + vi.advanceTimersByTime(100) + expect(window.destroy).not.toHaveBeenCalled() + f.policy({ blocking: true, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).not.toHaveBeenCalled() + expect(window.loadURL).toHaveBeenCalledOnce() + f.policy({ blocking: false, checking: false }) + vi.advanceTimersByTime(150) + expect(window.destroy).toHaveBeenCalledOnce() + expect(ui!.confirmationWindow).toBeUndefined() + f.policy({ blocking: true, checking: false }) + f.policy({ blocking: false, checking: false }) + ui!.dispose() + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index 03528a86f5..4f0103bb17 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -114,6 +114,11 @@ it('keeps mandatory diagnostics expandable without clearing the block or authori expect(disclosure.hidden).toBe(true) expect(p.element('error').hidden).toBe(true) expect(p.document.getElementById('quit')).toBeNull() + expect(p.document.body.classList.contains('visible')).toBe(true) + publish({ ...initial, policy: { blocking: false, checking: false } }) + expect(p.document.body.classList.contains('visible')).toBe(false) + publish(initial) + expect(p.document.body.classList.contains('visible')).toBe(true) p.dom.window.dispatchEvent(new p.dom.window.Event('pagehide')) expect(unsubscribe).toHaveBeenCalledOnce() }) diff --git a/apps/desktop/tests/update-overlay.spec.ts b/apps/desktop/tests/update-overlay.spec.ts index 712163265b..97bb5cc266 100644 --- a/apps/desktop/tests/update-overlay.spec.ts +++ b/apps/desktop/tests/update-overlay.spec.ts @@ -25,3 +25,28 @@ it('gives the Windows mandatory modal native move, resize, and maximize controls expect(window.show).toHaveBeenCalledOnce() expect(window.webContents.setWindowOpenHandler).toHaveBeenCalledOnce() }) + +it('keeps the macOS mandatory overlay stationary and blocks parent keyboard input until close', () => { + const platform = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + const parent = Object.assign(new EventEmitter(), { + getContentBounds: () => ({ x: 0, y: 0, width: 1000, height: 700 }), + webContents: Object.assign(new EventEmitter(), { insertCSS: vi.fn(async () => 'blur'), removeInsertedCSS: vi.fn(async () => {}) }), + isDestroyed: () => false, + }) + const window = Object.assign(new EventEmitter(), { + webContents: { setWindowOpenHandler: vi.fn() }, show: vi.fn(), focus: vi.fn(), + setMenu: vi.fn(), setBounds: vi.fn(), isDestroyed: () => false, + }) + native.create.mockReturnValue(window) + try { + createMandatoryUpdateWindow(parent as unknown as BrowserWindow, 'owned', 'Update required', 'darwin') + expect(native.create).toHaveBeenLastCalledWith(expect.objectContaining({ modal: false, transparent: true, frame: false })) + const event = { preventDefault: vi.fn() } + parent.webContents.emit('before-input-event', event) + expect(event.preventDefault).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + window.emit('closed') + expect(parent.listenerCount('focus')).toBe(0) + expect(parent.webContents.listenerCount('before-input-event')).toBe(0) + } finally { platform.mockRestore() } +}) From 72d93cbcc2e5be32b15d18f10a1d87fdd0aea907 Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 18 Sep 2026 14:18:38 +0800 Subject: [PATCH 41/44] fix(desktop): reset scroll when replacing update prompts --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/update-dialog.js | 1 + apps/desktop/tests/update-error-renderer.spec.ts | 4 ++++ 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f604997189..0c0db6e288 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/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 apps/desktop/README.md -README.md: 2948fa988fb5dcc646430b5bade6d07de6b01056 -README.zh.md: f8e6469295f15addb36eff4b04ba8e687013d7dd +README.md: 531c168c0d3fc3091a330f5350d0a85ac3009f08 +README.zh.md: 6a20f83623a54e8f87d05104280b97ddfb2a796a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 2948fa988f..531c168c0d 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -263,7 +263,7 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Updates -Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content while retaining the backdrop and background blur. +Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the backdrop and background blur. `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index f8e6469295..6a20f83623 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -264,7 +264,7 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up ## 更新 -打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗仅替换卡片内容,保留蒙层与背景模糊。 +打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。 `DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。 diff --git a/apps/desktop/renderer/update-dialog.js b/apps/desktop/renderer/update-dialog.js index fab2abf05e..d03eadadf7 100644 --- a/apps/desktop/renderer/update-dialog.js +++ b/apps/desktop/renderer/update-dialog.js @@ -47,6 +47,7 @@ function render(state) { document.getElementById('actions').append(button) } document.querySelector('main').hidden = false + document.getElementById('dialog').scrollTop = 0 document.body.classList.add('visible') document.getElementById('dialog').focus() } diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index 4f0103bb17..ad92f2df8f 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -58,14 +58,18 @@ it.each(['en', 'zh-CN'])('keeps ordinary diagnostics folded, text-only, and keyb expect(respond).toHaveBeenCalledWith(1, 0) const backdrop = p.document.body const dialog = p.element('dialog') + dialog.scrollTop = 170 publish({ ...state, revision: 2, message: locale.messages.updateChecking, technicalDetails: '', buttons: ['OK'] }) expect(p.document.body).toBe(backdrop) expect(p.element('dialog')).toBe(dialog) + expect(dialog.scrollTop).toBe(0) expect(backdrop.classList.contains('visible')).toBe(true) expect(p.element('actions').childElementCount).toBe(1) expect(disclosure.open).toBe(false) expect(p.document.activeElement).toBe(dialog) + dialog.scrollTop = 80 publish(state) + expect(dialog.scrollTop).toBe(80) expect(p.element('title').textContent).toBe(locale.messages.updateChecking) publish(null) expect(backdrop.classList.contains('visible')).toBe(false) From 0c3eb28276928ab29eec2ec8f4ea2293ca777fbb Mon Sep 17 00:00:00 2001 From: winewill Date: Fri, 18 Sep 2026 14:29:29 +0800 Subject: [PATCH 42/44] test(desktop): await mandatory overlay fade after policy clearance --- apps/desktop/tests/main-startup.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 1c92e81aa8..d68d076971 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -812,6 +812,8 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() request.mockImplementationOnce(async () => Response.json({ code: 0, data: { biz_code: 0, biz_data: null } })) await vi.advanceTimersByTimeAsync(20_000) + expect(modal.isDestroyed()).toBe(false) + await vi.advanceTimersByTimeAsync(150) expect(modal.isDestroyed()).toBe(true) expect(host.stop).not.toHaveBeenCalled() expect(request.mock.calls[0]![1]!.headers).toMatchObject({ 'x-client-bundle-id': 'com.deepseek.dsh', 'x-client-version': '1.0.0' }) From 98c083bb3161dd4ad9821e2bd0b14e54d3d91dd4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 18 Sep 2026 15:01:24 +0800 Subject: [PATCH 43/44] fix(web): match custom provider URL placeholders to protocol --- .../client/ui-settings-models/README.i18n.yaml | 4 ++-- packages/client/ui-settings-models/README.md | 2 +- packages/client/ui-settings-models/README.zh.md | 2 +- .../src/client/CustomProviderCard.tsx | 4 +++- .../ui-settings-models/src/client/locales.ts | 2 ++ .../tests/provider-form.client.spec.tsx | 14 +++++++++++--- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 94fe93fbe3..c3c33c6f90 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-settings-models/README.md -README.md: 6feb95d987f11bcf81b9bfa2c67068567244dcec -README.zh.md: 9316d698bdf11f630f5815b9fffd0bbdc4546031 +README.md: c1567814b5de39f9b8819fcc8848e36c0a6ff842 +README.zh.md: baa0f02e8274a7146f9eade36d3ad464ac944b27 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 6feb95d987..c1567814b5 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -43,7 +43,7 @@ Expand **Customized settings → Model options** to edit each model. Both provid ### Adding and deleting providers -The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 9316d698bd..baa0f02e82 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference" ### 新增与删除提供方 -「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 +「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供方错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 ### 首次运行弹窗 diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index 9b4953d86b..4f382e92a1 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -236,7 +236,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { className={styles['input']} type="text" value={baseURL} - placeholder={t('customBaseUrlPlaceholder')} + placeholder={t(protocol === 'anthropic-messages' + ? 'customAnthropicBaseUrlPlaceholder' + : 'customBaseUrlPlaceholder')} aria-label={t('baseUrl')} aria-invalid={baseUrlInvalid} disabled={profileDisabled} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index d06ee79c52..2b0f92d8df 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -93,6 +93,7 @@ export const en = { customBaseUrlInvalid: 'Enter a valid HTTP or HTTPS URL.', customNeedsModels: 'A custom provider needs at least one model.', customBaseUrlPlaceholder: 'https://gateway.example/v1', + customAnthropicBaseUrlPlaceholder: 'https://gateway.example', settingsPathUnresolvable: 'unresolvable settings path', create: 'Create provider', creating: 'Creating\u2026', @@ -204,6 +205,7 @@ export const zh: { [Key in keyof typeof en]: string } = { customBaseUrlInvalid: '请输入有效的 HTTP 或 HTTPS 地址。', customNeedsModels: '自定义提供方至少需要一个模型。', customBaseUrlPlaceholder: 'https://gateway.example/v1', + customAnthropicBaseUrlPlaceholder: 'https://gateway.example', settingsPathUnresolvable: '无法解析设置路径', create: '创建提供方', creating: '创建中\u2026', diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 0659607e8d..9b6ff1f55a 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -1372,10 +1372,18 @@ describe('hand-declared providers', () => { it('creates with the chosen protocol and no display name', async () => { const { mutate, onClose } = mountCard() + const baseUrl = screen.getByLabelText(en.baseUrl) + const protocol = screen.getByLabelText(en.customApi) + + expect(baseUrl.placeholder).toBe('https://gateway.example/v1') + fireEvent.change(protocol, { target: { value: 'openai-responses' } }) + expect(baseUrl.placeholder).toBe('https://gateway.example/v1') fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) - fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) - fireEvent.change(screen.getByLabelText(en.customApi), { target: { value: 'anthropic-messages' } }) + fireEvent.change(baseUrl, { target: { value: 'https://acme.test/anthropic' } }) + fireEvent.change(protocol, { target: { value: 'anthropic-messages' } }) + expect(baseUrl.placeholder).toBe('https://gateway.example') + expect(baseUrl.value).toBe('https://acme.test/anthropic') fireEvent.click(screen.getByRole('button', { name: en.addModel })) fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) fireEvent.click(screen.getByText(en.create)) @@ -1387,7 +1395,7 @@ describe('hand-declared providers', () => { // nothing ever sets. The with-key case is covered above. expect(firstMutate(mutate).ops[0]?.value).toEqual({ api: 'anthropic-messages', - baseURL: 'https://acme.test/v1', + baseURL: 'https://acme.test/anthropic', models: [{ id: 'm' }], }) }) From f83800530c02c21330b09b0d4c799751d531887f Mon Sep 17 00:00:00 2001 From: Turtle 2099 Date: Fri, 18 Sep 2026 15:04:54 +0800 Subject: [PATCH 44/44] fix(web): recolor the plan chip and align it with the control row (#4399) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(web): make the plan chip a business-accent control at row height The chip borrowed the warn-state pair for a plain mode flag, so it read as a warning, and its 2px vertical padding left it 4px shorter than the + and select controls beside it. It now uses the business accent pair, takes the row's 28px height, and carries Chinese copy; the unused chip.off.* dictionary entries had no surface and are dropped. * docs(web): re-record the plan chip README pair * docs(web): state the plan chip's close glyph and drop stale copy claims The READMEs described the chip without the × the component renders, and the zh side still quoted the English accessibility string after this branch gave the namespace its own Chinese one. * test(web): pin the plan chip's accent tokens, height, and Chinese copy The lifecycle-chrome lane asserted the chip's warn pair; it now asserts the business pair plus the 28px row height, and a zh plan-active golden records the 「计划」 label the chip shows under the Chinese locale. * fix(web): color claimed command tokens with the business accent The draft's claimed token still used the warn label color while the plan chip moved to the business pair, so a claimed /计划 read orange above a blue chip. The claim decoration and the tests asserting its inline style now use state-business-primary. * fix(web): keep the composer control row on one line at narrow widths The fixed-width mode chips and send circle left the row's 12px gaps deciding between a slack first line and a wrapped trailing group: beside the plan chip, a long model name moved the model trigger and send circle to a second line while the first line still had room. Narrow rows now use 8px gaps, which holds the row together down to the icon-only model trigger. * fix(web): drop the context ring in the narrow composer row With plan mode on and a long model name, the 28px occupancy ring plus its gap was what pushed the trailing group onto a second line. The ring yields at the same row width that already hides the access-mode label, so the fixed controls keep one line; the reading stays reachable from a wider window. --------- Co-authored-by: Turtle --- apps/web/tests/lifecycle-chrome.e2e.ts | 49 ++++++++++++++++--- .../src/client/input/editor/claim-decor.ts | 8 +-- .../src/client/skeleton/InputBar.module.css | 12 +++++ .../tests/input-bar.client.spec.tsx | 4 +- .../tests/input-matrix.client.spec.tsx | 4 +- .../tests/input-scenarios.client.spec.tsx | 8 +-- .../tests/lexical-editor-core.client.spec.tsx | 4 +- packages/client/ui-plan/README.i18n.yaml | 4 +- packages/client/ui-plan/README.md | 4 +- packages/client/ui-plan/README.zh.md | 6 +-- .../src/client/PlanModeControl.module.css | 18 ++++--- packages/client/ui-plan/src/client/locales.ts | 12 ++--- .../tests/plan-mode-control.client.spec.tsx | 10 ++-- .../plan-active-zh.expected.md | 25 ++++++++++ 14 files changed, 122 insertions(+), 46 deletions(-) create mode 100644 snapshots/web/lifecycle-chrome/plan-active-zh.expected.md diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 818aa80ba2..2948072fd0 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -23,7 +23,8 @@ import { launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, + connectFreshWorkspace, connectFreshWorkspaceZh, expandOwningTurnProcess, newEnglishPage, saveFailureShot, + writeComposerDraft, ZH_BROWSER_LOCALE, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) @@ -34,6 +35,7 @@ const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') +const PLAN_ACTIVE_ZH_EXPECTED = join(SNAPSHOT_DIR, 'plan-active-zh.expected.md') const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md') // Post-reload golden: the same settled conversation rebuilt purely from // persistence + history — byte-equal rendering is exactly the recovery claim. @@ -153,7 +155,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await inputPage.keyboard.insertText('这是任务') await expect.poll(() => input.textContent()).toBe(`${token} 这是任务`) for (let i = 0; i < 5; i++) await input.press('Backspace') - const tokenText = () => input.locator('[data-lexical-text][style*="warn-label"]').textContent() + const tokenText = () => input.locator('[data-lexical-text][style*="business-primary"]').textContent() await expect.poll(() => input.textContent()).toBe(token) await expect.poll(() => input.getAttribute('data-phase')).toBe('claimed') await expect.poll(tokenText).toBe(token) @@ -195,7 +197,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } }) - it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { + it.skipIf(MODE === 'record')('shows active Plan as the business-state status action', async () => { const activeScaffold = await launchWebScaffold() const activePage = await newEnglishPage(browser) const activeTripwire = watchConsole(activePage) @@ -220,14 +222,15 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await compareOrRefreshGolden(PLAN_ACTIVE_EXPECTED, planSnapshot, MODE) const planStyle = await planButton.evaluate((element) => { const probe = document.createElement('span') - probe.style.color = 'var(--dsw-alias-state-warn-label)' - probe.style.backgroundColor = 'var(--dsw-alias-state-warn-tertiary)' + probe.style.color = 'var(--dsw-alias-state-business-primary)' + probe.style.backgroundColor = 'var(--dsw-alias-state-business-tertiary)' document.body.append(probe) const actual = getComputedStyle(element) const reference = getComputedStyle(probe) const result = { color: actual.color, backgroundColor: actual.backgroundColor, + height: actual.height, borderRadius: actual.borderRadius, fontSize: actual.fontSize, referenceColor: reference.color, @@ -238,6 +241,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () }) expect(planStyle.color).toBe(planStyle.referenceColor) expect(planStyle.backgroundColor).toBe(planStyle.referenceBackgroundColor) + expect(planStyle.height).toBe('28px') expect(planStyle.borderRadius).toBe('999px') expect(planStyle.fontSize).toBe('13px') await planButton.click() @@ -253,6 +257,39 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () } }) + it.skipIf(MODE === 'record')('shows the active Plan chip with the Chinese copy', async () => { + const zhScaffold = await launchWebScaffold() + const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const zhTripwire = watchConsole(zhPage) + try { + await zhPage.goto(zhScaffold.authenticatedUrl, { waitUntil: 'load' }) + await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspaceZh(zhPage, zhScaffold.workspaceCwd) + const input = zhPage.locator('[data-composer-input]').first() + await zhPage.getByRole('button', { name: '添加文件或调用指令' }).click() + const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) + await menu.waitFor({ timeout: 10_000 }) + await menu.getByRole('option', { name: '计划 plan 进入或退出计划模式', exact: true }).click() + await expect.poll(() => input.textContent()).toBe('/计划 ') + await input.press('Enter') + const planButton = zhPage.getByRole('button', { name: '计划模式已开启,按下关闭' }) + await planButton.waitFor({ timeout: 10_000 }) + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + const planSnapshot = await captureStableAria(zhPage, '[class*="frame"]', zhScaffold.workspaceCwd) + await compareOrRefreshGolden(PLAN_ACTIVE_ZH_EXPECTED, planSnapshot, MODE) + await planButton.click() + await expect.poll(() => planButton.count()).toBe(0) + expect(zhTripwire.pageErrors).toEqual([]) + expect(zhTripwire.warnings).toEqual([]) + } catch (error) { + await saveFailureShot(zhPage, 'web-e2e-plan-active-zh').catch(() => undefined) + throw error + } finally { + await zhPage.close() + await zhScaffold.close() + } + }) + it('sends the first prompt from the empty-state hero (all modes)', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-lifecycle-send')) if (MODE !== 'record') { @@ -564,7 +601,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md', - 'hero.expected.md', 'plan-active.expected.md', + 'hero.expected.md', 'plan-active.expected.md', 'plan-active-zh.expected.md', 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) diff --git a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts index 7f2bec56ee..7e28273305 100644 --- a/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts +++ b/packages/client/ui-conversation/src/client/input/editor/claim-decor.ts @@ -1,15 +1,15 @@ /** * Claim-token highlight: while a command claim holds, the draft's leading - * token renders in the warn color. A TextNode transform keeps the token in - * its own styled node (splitting when typing merges text into it), and the + * token renders in the business accent. A TextNode transform keeps the token + * in its own styled node (splitting when typing merges text into it), and the * shell nudges the first leaf dirty when the claim flips so entering and * leaving claimed restyles without a text edit. */ import type { LexicalEditor, TextNode as TextNodeType } from 'lexical' import { $getRoot, $isElementNode, $isTextNode, TextNode } from 'lexical' -/** Inline style carried by the claim-token node (the old backdrop's hlToken color). */ -const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)' +/** Inline style carried by the claim-token node. */ +const TOKEN_STYLE = 'color: var(--dsw-alias-state-business-primary)' /** The document's first text leaf, or null (empty document / leading chip). */ function firstTextLeaf(): TextNodeType | null { 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 2ee0f6d16d..6226a18ffa 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -296,6 +296,18 @@ gap: 12px; } +/* Narrow rows drop the roomy control gaps: the mode chips and the send circle + are fixed-width, so a long model name beside the plan chip wraps the + trailing group while the first line still has room to spare. The 8px rhythm + keeps the row on one line down to the icon-only model trigger. */ +@container (max-width: 560px) { + .tools, + .modes, + .trailing { + gap: 8px; + } +} + /* Attach circle (figma + control): 28px, selector fill, primary glyph. */ .add { display: grid; diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 6d3f4dfc88..4123e7eee3 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -1305,9 +1305,9 @@ describe('machine pending lock', () => { }) describe('decorations', () => { - /** The claim-token styled leaf (the transform's inline warn color). */ + /** The claim-token styled leaf (the transform's inline accent color). */ function tokenSpanOf(container: HTMLElement): HTMLElement | null { - return container.querySelector('[data-lexical-text][style*="warn-label"]') + return container.querySelector('[data-lexical-text][style*="business-primary"]') } it('claimed token styles the leading leaf and sets the blank-args hint variable', () => { diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 79acf88d3f..96de6a18d0 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -161,7 +161,7 @@ describe('matrix row: claimed', () => { claim() act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh expect(shell.snapshot.claim).toEqual({ name: 'goal', token: '/goal ', hint: '目标' }) - expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ') + expect(view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe('/goal ') // The zh dictionary owns a hint.goal entry, which overrides the raw claim hint (production behavior). expect(textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) expect(textarea.getAttribute('contenteditable')).toBe('true') @@ -191,7 +191,7 @@ describe('matrix row: claimed', () => { expect(shell.snapshot.phase).toBe('plain') expect(shell.snapshot.claim).toBeUndefined() act(() => { shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh - expect(view.container.querySelector('[data-lexical-text][style*="warn-label"]')).toBeNull() + expect(view.container.querySelector('[data-lexical-text][style*="business-primary"]')).toBeNull() }) }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index dee63d6dd7..674cf3dc0a 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -224,7 +224,7 @@ describe('scenario A: menu-pick /goal, type args, enter submits', () => { expect(b.shell.snapshot.phase).toBe('claimed') expect(b.shell.snapshot.draft).toBe('/goal ') act(() => { b.shell.editor.update(() => {}, { discrete: true }) }) // flush the queued decoration refresh - expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe('/goal ') + expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe('/goal ') // The zh dictionary owns a hint.goal entry, which overrides the machine's raw hint (production behavior). expect(b.textarea.style.getPropertyValue('--dsh-composer-hint')).toBe(JSON.stringify('输入目标,智能体将持续执行')) // Continue typing args; hint drops; claim holds. @@ -328,12 +328,12 @@ describe('scenario H: backspace breaks the token', () => { } expect(b.shell.snapshot.draft).toBe(`/${name}`) expect(b.shell.snapshot.phase).toBe('claimed') - expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name}`) + expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe(`/${name}`) b.type(`/${name} `) await act(async () => {}) expect(b.shell.snapshot.phase).toBe('claimed') expect(b.shell.snapshot.draft).toBe(`/${name} `) - expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')?.textContent).toBe(`/${name} `) + expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')?.textContent).toBe(`/${name} `) b.type(`/${name}x`) expect(b.shell.snapshot.phase).toBe('plain') }) @@ -348,7 +348,7 @@ describe('scenario H: backspace breaks the token', () => { // Backspace into the token: watch break → plain, visuals gone. b.type('/goa ') expect(b.shell.snapshot.phase).toBe('plain') - expect(b.view.container.querySelector('[data-lexical-text][style*="warn-label"]')).toBeNull() + expect(b.view.container.querySelector('[data-lexical-text][style*="business-primary"]')).toBeNull() }) }) diff --git a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx index 2d63aaefa9..cdee0965ba 100644 --- a/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx +++ b/packages/client/ui-conversation/tests/lexical-editor-core.client.spec.tsx @@ -426,10 +426,10 @@ describe('detect-span application', () => { }) describe('claim precedence over text-ref entities', () => { - const TOKEN_STYLE = 'color: var(--dsw-alias-state-warn-label)' + const TOKEN_STYLE = 'color: var(--dsw-alias-state-business-primary)' const LEXICON: ReadonlyMap<'/' | '@', readonly string[]> = new Map([['/', ['plan']]]) - it.each(['/plan', '/plan '])('keeps the lexicon-listed %j claim plain and warn-styled until release', (token) => { + it.each(['/plan', '/plan '])('keeps the lexicon-listed %j claim plain and accent-styled until release', (token) => { const editor = makeEditor() let claim: string | null = token registerClaimDecoration(editor, () => claim) diff --git a/packages/client/ui-plan/README.i18n.yaml b/packages/client/ui-plan/README.i18n.yaml index 20e6f1952e..2b68bc2db2 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: f5cec26aff9ec7fc478915c20bca86c06474f9f7 -README.zh.md: 4bd416b757b7ff6bae2947b84db9f8d50101aa00 +README.md: 0ba994c4003dc5a3be5be11f217435692039f380 +README.zh.md: 5de065cb59d597261628b43607a45ad46d098e1b diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index f5cec26aff..0ba994c400 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and `dsh-plan-mode`; the chip then ### What the chip shows -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. +While the effective target is plan mode, the seat renders the blue "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 @@ -49,7 +49,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface
    Implementation internals — click to expand -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". +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. Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. diff --git a/packages/client/ui-plan/README.zh.md b/packages/client/ui-plan/README.zh.md index 4bd416b757..5de065cb59 100644 --- a/packages/client/ui-plan/README.zh.md +++ b/packages/client/ui-plan/README.zh.md @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 及 `dsh-plan-mode` 一起挂载本插件;plan 模式激活时,徽章随即占据 composer 的 plan 座位(访问模式控件右侧)。经 `/plan` 命令路径进入 plan 模式——从 composer 的 `+` Command 菜单选择 Plan,或键入 `/plan`——再用徽章将其关闭。 +与 `ui-conversation` 及 `dsh-plan-mode` 一起挂载本插件;plan 模式激活时,徽章随即占据 composer 的 plan 座位(访问模式控件右侧)。经 `/plan` 命令路径进入 plan 模式——从 composer 的 `+` Command 菜单选择「计划」,或键入 `/plan`——再用徽章将其关闭。 ### 徽章显示什么 -当有效目标为 plan 模式时,该座位渲染 warn 色「Plan ×」状态按钮,执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。 +当有效目标为 plan 模式时,该座位渲染 蓝色「计划 ×」状态按钮,执行 `/plan off`。否则座位保持为空:未组合 plan-mode 的宿主,或尚无会话的 Draft,都不显示任何内容。plan 模式为有效目标期间,composer 文本框的 placeholder 切换为 plan 任务提示——「describe your task to generate plan」——除非所属 surface 提供自己的 placeholder。 ### 查看已提交的计划 @@ -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 声明的 `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` 命令的提示逐字共用。 计划卡片通过 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/PlanModeControl.module.css b/packages/client/ui-plan/src/client/PlanModeControl.module.css index b4547e3b7f..793ca4e5d6 100644 --- a/packages/client/ui-plan/src/client/PlanModeControl.module.css +++ b/packages/client/ui-plan/src/client/PlanModeControl.module.css @@ -1,4 +1,5 @@ -/* Active plan status follows Figma's warn-state pill. */ +/* Active plan status: a business accent pill at the control row's own height + (28px, like the + control and the sibling selects). */ .wrap { display: inline-flex; @@ -10,13 +11,14 @@ display: inline-flex; align-items: center; gap: 4px; + height: 28px; min-width: 34px; - padding: 2px 8px; + padding: 0 8px; border: none; border-radius: 999px; corner-shape: round; - background: var(--dsw-alias-state-warn-tertiary); - color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-business-tertiary); + color: var(--dsw-alias-state-business-primary); font-size: 13px; font-weight: 500; line-height: 20px; @@ -24,11 +26,15 @@ } .chip:hover:not(:disabled) { - color: var(--dsw-alias-state-warn-primary); + background: color-mix( + in srgb, + var(--dsw-alias-state-business-tertiary), + var(--dsw-alias-state-business-primary) 6% + ); } .chip:focus-visible { - outline: 2px solid var(--dsw-alias-state-warn-label); + outline: 2px solid var(--dsw-alias-state-business-primary); outline-offset: 2px; } diff --git a/packages/client/ui-plan/src/client/locales.ts b/packages/client/ui-plan/src/client/locales.ts index 96c15a0237..56e7ca9978 100644 --- a/packages/client/ui-plan/src/client/locales.ts +++ b/packages/client/ui-plan/src/client/locales.ts @@ -2,7 +2,7 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'chip.label': 'Plan', + 'chip.label': '计划', 'preview.title': '计划', 'preview.document': '计划 · Markdown', 'preview.action': '打开', @@ -16,11 +16,9 @@ export const zh = { 'preview.notFound': '未找到这份计划', 'preview.unavailable': '计划预览不可用', 'preview.expired': '临时计划预览已失效,请从仍在等待审批的卡片重新打开。', - 'chip.on.aria': 'plan mode 已开启,按下关闭', - 'chip.on.title': 'plan mode 已开启 — 点击关闭(/plan off)', - 'chip.off.aria': 'plan mode 已关闭,按下开启', - 'chip.off.title': 'plan mode 已关闭 — 点击开启(/plan)', - 'chip.exitFailed': '退出 plan mode 失败', + 'chip.on.aria': '计划模式已开启,按下关闭', + 'chip.on.title': '计划模式已开启 — 点击关闭(/plan off)', + 'chip.exitFailed': '退出计划模式失败', } satisfies Record /** The plan namespace key union. */ @@ -44,7 +42,5 @@ export const en = { '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', - 'chip.off.title': 'Plan mode off — click to turn on (/plan)', 'chip.exitFailed': 'Failed to exit plan mode', } satisfies Record diff --git a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx index 1249e89732..5e1e0fa286 100644 --- a/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx +++ b/packages/client/ui-plan/tests/plan-mode-control.client.spec.tsx @@ -32,7 +32,7 @@ function setup( return { store, exitPlanMode, view } } -const chip = () => screen.getByRole('button', { name: 'plan mode 已开启,按下关闭' }) +const chip = () => screen.getByRole('button', { name: '计划模式已开启,按下关闭' }) describe('PlanChip', () => { it('renders nothing for an absent capability or a default-mode target', () => { @@ -48,10 +48,10 @@ describe('PlanChip', () => { it('renders the Plan status for active and pending-entry targets', () => { setup({ active: true, pending: false }) - expect(chip().textContent).toBe('Plan') + expect(chip().textContent).toBe('计划') cleanup() setup({ active: false, pending: true }) - expect(chip().textContent).toBe('Plan') + expect(chip().textContent).toBe('计划') }) it('executes /plan off once and follows the projection down', async () => { @@ -65,7 +65,7 @@ describe('PlanChip', () => { resolve(null) store.set({ value: { active: true, pending: true } }) await waitFor(() => { - expect(screen.queryByRole('button', { name: 'plan mode 已开启,按下关闭' })).toBeNull() + expect(screen.queryByRole('button', { name: '计划模式已开启,按下关闭' })).toBeNull() }) }) @@ -81,7 +81,7 @@ describe('PlanChip', () => { .mockRejectedValueOnce('socket closed') setup({ active: true, pending: false }, exitPlanMode) fireEvent.click(chip()) - expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no') + expect((await screen.findByText('退出计划模式失败')).getAttribute('title')).toBe('host said no') expect(chip()).toBeTruthy() fireEvent.click(chip()) diff --git a/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md new file mode 100644 index 0000000000..14104cb63f --- /dev/null +++ b/snapshots/web/lifecycle-chrome/plan-active-zh.expected.md @@ -0,0 +1,25 @@ +- button "新建会话" +- button "收起侧边栏" +- button "新建会话": 新会话 +- navigation "全局面板": + - button "插件" +- text: 工作区 +- button "搜索会话" +- textbox "搜索会话…" +- button "视图选项" +- button "添加工作区" +- tree "会话": + - treeitem "workspace" [expanded] + - treeitem "新会话" [selected] +- button "设置" +- banner: + - button "打开右侧边栏" +- text: 探索未至之境 预览版 +- button "选择工作区": workspace +- button "标准模式" +- textbox "描述你想要构建的内容, / 调用指令, @ 文件或对话" +- button "添加文件或调用指令" +- button "访问模式,当前:工作区内修改": 工作区内修改 +- button "计划模式已开启,按下关闭": 计划 +- button "选择模型,当前 DeepSeek-V4-Flash": DeepSeek-V4-Flash +- button "发送消息" [disabled]