diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index a7b0ac458f..e891710dea 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 6126d06e48111a551a0dc57247659a3086986c66 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: e7bf4aa6952b4fd7b187c91e2ac5d64aac32cabb +2026-07-25-web-client-session-scope-and-provide-channel.md: 990a9b6eef862a7ac4a549358beae7f9081eaa0b +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 5c90611fab36611766bb407138c4f0c8a6b3a01b diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 6126d06e48..990a9b6eef 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -63,21 +63,21 @@ Session instances share the scope's lifecycle, while the catalog reports discove A session "materialized but with no first prompt" is governed by the summary-derived bit `blank` (a derived column, not a header field; SessionHeader stays immutable): -- The host criterion: `session.seq === 0` (zero log events = no user message yet). A live session reads `summarize()` straight from memory; a cold session is always `false` — the JSONL provider's lazy-create contract guarantees a never-appended session never enters `persistence.list()`, so blank never touches disk. +- The Host criterion is absence of `turn/start`; slash-command and configuration events do not clear `blank`. Live summaries use the Session projection. Cold summaries use cached metadata, leaving cache misses visible as unknown. Reuse acquires the writer under [blank Session acquisition](2026-09-17-process-local-blank-sessions.md). - The wire carries it in two places: the required `SessionSummary.blank` column, and the required `blank` field on the `host/session-added` frame (always true at creation, letting other tabs enter the same blank-session state into their mirrors). - The client mirror only lowers, never raises (monotonic), flipped from three sources, all reusing existing wire signals: - The sender's own tab: the **successful response** to the first `prompt()` flips false (acceptance proves the user/message is already in the host log — this flip is confirmation, not optimism; `onEngaged` synchronously updates the list mirror, converting the current `New Session` row in place to an ordinary title, adding no list row). A rejected first prompt keeps the session blank: aligned with host authority, still shown as `New Session`, keeping its connectWorkspace reuse eligibility while it remains a Workspace member. - Other tabs: the `host/session-status (running:true)` frame flips it — a blank session never runs, so the first running necessarily means no longer blank; - Reconnect alignment: `session.list`'s summary.blank is authoritative, so a tab that missed frames aligns naturally on its next pull; a stale blank:true can never mark a converted session back to blank. - List discipline: the store retains every row; the Workspace browser's grouping, flat view, search, and counts share one visible projection — every non-blank session shows, while blank sessions show only the row retained by the `mainView` source, with its title forced to `New Session`. After a Workspace switch, the old blank entity stays in the mirror but is hidden from the list while the target Workspace's main blank shows; the user-visible surface therefore holds at most one blank row globally. -- The residue ledger takes zero GC: after a refresh, blank sessions come back with the bit intact and are reused on the next same-workspace connect while they remain members, so the ordinary single-tab path keeps at most one per workspace; after a host restart, blanks leave no disk trace and simply evaporate; the extra empty shells from multi-tab races only become non-current hidden rows, digested by later reuse, with no coordination. +- Blank Sessions can persist through creation checkpoints and command events. Browser reloads and Host restarts reuse eligible blanks after acquiring their writer; occupied blanks are skipped. Old logs remain on disk without automatic cleanup. ### connectWorkspace: the sole entry point of New Session `workspaces.connectWorkspace(workspaceId): Promise` (owned by WorkspaceRuntime — it holds both the workspace canonical path and the sessions reference): -- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit returns that id directly, creating nothing. -- The create arm: on a miss, `session.create({workspaceId})` returns the new id. +- The reuse arm: the list mirror is searched for `blank && cwd == workspace.path && sessionIds.includes(id)` — the host's own membership rule, never cwd alone. A cwd match without the account slot (a CLI/TUI session birthed at the host cwd, or a deleted/recreated registration) would open a session no grouping surface can show under this Workspace, so it falls through to the create arm instead (see the [membership reuse fix](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md)); a hit is adopted with explicit-id `session.create`, which acquires and retains the writer before returning. Only writer contention skips a candidate. +- The create arm: when no candidate can be acquired, `session.create({workspaceId})` returns the new id. - An unknown workspaceId fails loud (never silently creating somewhere else). - The resolution guarantee (one contract for both arms): when the promise resolves, the returned id is already in the list store. The view owner then retains it synchronously, so a draft mover can write text through that binding before history readiness without waiting for a notifier flush. - The caller takes the id and installs a `mainView` reference; sending the first prompt is an ordinary `session.prompt` — the Session already exists, a failure is an ordinary prompt failure, the draft text is still in the machine, and a retry is simply sending again. @@ -135,6 +135,6 @@ Blank Sessions retain the header's leading and corner slots so navigation contro - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. -- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse. +- Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reuse acquires the persisted writer, and the ordinary ceiling rides same-Workspace reuse. - The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the [resident conversation shell](../../../../packages/client/ui-conversation/README.md) routes activation to the Workspace picker during that interval. - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index e7bf4aa695..5c90611fab 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -63,21 +63,21 @@ Session 实例与 scope 同生命周期;catalog 只报告可发现性,不持 「实体化但无首条提示词」的会话经 summary 派生位 `blank` 治理(派生列而非 header 字段,SessionHeader 保持不可变): -- host 判据:`session.seq === 0`(零日志事件 = 尚无用户消息)。live 会话 `summarize()` 内存直读;cold 会话恒 `false`——JSONL provider 的 lazy-create 约定保证 never-appended 会话不进入 `persistence.list()`,所以 blank 从不落盘。 +- Host 判据是没有 `turn/start`;slash 命令与配置事件不会清除 `blank`。Live summary 使用 Session 投影。Cold summary 使用缓存元数据,缓存缺失时以未知状态保留在列表中。复用按[空白 Session 获取](2026-09-17-process-local-blank-sessions.zh.md)规则取得写锁。 - wire 承载两处:`SessionSummary.blank` 必填列;`host/session-added` 帧必填 `blank` 字段(创建时恒 true,供别的 tab 按同一空会话状态入镜像)。 - client 镜像只降不升(单调),三来源翻转,全部复用既有 wire 信号: - 发送方本地:首次 `prompt()` 的**成功响应**翻 false(受理即证明用户消息已入 host 日志——此点翻转是确证而非乐观;`onEngaged` 同步更新列表镜像,当前 `New Session` 行原地转为普通标题,不新增列表行)。首条提示词被拒则会话保持 blank:与 host 权威对齐、继续显示为 `New Session`、在仍为该工作区成员时保持 connectWorkspace 复用资格。 - 其他端:`host/session-status (running:true)` 帧翻转——blank 会话从不 running,首次 running 必然已非 blank; - 重连对齐:`session.list` 的 summary.blank 是权威,错过帧的端下次拉取自然对齐;陈旧的 blank:true 不能把已转正的会话重新标回 blank。 - 列表纪律:store 保留全部行;Workspace browser 的分组、平铺、搜索和计数共用同一可见投影——所有非 blank 会话都显示,blank 会话只显示由 `mainView` 来源持有的一行,并强制标题为 `New Session`。切换 Workspace 后,旧 blank 实体仍在镜像中但从列表隐藏,目标 Workspace 的主 blank 显示;因此用户可见面全局至多一条 blank 行。 -- 残留账零 GC:刷新后 blank 会话带位回来,下次同 workspace 且仍为成员时复用,普通单端路径使每个 workspace 至多保留一个;host 重启后 blank 无盘痕自然蒸发;多 tab 竞态多出的空壳只会成为非 current 隐藏行,后续复用消化,不做协调。 +- 空白 Session 可以通过创建检查点与命令事件持久化。浏览器刷新与 Host 重启会在取得写锁后复用符合条件的空白会话,并跳过被占用的候选。旧日志保留在磁盘上,不自动清理。 ### connectWorkspace:New Session 的唯一入口 `workspaces.connectWorkspace(workspaceId): Promise`(归属 WorkspaceRuntime——它同时持有 workspace 规范 path 与 sessions 引用): -- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中直接返回该 id,不新建。 -- 新建臂:未命中则 `session.create({workspaceId})`,返回新 id。 +- 复用臂:list mirror 中找 `blank && cwd == workspace.path && sessionIds.includes(id)`——host 自己的成员规则,绝不只按 cwd。没有账户槽位的 cwd 匹配(CLI(命令行界面)/TUI 在 host cwd 创建的会话,或已删除/重建的注册)会打开一个任何分组表面都无法显示在该工作区下的会话,因此落到新建臂(见[成员复用修复](../../archived/bug-fix/2026-08-05-workspace-blank-session-reuse-membership.md));命中后通过显式 ID 的 `session.create` 收养,在返回前取得并持续持有写锁。仅写锁争用会跳过候选。 +- 新建臂:没有可获取的候选时,`session.create({workspaceId})`,返回新 id。 - 未知 workspaceId fail loud(不静默创建到别处)。 - 解析保证(两臂同约定):promise resolve 时返回的 id 已在 list store。视图 owner 随后同步 retain,因此 draft 搬运方可以在历史就绪前通过该 binding 写入文本,无需等待 notifier flush。 - 调用方拿 id 安装一份 `mainView` reference;首条提示词发送就是普通 `session.prompt`——Session 本来就在,失败即普通提示词失败,draft 文本还在 machine 里,重试即再次发送。 @@ -135,6 +135,6 @@ blank Session 保留 header 的 leading 与 corner slot,让右侧栏展开入 - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 -- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 Workspace 复用。 +- 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),复用时获取持久化写锁,常规上限靠同 Workspace 复用。 - 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,[常驻会话壳](../../../../packages/client/ui-conversation/README.zh.md)会把激活操作转到 Workspace picker。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml new file mode 100644 index 0000000000..e89e510aea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md +2026-09-17-process-local-blank-sessions.md: 58d222185ae67c1f70874c0d0a73910eaa1e0e5b +2026-09-17-process-local-blank-sessions.zh.md: 0f3fdf050931d11a80e92d98577ac8b42628bb60 diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md new file mode 100644 index 0000000000..58d222185a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.md @@ -0,0 +1,35 @@ +# Agent Note: Writer acquisition for blank Session reuse + +Status: implemented + +English | [中文](2026-09-17-process-local-blank-sessions.zh.md) + +## Problem + +Two Hosts sharing Session storage can select the same blank Session when opening a Workspace. A blank Session already owns a live Agent, accepts slash commands, and can hold its writer lock after the creation checkpoint. An unowned persisted blank also carries command settings that remain useful after a Host restart. + +## Decision + +The Session catalog includes persisted blanks. Startup restoration selects the saved blank; ordinary Workspace navigation selects the first eligible unarchived member in catalog order. Explicit-id `session.create` resumes that Session and acquires its writer before opening history. Only `session/writer-held` triggers creation of a new Session. Other errors propagate. Concurrent Workspace connects in one Client share the acquisition and fallback. Later navigation cancels a pending startup selection. + +The Agent retains the acquired handle throughout its lifetime. Checking ownership and releasing a probe lock would leave a race before resume. No PID or process identity enters persisted data or the Remote schema. + +This decision extends the blank-reuse policy in the [Client Session scope decision](2026-07-25-web-client-session-scope-and-provide-channel.md); its real-Session slash support and Provider adoption rationale remain active. + +## Alternatives considered + +**Client-only drafts.** Slash commands and Session-scoped plugins require a real Session before the first prompt. Deferring creation requires a separate command lifecycle. + +**Release a live blank's writer lock.** Blank means no `turn/start`, not no events. A live Agent can still append command and configuration events, so releasing its lock permits competing writers. + +**Hide every cold blank.** This discards reusable command settings from navigation and creates another blank after every restart, even when the old writer is free. + +**Persist a creator PID.** Kernel writer ownership already arbitrates acquisition. PID reuse and stale creator records add no authority. + +## Consequences + +A Host can reuse its own live blank or reclaim an unowned persisted blank with its slash state. Separate Hosts create distinct blanks while existing writers remain held. A held selected blank causes fresh creation even when another blank is free; navigation does not search for another candidate. No cleanup is added. Opening the same established conversation from two Hosts can still encounter writer contention. Unknown projection hints remain visible without cold body scans; automatic reuse requires known blank metadata. A non-contention acquisition failure aborts New Session and is currently reported only to the console; hidden blanks have no recovery action in this flow. + +## Verification + +Navigation tests cover saved selection, single-candidate contention fallback, non-contention failures, overlapping acquisition, and superseding navigation. A shipped Web-composition test holds a real persistence writer, verifies fresh creation despite another free blank, and reclaims the released selected blank with its plan state. The fresh-round-trip recorded Session scenario reloads its blank before submitting the recorded prompt. diff --git a/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md new file mode 100644 index 0000000000..0f3fdf0509 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-17-process-local-blank-sessions.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 复用空白 Session 时获取写锁 + +Status: implemented + +[English](2026-09-17-process-local-blank-sessions.md) | 中文 + +## Problem + +两个共享 Session 存储的 Host 在打开 Workspace 时可能选中同一个空白 Session。空白 Session 已有活动 Agent,可以接受 slash 命令,并且可能在创建检查点后持有写锁。无人占用的持久化空白会话也可能带有 Host 重启后仍有用的命令设置。 + +## Decision + +Session 列表包含持久化空白会话。启动恢复选择已保存的空白会话;普通 Workspace 导航按列表顺序选择第一个符合条件且未归档的成员。显式 ID 的 `session.create` 会恢复该 Session,并在打开历史前取得写锁。只有 `session/writer-held` 会触发新建 Session。其他错误返回调用方。同一 Client 的并发 Workspace 连接共享获取与回退过程。后续导航会取消尚未完成的启动选择。 + +Agent 在整个生命周期内持有取得的句柄。检查占用后释放探测锁,会在恢复前留下竞态。持久化数据与 Remote schema 均不引入 PID 或进程身份。 + +本决策扩展 [Client Session scope 决策](2026-07-25-web-client-session-scope-and-provide-channel.zh.md)中的空白会话复用策略;其真实 Session 支持 slash 以及 Provider 收养的理由仍然有效。 + +## Alternatives considered + +**纯客户端草稿。** Slash 命令与 Session 作用域插件在首条提示词之前就需要真实 Session。延迟创建需要单独的命令生命周期。 + +**释放活动空白会话的写锁。** Blank 表示没有 `turn/start`,并不表示没有事件。活动 Agent 仍可能追加命令与配置事件,因此释放其锁会允许多个写入方竞争。 + +**隐藏所有冷空白会话。** 这会让导航丢失可复用的命令设置,并且每次重启都创建另一空白会话,即使旧写锁已空闲。 + +**持久化创建者 PID。** 内核写锁已能仲裁获取操作。PID 复用与过时的创建者记录无法增加权威性。 + +## Consequences + +Host 可以复用自己的活动空白会话,也可以接管无人占用的持久化空白会话并保留其 slash 状态。现有写锁被持有时,不同 Host 创建不同的空白会话。所选空白会话被占用时,即使另有空闲空白会话也会新建;导航不再寻找其他候选。不增加清理逻辑。两个 Host 打开同一个已有对话时仍可能遇到写锁争用。未知投影提示仍可见且不扫描冷日志正文;自动复用要求已知的空白元数据。非锁争用的获取错误会中止新建会话,目前只报告到控制台;此流程没有针对隐藏空白会话的恢复操作。 + +## Verification + +导航测试覆盖已保存选择、单候选锁争用回退、其他错误、重叠获取以及后续导航替代。实际 Web 组合测试持有真实持久化写锁,验证另有空闲空白会话时仍新建,并接管已释放的所选空白会话及其 plan 状态。fresh-round-trip 的录制 Session 场景在提交已录制的提示词前刷新空白会话。 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml index 16c17f3405..c18c6397ed 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md -2026-09-08-feedback-dialog-and-categories.md: ee656946a9e5d2921a02a1b642041e5965661f31 -2026-09-08-feedback-dialog-and-categories.zh.md: ef2b2d4670aad12398125925dd1e1a7e7c10a5f0 +2026-09-08-feedback-dialog-and-categories.md: 1d544e6ef978c10008ac62768b6faaf9eaaf8b73 +2026-09-08-feedback-dialog-and-categories.zh.md: 6e58820bf308a3a9f7d15b64027f90e7e6771bd4 diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md index ee656946a9..1d544e6ef9 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.md @@ -14,10 +14,14 @@ The Web client had two disconnected feedback paths with no visible outcome. `/fe `ui-message-feedback` becomes the Web feedback surface. A per-session `FeedbackSurface` owns the message-feedback controller, a `FeedbackDialogController` for the draft, the submission, and the toast sequence, and the routing between them: a message target puts its selected judgment with the dialog's category and note through the message controller, while the Session target records through `ctx.remote.sessionFeedback`. A `FeedbackDialog` entry of `conversation.input.overlay` renders the Modal and Toast primitives from the dialog store. A decoration on the Host's `feedback` command opens the dialog for the Session from a menu pick or a bare Enter while `/feedback ` still reaches the Host; it uses the `action` kind in `CommandUiSpec`, which consumes the trigger token and runs a client callback without submitting anything. The later [symmetric message feedback submission](2026-09-10-symmetric-message-feedback-submission.md) decision owns the rating entry rule: either unrecorded rating opens the dialog, while clicking the recorded rating retracts it. The note popover, `clearNote`, and `clear` remain absent because the dialog is the only note editor. +The Session Header's more-actions menu also opens the Session dialog through the `feedbackUi.openSession` Client service. The menu remains owned by `session-log-export`, while the existing per-session feedback controller owns the draft and submission. An observable tracks the optional feedback plugin's availability, so unloading feedback removes only its menu row and leaves export usable. Opening or dismissing the form writes no feedback event; Submit records one Session remark. + The dialog is the shared Modal card at the design's width; the design's checkbox for including the conversation log is not built, because the log travels with every feedback event and is not optional. An oversized description still fails on submit with `note-too-large`; the dialog stays open with its draft and a warning toast presents the localized failure. ## Alternatives considered +**A child slot for Header menu actions.** A slot would let each plugin own its menu row, but requires a shared menu-item API beyond the feedback entry requested in #4445. The optional `feedbackUi` service keeps this change local while leaving dialog state and submission in the feedback plugin. + **Encode the category into the note text.** A prefix in free text is not filterable without parsing and would leak into the verbatim note that telemetry uploads; a durable id in the payload is what a consumer can group by. **Submit the dialog through the command plane as `/feedback `.** The command rejects empty text, cannot carry a category, and writes an acknowledgement row the design replaces with a toast; the Remote records the same event with neither constraint. diff --git a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md index ef2b2d4670..6e58820bf3 100644 --- a/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-feedback-dialog-and-categories.zh.md @@ -14,10 +14,14 @@ Web 客户端有两条互不相连的反馈路径,且都没有可见结果。` `ui-message-feedback` 成为 Web 反馈界面。每个 Session 一个 `FeedbackSurface`,拥有消息反馈控制器、负责草稿、提交与 toast 序号的 `FeedbackDialogController`,以及两者之间的路由:消息目标经消息控制器 put 一条带弹窗分类与备注的所选评分,Session 目标经 `ctx.remote.sessionFeedback` 记录。`conversation.input.overlay` 的 `FeedbackDialog` 条目从弹窗 store 渲染 Modal 与 Toast 基元。宿主 `feedback` 命令上的装饰让菜单选中或不带参数的回车为 Session 打开弹窗,而 `/feedback ` 仍到达宿主;它使用 `CommandUiSpec` 中的 `action` 种类:裸调用消费触发 token 后运行一个客户端回调,不提交任何内容。后续的[对称消息反馈提交](2026-09-10-symmetric-message-feedback-submission.zh.md)决策拥有评分入口规则:任一未记录的评分都会打开弹窗,再次点击已记录的评分则撤回。备注浮层、`clearNote` 与 `clear` 继续保持移除,因为弹窗是唯一的备注编辑器。 +会话标题栏的更多操作菜单也通过 `feedbackUi.openSession` Client 服务打开 Session 弹窗。菜单仍由 `session-log-export` 持有,现有的逐 Session 反馈控制器持有草稿与提交。一个可观察值跟踪可选反馈插件的可用状态,因此卸载反馈插件只移除其菜单行,导出仍然可用。打开或关闭表单不写反馈事件;点击提交才记录一条 Session 反馈。 + 弹窗是共用的 Modal 卡片,宽度按设计稿;设计稿里「包括当前对话的日志」复选框不做,因为日志随每个反馈事件一起投递,不是可选项。超长描述仍在提交时以 `note-too-large` 失败;弹窗保留草稿并通过警告 toast 展示本地化错误。 ## 考虑过的替代方案 +**为标题栏菜单动作声明子 slot。** slot 可以让每个插件持有自己的菜单行,但需要共享菜单项 API,超出 #4445 所需反馈入口的范围。可选的 `feedbackUi` 服务将改动限制在局部,同时让弹窗状态与提交继续由反馈插件持有。 + **把分类编进备注文本。** 自由文本里的前缀不解析就无法过滤,还会混进遥测上传的原样备注;载荷里的持久 id 才是消费方能分组的东西。 **让弹窗经命令平面以 `/feedback ` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。 diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml index 0458b32e17..057e138853 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md -2026-09-08-present-workspace-source-files.md: e1bc2bbc56d423741ca53550af2879fed3f01ecf -2026-09-08-present-workspace-source-files.zh.md: e4f21172b36a5fef50e44a79adca71011ee90a3b +2026-09-08-present-workspace-source-files.md: 52cc3e9cdbcd30d378117a327b86062523a2cb3e +2026-09-08-present-workspace-source-files.zh.md: 99b7b9df1d9ca2da2175df1ae7e9d8acbb5c5fdd diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md index e1bc2bbc56..52cc3e9cdb 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.md @@ -12,7 +12,7 @@ Users need to open and edit the files produced in their workspace, including she The [present tool](../../../../packages/deliverables/tool-present/README.md) declares existing regular source files under the [Session filesystem access policy](2026-09-09-present-filesystem-access.md). It records paths and optional descriptions without reading or copying contents. The [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) opens current workspace sources in the Host's default application. Edits are visible on the next open; deletion or movement makes the declaration unavailable. File-content preservation and copy-on-write storage are deferred until a persistence design owns them. -The tool description requires `present` after writing a file the user asked to receive and before the final response, including files created through Bash or code execution. A prose path reference does not replace the call. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) uses a user request that does not name `present`, and checks the resulting file, delivery event, and card. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. +The [tool description](../../../../packages/deliverables/tool-present/README.md#model-experience) guides the model's choice of files for explicit delivery. A declaration remains distinct from a prose file reference. The recorded [SVG delivery scenario](../../../../snapshots/web/present-svg/snapshot.yml) covers the resulting file, delivery event, and card. New recordings explicitly request a separate file card; replay preserves the recorded request. This scenario verifies explicit delivery, not a requirement to call `present` for every SVG. Its UI snapshot covers the expanded Chat transcript; navigation and composer controls belong to their own scenarios, so unrelated chrome changes cannot invalidate file-delivery expectations. The tool remains an ordinary package with shared filesystem and tool error classes. Its pure type entry owns the delivery event without importing Host code into the browser. The `standard`, `ptc`, and `cordis` presets mount it; `minimal` retains its two tools. Each plugin instance correlates its executions with successful final `tools/result` notifications before appending `deliverables/presented`. Native and nested calls share this rule. A later enclosing program failure does not revoke a completed nested declaration; blocked results publish none, and same-name scoped replacements cannot publish another instance's results. diff --git a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md index e4f21172b3..99b7b9df1d 100644 --- a/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md +++ b/.agents/notes/implemented/feature/2026-09-08-present-workspace-source-files.zh.md @@ -12,7 +12,7 @@ Status: implemented [present 工具](../../../../packages/deliverables/tool-present/README.zh.md)声明交付[Session 文件系统访问策略](2026-09-09-present-filesystem-access.zh.md)允许的已有普通源文件。它记录路径和可选说明,不读取或复制内容。[交付插件](../../../../packages/client/ui-deliverables/README.zh.md)使用 Host 默认应用打开当前工作区源文件。下次打开会看到编辑后的内容;删除或移动文件会使声明不可用。文件内容保留与写时复制存储延期到有持久化设计负责时实现。 -工具说明要求在写好用户要求接收的文件后、最终回复前调用 `present`,包括通过 Bash 或代码执行创建的文件。正文中的路径引用不能替代调用。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)使用未提及 `present` 的用户请求,检查生成文件、交付事件和卡片。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 +[工具说明](../../../../packages/deliverables/tool-present/README.zh.md#model-experience)指导模型选择需要显式交付的文件。交付声明与正文中的文件引用各自独立。录制的 [SVG 交付场景](../../../../snapshots/web/present-svg/snapshot.yml)覆盖生成文件、交付事件和卡片。新录制明确请求独立文件卡片,回放保留录制时的请求。该场景验证显式交付,不要求所有 SVG 都调用 `present`。其 UI 快照覆盖展开后的 Chat 对话内容;导航和输入框控件由各自场景负责,避免无关界面改动使文件交付预期失效。 工具保持为普通包,共享文件系统和工具错误类型。其纯类型入口拥有交付事件,不向浏览器导入 Host 代码。`standard`、`ptc` 与 `cordis` preset 挂载工具;`minimal` 保持两个工具。每个插件实例将其执行与成功的最终 `tools/result` 通知关联,再追加 `deliverables/presented`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。 diff --git a/apps/desktop/tests/packaging-run.spec.ts b/apps/desktop/tests/packaging-run.spec.ts index b0ff7fce39..c0034ad53b 100644 --- a/apps/desktop/tests/packaging-run.spec.ts +++ b/apps/desktop/tests/packaging-run.spec.ts @@ -47,7 +47,20 @@ describe('packaging run records', () => { cwd: root, env: environment, })).rejects.toThrow('fatal failed') const descendant = JSON.parse(await readFile(join(run.directory, 'descendant.json'), 'utf8')) as { pid: number } - expect(() => process.kill(descendant.pid, 0)).toThrow() + // Linux can retain a dead descendant as a zombie until its new parent reaps it. + await expect.poll(async () => { + try { + if (process.platform === 'linux') { + const status = await readFile(`/proc/${descendant.pid}/status`, 'utf8') + return /^State:\s+[ZXx]\b/m.test(status) + } + process.kill(descendant.pid, 0) + return false + } catch (error) { + if (['ENOENT', 'ESRCH'].includes((error as NodeJS.ErrnoException).code ?? '')) return true + throw error + } + }, { timeout: 5_000 }).toBe(true) const events = (await readFile(join(run.directory, 'events.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as { type: string; fatalObserved?: boolean; terminationError?: boolean }) expect(events.at(-1)).toMatchObject({ type: 'stage-end', fatalObserved: true, terminationError: false }) run.finish(false) diff --git a/apps/web/tests/blank-writer-reuse.e2e.ts b/apps/web/tests/blank-writer-reuse.e2e.ts new file mode 100644 index 0000000000..99b2be07e3 --- /dev/null +++ b/apps/web/tests/blank-writer-reuse.e2e.ts @@ -0,0 +1,97 @@ +/** Blank Session writer acquisition through the shipped Web composition. */ + +import { chromium, type Browser, type Page } from 'playwright' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionHandle } from '@deepseek-ai/dsh-session-persistence' +import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts' +import { newEnglishPage } from './support.ts' + +const HELD = SessionId('blank-held') +const FREE = SessionId('blank-free') + +describe.each([false, true])('web e2e: selected blank writer held: %s', (held) => { + let scaffold: WebScaffold + let browser: Browser + let writer: SessionHandle | undefined + const tripwires: ReturnType[] = [] + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) + for (const sessionId of [HELD, FREE]) { + const handle = await scaffold.ctx.agents.create({ + sessionId, + meta: { cwd: scaffold.workspaceCwd }, + agentOptions: scaffold.ctx.agentDefaultModel.currentSelection(), + }) + try { + handle.agent.session.append('plan/mode', { active: true }) + await workspace.attachSession(sessionId) + } finally { + await handle.dispose() + } + } + if (held) writer = await scaffold.ctx.sessionPersistence.open(HELD, 'write') + browser = await chromium.launch() + }, 120_000) + + afterAll(async () => { + await browser?.close() + await writer?.close() + await scaffold?.close() + }) + + async function openSavedBlank(sessionId = HELD): Promise { + const page = await newEnglishPage(browser) + tripwires.push(watchConsole(page)) + await page.addInitScript((id) => { + localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: id })) + }, sessionId) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 30_000 }) + return page + } + + async function selected(page: Page): Promise { + return page.evaluate(() => { + const value = localStorage.getItem('dsh.sessions.current') + return value === null ? undefined : (JSON.parse(value) as { sessionId?: string }).sessionId + }) + } + + it('reuses the selected blank or creates on contention without trying another blank', async () => { + const first = await openSavedBlank() + await expect.poll(async () => { + const id = await selected(first) + return id !== undefined && (!held || id !== HELD) + }).toBe(true) + const selectedId = SessionId((await selected(first))!) + expect(selectedId).not.toBe(FREE) + await expect.poll(() => scaffold.ctx.agents.get(selectedId)).toBeDefined() + if (!held) { + expect(selectedId).toBe(HELD) + const reclaimed = scaffold.ctx.agents.get(HELD)! + expect(scaffold.ctx.sessionProjections.stateOf(reclaimed.session, 'plan')).toMatchObject({ active: true }) + await expect(scaffold.ctx.sessionPersistence.open(HELD, 'write')) + .rejects.toMatchObject({ name: 'SessionAlreadyOwnedError' }) + } + const count = held ? 3 : 2 + expect(scaffold.ctx.workspaceRegistry.list()[0]!.sessionIds).toHaveLength(count) + + const second = await openSavedBlank(selectedId) + await expect.poll(() => selected(second)).toBe(selectedId) + expect(scaffold.ctx.workspaceRegistry.list()[0]!.sessionIds).toHaveLength(count) + + await writer?.close() + writer = undefined + const third = await openSavedBlank() + await expect.poll(() => scaffold.ctx.agents.get(HELD)).toBeDefined() + await expect.poll(() => selected(third)).toBe(HELD) + expect(scaffold.ctx.workspaceRegistry.list()[0]!.sessionIds).toHaveLength(count) + for (const tripwire of tripwires) { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } + }) +}) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index fd85c388f8..a5b45a3511 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -121,11 +121,61 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(await header.evaluate(element => element.getBoundingClientRect().height)).toBeGreaterThan(compactHeight) await card.waitFor({ state: 'visible' }) expect(await card.getByText('已编辑 4 个文件', { exact: true }).count()).toBe(1) - expect(await card.getByRole('listitem').count()).toBe(3) - expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(1) + expect(await card.getByRole('listitem').count()).toBe(4) + expect(await card.getByRole('button', { name: '展开全部 4 个改动文件' }).count()).toBe(0) // The header and every row open the turn's review in the Sidebar, with or without a Host desktop. expect(await card.getByRole('button', { name: '在侧边栏查看本轮改动' }).count()).toBe(1) expect(await card.getByRole('button', { name: '查看 notes.txt 的改动' }).count()).toBe(1) + const geometry = await card.evaluate((element) => { + const requiredElement = (value: T | null | undefined, name: string): T => { + if (value === null || value === undefined) throw new Error(`changed-files layout is missing ${name}`) + return value + } + const header = requiredElement(element.children[0] as HTMLElement | undefined, 'header') + const tile = requiredElement(header.children[0] as HTMLElement | undefined, 'tile') + const tileMark = requiredElement(tile.children[0] as HTMLElement | undefined, 'tile mark') + const icon = requiredElement(tileMark.querySelector('svg'), 'icon') + const titles = requiredElement(header.children[1] as HTMLElement | undefined, 'titles') + const title = requiredElement(titles.children[0] as HTMLElement | undefined, 'title') + const stat = requiredElement(titles.children[1] as HTMLElement | undefined, 'stat') + const list = requiredElement(element.querySelector('ul'), 'list') + const row = requiredElement(list.querySelector('button'), 'row') + const path = requiredElement(row.children[0] as HTMLElement | undefined, 'path') + return { + radius: getComputedStyle(element).borderRadius, + headerHeight: header.getBoundingClientRect().height, + headerPadding: getComputedStyle(header).padding, + tileSize: `${tile.getBoundingClientRect().width}x${tile.getBoundingClientRect().height}`, + tileBorder: getComputedStyle(tile).borderTopWidth, + tileMarkSize: `${tileMark.getBoundingClientRect().width}x${tileMark.getBoundingClientRect().height}`, + tileMarkRadius: getComputedStyle(tileMark).borderRadius, + iconWidth: icon.getAttribute('width'), + titleFontSize: getComputedStyle(title).fontSize, + statFontSize: getComputedStyle(stat).fontSize, + listPadding: getComputedStyle(list).padding, + rowHeight: row.getBoundingClientRect().height, + rowPadding: getComputedStyle(row).padding, + rowFontSize: getComputedStyle(row).fontSize, + pathFontSize: getComputedStyle(path).fontSize, + } + }) + expect(geometry).toEqual({ + radius: '18px', + headerHeight: 60, + headerPadding: '8px 10px', + tileSize: '40x40', + tileBorder: '1px', + tileMarkSize: '20x20', + tileMarkRadius: '8px', + iconWidth: '10', + titleFontSize: '13px', + statFontSize: '10px', + listPadding: '0px', + rowHeight: 32, + rowPadding: '7px 18px 7px 14px', + rowFontSize: '11px', + pathFontSize: '12px', + }) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) @@ -142,18 +192,111 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await card.getByRole('button', { name: '查看 notes.txt 的改动' }).click() await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' }) expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1) - await expect.poll(() => drawn(review)).toEqual(['context:11 start', 'add:2+done']) + await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) + const compareTool = review.locator('[data-review-tool="split"]') + const wrapTool = review.locator('[data-review-tool="wrap"]') + expect(await compareTool.evaluate(button => getComputedStyle(button).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + expect(await wrapTool.evaluate(button => getComputedStyle(button).backgroundColor)).toBe('rgba(0, 0, 0, 0)') + expect(await compareTool.locator('svg').evaluate(icon => getComputedStyle(icon).transform)).toBe('matrix(0, 1, -1, 0, 0, 0)') + await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['context:1start', 'add:']) + expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['context:1start', 'add:2done']) + const headerAlignment = await review.locator('[data-review-file="notes.txt"]').evaluate((button) => { + const center = (element: Element) => { + const rect = element.getBoundingClientRect() + return rect.top + rect.height / 2 + } + const text = button.querySelector('span') + const caret = button.querySelector('svg') + const counts = button.parentElement?.nextElementSibling + if (text === null || caret === null || counts === null || counts === undefined) { + throw new Error('review selector alignment elements are missing') + } + const buttonCenter = center(button) + return [center(text), center(caret), center(counts)].map(value => Math.abs(value - buttonCenter)) + }) + for (const offset of headerAlignment) expect(offset).toBeLessThanOrEqual(0.5) + const addedLine = review.locator('[data-diff-side="right"] [data-diff-line="add"]') + const rightContextLine = review.locator('[data-diff-side="right"] [data-diff-line="context"]') + const addedRule = await addedLine.evaluate((line) => { + const number = line.children[0] + const text = line.querySelector('span:last-child') + if (number === undefined || text === null) throw new Error('added diff line is incomplete') + const left = line.getBoundingClientRect().left + return { + shadow: getComputedStyle(line).boxShadow, + textColour: getComputedStyle(text).color, + numberLeft: number.getBoundingClientRect().left - left, + textLeft: text.getBoundingClientRect().left - left, + } + }) + const rightContextRule = await rightContextLine.evaluate((line) => { + const number = line.children[0] + const text = line.children[1] + if (number === undefined || text === undefined) throw new Error('context diff line is incomplete') + const left = line.getBoundingClientRect().left + return { + numberLeft: number.getBoundingClientRect().left - left, + textLeft: text.getBoundingClientRect().left - left, + } + }) + expect(addedRule.shadow).toContain(addedRule.textColour) + expect(addedRule.numberLeft).toBeCloseTo(rightContextRule.numberLeft, 1) + expect(addedRule.textLeft).toBeCloseTo(rightContextRule.textLeft, 1) // The ignored file has no snapshot; its comparison comes from the copies captured around the write call. await review.getByRole('button', { name: '选择要查看的文件' }).click() + const selectedAlignment = await page.getByRole('menuitem').filter({ hasText: 'notes.txt' }).evaluate((row) => { + const center = (element: Element) => { + const rect = element.getBoundingClientRect() + return rect.top + rect.height / 2 + } + const content = row.querySelector('span > span') + const path = content?.children[0] + const counts = content?.children[1] + const check = row.querySelector(':scope > svg') + if (path === undefined || counts === undefined || check === null) throw new Error('review menu alignment elements are missing') + const rowCenter = center(row) + return [center(path), center(counts), center(check)].map(value => Math.abs(value - rowCenter)) + }) + for (const offset of selectedAlignment) expect(offset).toBeLessThanOrEqual(0.5) await page.getByRole('menuitem').filter({ hasText: 'app.local' }).click() await review.locator('[data-review-file="app.local"]').waitFor({ state: 'visible' }) - await expect.poll(() => drawn(review)).toEqual(['add:1+mode=demo']) + await expect.poll(() => drawn(review.locator('[data-diff-side="right"]'))).toEqual(['add:1mode=demo']) expect(await review.getByText('本轮新建的文件').count()).toBe(1) - // A card row opens the same tab on another file; the split and wrap choices switch the drawing. + // A card row opens the same tab on another file; split is the initial view and wrap changes its drawing. await card.getByRole('button', { name: '查看 intro.md 的改动' }).click() await review.locator('[data-review-file="intro.md"]').waitFor({ state: 'visible' }) expect(await column.locator('[data-dockkit-tab]').filter({ hasText: '第 1 轮改动' }).count()).toBe(1) - await review.getByRole('button', { name: '左右对比' }).click() + const deletedLine = review.locator('[data-diff-side="left"] [data-diff-line="del"]').first() + await expect.poll(() => deletedLine.evaluate(line => line.querySelector('[data-diff-code] span[style]') !== null)).toBe(true) + const deletedRule = await deletedLine.evaluate((line) => { + const number = line.children[0] + const text = line.querySelector('span:last-child') + if (number === undefined || text === null) throw new Error('deleted diff line is incomplete') + const left = line.getBoundingClientRect().left + return { + shadow: getComputedStyle(line).boxShadow, + textColour: getComputedStyle(text).color, + numberLeft: number.getBoundingClientRect().left - left, + textLeft: text.getBoundingClientRect().left - left, + } + }) + const leftContextRule = await review.locator('[data-diff-side="left"] [data-diff-line="context"]').first().evaluate((line) => { + const number = line.children[0] + const text = line.children[1] + if (number === undefined || text === undefined) throw new Error('context diff line is incomplete') + const left = line.getBoundingClientRect().left + return { + numberLeft: number.getBoundingClientRect().left - left, + textLeft: text.getBoundingClientRect().left - left, + } + }) + expect(deletedRule.shadow).toContain(deletedRule.textColour) + expect(deletedRule.numberLeft).toBeCloseTo(leftContextRule.numberLeft, 1) + expect(deletedRule.textLeft).toBeCloseTo(leftContextRule.textLeft, 1) + await compareTool.click() + await review.locator('[data-review-view="unified"]').waitFor({ state: 'visible' }) + expect(await compareTool.locator('svg').evaluate(icon => getComputedStyle(icon).transform)).toBe('none') + await compareTool.click() await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。']) expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。']) @@ -197,6 +340,13 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await review.getByRole('button', { name: '自动换行' }).click() await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' }) await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。']) + const wrappedAddition = review.locator('[data-diff-line="del"] > span').nth(1) + const wrappedAdditionRule = await wrappedAddition.evaluate((cell) => { + const text = cell.querySelector('[data-diff-code]') + if (text === null) throw new Error('wrapped addition has no text') + return { shadow: getComputedStyle(cell).boxShadow, textColour: getComputedStyle(text).color } + }) + expect(wrappedAdditionRule.shadow).toContain(wrappedAdditionRule.textColour) // No desktop, so the tools offer the sidebar file but no native open. expect(await review.locator('[data-review-tool="open-file"]').count()).toBe(1) expect(await review.locator('[data-review-tool="open-native"]').count()).toBe(0) diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 78528ab7d5..3b7a533f1a 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -11,16 +11,16 @@ - switch "启用 智能体团队" - listitem: - button "查看 终端": 终端 - - text: 限制 agent 运行的每一条命令。 + - text: 限制每条命令最多能跑多久、最多输出多少内容。 - listitem: - button "查看 Agent 循环": Agent 循环 - - text: Agent 如何派发工具调用。 + - text: 控制 Agent 派发工具调用的方式。 - listitem: - button "查看 Subagent": Subagent - text: 设置 Subagent 的递归层级、数量和模型。 - listitem: - button "查看 网页搜索": 网页搜索 - - text: DeepSeek 搜索提供方。 + - text: 设置 DeepSeek 的搜索提供方。 - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md index 20815bb8fc..3493ac3e13 100644 --- a/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/cancelled.expected.md @@ -2,9 +2,8 @@ - heading "添加插件" [level=2] - button "关闭" - paragraph: 输入插件的包名、GitHub 仓库地址或本地目录路径。 - - text: 包名或地址 - textbox "包名或地址": - /placeholder: 例如 @deepseek-ai/dsh-experimental-auto-review - text: slow-package - - button "不知道该填什么?" + - button "插件安装引导和示例" - button "安装" diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index d0f1b2a24e..ae850f5daa 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -11,16 +11,16 @@ - switch "启用 智能体团队" - listitem: - button "查看 终端": 终端 - - text: 限制 agent 运行的每一条命令。 + - text: 限制每条命令最多能跑多久、最多输出多少内容。 - listitem: - button "查看 Agent 循环": Agent 循环 - - text: Agent 如何派发工具调用。 + - text: 控制 Agent 派发工具调用的方式。 - listitem: - button "查看 Subagent": Subagent - text: 设置 Subagent 的递归层级、数量和模型。 - listitem: - button "查看 网页搜索": 网页搜索 - - text: DeepSeek 搜索提供方。 + - text: 设置 DeepSeek 的搜索提供方。 - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index 7be62ce234..a13d1a001e 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -11,16 +11,16 @@ - switch "启用 智能体团队" - listitem: - button "查看 终端": 终端 - - text: 限制 agent 运行的每一条命令。 + - text: 限制每条命令最多能跑多久、最多输出多少内容。 - listitem: - button "查看 Agent 循环": Agent 循环 - - text: Agent 如何派发工具调用。 + - text: 控制 Agent 派发工具调用的方式。 - listitem: - button "查看 Subagent": Subagent - text: 设置 Subagent 的递归层级、数量和模型。 - listitem: - button "查看 网页搜索": 网页搜索 - - text: DeepSeek 搜索提供方。 + - text: 设置 DeepSeek 的搜索提供方。 - heading "已安装" [level=3] - text: "1" - list: diff --git a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md index fbfa9b1cab..f4bdf601f8 100644 --- a/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md +++ b/apps/web/tests/expected/web-runtime-context/file-reference-prompt.expected.md @@ -1 +1 @@ -When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. +Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix. diff --git a/apps/web/tests/message-feedback.e2e.ts b/apps/web/tests/message-feedback.e2e.ts index df89180c18..69676f37d2 100644 --- a/apps/web/tests/message-feedback.e2e.ts +++ b/apps/web/tests/message-feedback.e2e.ts @@ -1,8 +1,6 @@ -// Keyless browser regression for durable per-message feedback. Cold-seeds a -// settled two-turn transcript (zero model calls), records a Like through the -// feedback dialog, replaces it through the same dialog with a Dislike, proves -// the judgment survives a full page reload from the Host's canonical log, then -// retracts it. +// Keyless browser regression for durable message and Session feedback. A +// cold-seeded, settled two-turn transcript avoids model calls while exercising +// message ratings and the Session Header dialog against the Host's canonical log. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' @@ -145,4 +143,40 @@ describe('web e2e: durable per-message feedback', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) + + it.skipIf(MODE === 'record')('opens the shared dialog from the header and records Session feedback only on submit', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-header-feedback')) + await openSeededSession() + await page.getByText('DONE', { exact: true }).waitFor({ timeout: 30_000 }) + const agent = scaffold.ctx.agents.get(SessionId(SEED_ID)) + if (agent === undefined) throw new Error('seeded session did not attach an agent') + const feedbackEvents = () => agent.session.snapshotEvents().filter(event => event.type.startsWith('feedback/')) + const before = feedbackEvents().length + const dialog = page.getByRole('dialog', { name: 'Submit feedback' }) + + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Download session log' }).waitFor() + await page.getByRole('menuitem', { name: 'Feedback', exact: true }).click() + await dialog.waitFor() + expect(await page.getByRole('menu').count()).toBe(0) + expect(feedbackEvents()).toHaveLength(before) + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('discarded draft') + await dialog.getByRole('button', { name: 'Close', exact: true }).click() + expect(feedbackEvents()).toHaveLength(before) + + await page.getByRole('button', { name: 'More actions', exact: true }).click() + await page.getByRole('menuitem', { name: 'Feedback', exact: true }).click() + await dialog.waitFor() + expect(await dialog.getByRole('textbox', { name: 'Feedback details' }).inputValue()).toBe('') + await dialog.getByRole('button', { name: 'Product features and interaction', exact: true }).click() + await dialog.getByRole('textbox', { name: 'Feedback details' }).fill('Feedback from the Session menu.') + await dialog.getByRole('button', { name: 'Submit', exact: true }).click() + await expect.poll(() => dialog.count()).toBe(0) + await page.getByRole('alert').filter({ hasText: 'Thanks for your feedback' }).waitFor() + expect(feedbackEvents().slice(before)).toMatchObject([ + { type: 'feedback/record', data: { category: 'product-interaction', text: 'Feedback from the Session menu.' } }, + ]) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) }) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 6327a0f68f..825d95b081 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -95,7 +95,7 @@ describe('web e2e: plugin configuration pages', () => { expect(await official.getByRole('button', { name: `查看 ${title}`, exact: true }).count()).toBe(1) } // A card carries the one-liner; the fields wait for the page. - expect(await official.getByText('限制 agent 运行的每一条命令。', { exact: true }).count()).toBe(1) + expect(await official.getByText('限制每条命令最多能跑多久、最多输出多少内容。', { exact: true }).count()).toBe(1) expect(await panel.getByLabel('命令超时(毫秒)').count()).toBe(0) const snapshot = await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd) diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 8273a72d02..5c21f9aab8 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -1,4 +1,4 @@ -/** A file request elicits explicit SVG delivery without naming the present tool. */ +/** An explicit file-card request exercises SVG delivery without naming the present tool. */ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' @@ -17,7 +17,7 @@ import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' const DIR = fileURLToPath(new URL('../../../snapshots/web/present-svg', import.meta.url)) const FIXTURE = join(DIR, 'session.v3.jsonl') const MODE = webSnapshotMode() -const PROMPT = '简单画一个 SVG 表示冯诺依曼架构, 保存为 von-neumann.svg' +const RECORD_PROMPT = '简单画一个 SVG 表示冯诺依曼架构,保存为 von-neumann.svg,并提供独立文件卡片,方便打开。' const FILE = 'von-neumann.svg' describe('web e2e: requested SVG is explicitly delivered', () => { @@ -65,11 +65,12 @@ describe('web e2e: requested SVG is explicitly delivered', () => { } }) - it('writes valid SVG and calls present before the final reply', async () => { - if (MODE !== 'record') expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + it('writes valid SVG and provides the requested file card before the final reply', async () => { + const prompts = MODE === 'record' ? [RECORD_PROMPT] : fixtureUserPrompts(await readFile(FIXTURE, 'utf8')) + expect(prompts).toHaveLength(1) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() - await input.fill(PROMPT) + await input.fill(prompts[0]!) await input.press('Enter') const sessionId = await settled const session = scaffold.ctx.agents.get(sessionId)?.session @@ -91,7 +92,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { const events = session.snapshotEvents() const declarations = events.filter(event => event.type === 'deliverables/presented') const delivery = declarations.find(event => event.data.files.some(file => resolve(cwd, file.path) === join(cwd, FILE))) - expect(delivery, 'the file request must produce a successful present declaration').toBeDefined() + expect(delivery, 'explicit file-card delivery requires a successful present declaration').toBeDefined() if (delivery === undefined) throw new Error('SVG was written but not delivered') expect(events.some(event => ( event.type === 'tool/call' && event.data.name === 'present' && event.data.callId === delivery.data.callId diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index fce61a7bb3..b4e78ec07e 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -29,6 +29,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/fresh-round-t const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/ui.expected.md', import.meta.url)) const ECHO_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/submission-echo.expected.md', import.meta.url)) +const BLANK_EXPECTED = join(SNAPSHOT_DIR, 'blank-reload.expected.md') const UI_EXPANDED_EXPECTED = fileURLToPath( new URL('../../../snapshots/web/fresh-round-trip/ui-expanded.expected.md', import.meta.url), ) @@ -80,6 +81,17 @@ describe('web e2e: fresh round trip through the real assembly', () => { // Drift guard: the committed fixture must carry exactly the drive prompt. expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } + const beforeReload = await page.evaluate(() => localStorage.getItem('dsh.sessions.current')) + expect(beforeReload).not.toBeNull() + await page.reload({ waitUntil: 'load' }) + await page.locator('[data-composer-input]').first().waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor({ timeout: 15_000 }) + await page.getByRole('button', { name: 'Select model, current DeepSeek-V4-Flash' }).waitFor({ timeout: 15_000 }) + await expect.poll(() => page.evaluate(() => localStorage.getItem('dsh.sessions.current'))).toBe(beforeReload) + if (MODE !== 'record') { + await compareOrRefreshGolden(BLANK_EXPECTED, + await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd), MODE) + } const input = page.locator('[data-composer-input]').first() await input.waitFor({ timeout: 10_000 }) // Arm the host-side settled barrier BEFORE the send click. @@ -229,6 +241,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ + 'blank-reload.expected.md', 'session.v3.jsonl', 'submission-echo.expected.md', 'system-prompt.expected.md', diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 9e3e33699f..4530b1f040 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -385,6 +385,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = name: `Expand ${ONE_SHOT_LABEL} descendants`, }).count()).toBe(0) const oneShotRow = page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }) + expect(await oneShotRow.locator('[data-state="done"]').count()).toBe(1) expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 12cc32a904..ba97359e7b 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -103,6 +103,7 @@ "tests/clickable-links-gallery.e2e.ts", "tests/queue-actions.e2e.ts", "tests/cold-blank-session.e2e.ts", + "tests/blank-writer-reuse.e2e.ts", "tests/queue-image.e2e.ts", "tests/skill-invocation-policy.e2e.ts", "tests/skill-user-invoke.e2e.ts", diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index ecd7976970..ea95dc73fa 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: b133f9ba94276878b380999b3023210a5d627c6a -tool-catalog.zh.md: 3f63676e4eb379163352a6c6a590f868e53f6efb +tool-catalog.md: e737834ff79c78e514370be6548450401b5bd4ab +tool-catalog.zh.md: f15db56f7edfaa08ffdb794d57de5c4e3179a626 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index b133f9ba94..e737834ff7 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -627,7 +627,7 @@ The bash tool is the model-facing consumer of the bash executor seam. A `run_in_ ### `present` -Declare existing files accessible through the Session filesystem as final deliverables. When a file you create or update is an output the user asked to receive, you must call present after writing it and before your final response, including files created through Bash or code execution. Mentioning its path in your reply does not replace this call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. +Declare selected existing files accessible through the Session filesystem as final deliverables. Use present when the user needs a separate file deliverable, especially Office documents, spreadsheets, and slide decks. Prefer showing results in your final response when that is sufficient; creating or editing a file does not by itself require present. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. The files must already exist. The user opens the current source files; their contents are not copied or preserved. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 3f63676e4e..f15db56f7e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -631,7 +631,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac ### `present` -声明交付 Session 文件系统可访问的已有文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 +选择 Session 文件系统可访问的已有文件,声明为最终交付物。当用户需要独立的文件交付物时使用 present,尤其是 Office 文档、电子表格和演示文稿。如果最终回复已经足够展示结果,优先在回复中展示;创建或编辑文件本身不要求调用 present。通常选择最重要的 1 至 2 项交付物,任务需要时可以更多,但一次 present 调用最多 4 个文件。文件必须已存在。用户打开当前源文件;不复制或保存其内容。 ```json { diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index eb356a2423..273d92d4a4 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 498f3028ff55184a1a43681e78a48c565f69d22c -README.zh.md: d5e6c5d4c624955b59c59f644fa17a921e92c7cc +README.md: fce650e1d12cdeca3ca199fed54fa741177daf75 +README.zh.md: b88bd7a48ec433a9920c129b37d3a132afa8ede7 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 498f3028ff..fce650e1d1 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,6 +33,8 @@ Each endpoint states its activation policy. List reads only stored headers and p Client list rows and resident Sessions use the current `sessionListMetadata` projection to reject stale blank-session hints; recency is the later of the summary timestamp and the projected last user prompt. When SessionManager creates an instance before its list row arrives, it reconciles blankness with metadata already retained for that Session. An opened conversation is therefore not reused by New Session even when an older list response still marks it blank. +Explicit-id `session.create` adopts a live Session or resumes a persisted Session while retaining its writer lock. Writer contention returns `session/writer-held`; callers may try another blank without suppressing unrelated failures. `session.list` includes persisted blanks using cached metadata, without opening cold log bodies. + Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer list, history, or live values. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index d5e6c5d4c6..b88bd7a48e 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,6 +33,8 @@ Client journal 在发布 follow 快照、live entry 或历史页之前验证精 Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠正过期的空白会话提示;最近活动时间取摘要时间戳与投影中最后一次用户提示词时间的较晚值。当 SessionManager 在列表行到达前创建实例时,会使用已保留的该 Session 元数据对账空白状态。因此,即使旧列表响应仍将已有对话标为空白,新会话操作也不会复用已经打开过的对话。 +显式 ID 的 `session.create` 会收养活动 Session,或恢复持久化 Session 并持续持有其写锁。写锁争用返回 `session/writer-held`;调用方可以尝试其他空白会话,同时保留其他失败。`session.list` 根据缓存元数据列出持久化空白会话,不打开冷日志正文。 + Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含本地引用来源的正计数;Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 list、history 或 live 值。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 80008319fe..6003149581 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -520,6 +520,9 @@ export class SessionCommandController { private rejectCreation(sessionId: SessionId, error: unknown): never { if (remoteErrorOf(error) !== undefined) throw error + if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') { + throw new RemoteError('session/writer-held', error.message, { sessionId }) + } if (error instanceof ApiSessionPresetConflict) { throw new RemoteError('agent-preset/conflict', error.message, { sessionId: error.sessionId, diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts index 42bb3fe508..2e3e8cc3b2 100644 --- a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -119,6 +119,10 @@ describe('Session creation failures', () => { error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), code: 'session/conflict', }, + { + error: Object.assign(new Error('writer already held'), { name: 'SessionAlreadyOwnedError' }), + code: 'session/writer-held', + }, { error: new Error('factory unavailable'), code: 'gateway/internal', diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index c6b4e01546..f77acedd8d 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: 310147c8f767b8d68fb149de4c462a0a8154663b -README.zh.md: 0d3e90a87cb99676a51696e753a45d1a35a2e4d1 +README.md: 3d8e52fe6dfc3e517c09f772e63c073957e41cd9 +README.zh.md: 4a547889b9a12af7e65a671bef9176075d55ebc1 diff --git a/packages/client/README.md b/packages/client/README.md index 310147c8f7..3d8e52fe6d 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -76,7 +76,7 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-plugin-manager/`](ui-plugin-manager/README.md) | Contributes the sidebar Plugins panel: install, enable, disable, retry, and compose installed packages | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | | [`ui-deliverables/`](ui-deliverables/README.md) | Produces the changed-files card with its comparison tabs, delivery cards, and clickable final-response file references | — | -| [`ui-message-feedback/`](ui-message-feedback/README.md) | The feedback surface: per-message Like/Dislike in the assistant-message action strip, and the feedback dialog behind both ratings and `/feedback` | — | +| [`ui-message-feedback/`](ui-message-feedback/README.md) | Provides message ratings and feedback dialogs opened from ratings, `/feedback`, or the Session Header menu | `ctx.feedbackUi` | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the local Desktop or Host OS chooser | — | | [`ui-open-in-app/`](ui-open-in-app/README.md) | Session-header split button opening the workspace directory in an installed application, and the document preview's default-application controls for one file | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 0d3e90a87c..4a547889b9 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -76,7 +76,7 @@ kind: "package-group" | [`ui-plugin-manager/`](ui-plugin-manager/README.zh.md) | 贡献侧栏的“插件”面板:安装、启用、停用、重试与组合已安装的包 | — | | [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向「插件」设置贡献只读的 Host Loader 清单标签页 | — | | [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成改动文件卡片及其对比 tab、交付文件卡片与可点击的最终响应文件引用 | — | -| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 反馈界面:助手消息操作条中的逐消息赞踩,以及点赞、点踩与 `/feedback` 背后的反馈弹窗 | — | +| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 提供消息赞踩,以及从赞踩、`/feedback` 或会话标题栏菜单打开的反馈弹窗 | `ctx.feedbackUi` | | [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | | [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动本地 Desktop 或 Host OS 选择器的原生目录选择界面 | — | | [`ui-open-in-app/`](ui-open-in-app/README.zh.md) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮,以及文档预览里用默认应用打开单个文件的控件 | — | diff --git a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx index 564f5a6a41..704b53396f 100644 --- a/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx +++ b/packages/client/ui-chat/tests/assistant-markdown-path-images.client.spec.tsx @@ -25,6 +25,7 @@ describe('localPathMediaUrl', () => { }) it('keeps non-HTTP transports inert', () => { + expect(localPathMediaUrl('dsh-app:', 'dsh-app://app', '/tmp/graph.png')).toBeUndefined() expect(localPathMediaUrl('file:', 'file:///app', '/tmp/graph.png')).toBeUndefined() expect(localPathMediaUrl('ws:', ORIGIN, '/tmp/graph.png')).toBeUndefined() }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 457551a057..7ea790985c 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: a5d333353bd9c342be0549c70773ed38123e0ba8 -README.zh.md: 9704b73e5377491d6379bf8cf3424b4f30b2fda0 +README.md: 71f3545b8fb3fed3ecd678258e6c50bbfa4b580c +README.zh.md: 3e59ed2b7d4ac7e9d64b7d1f2f3c477e3ca1c50e diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index a5d333353b..71f3545b8f 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -38,11 +38,11 @@ The `present` tool row retains the delivery-document glyph while showing running Developer tools must be enabled to show this card or request its summary. Turning the preference off removes the card immediately while explicit delivery cards, inline file links, and recorded workspace changes remain available. -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count with the summed added and deleted lines, and each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Three rows show before a fold; a control below reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. The first file section starts 20px below the closing prose, a following explicit-delivery section starts 16px below the card, and the action footer starts 20px below the last file section. Final file deliveries still require `present`. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. ### The review tab -A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison; the selected file's counts follow it. The header's tools switch between the unified view and a side-by-side view that pairs each run of deletions with the additions that follow it, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, additions and deletions in the success and error colours, and a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. +A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison. A new tab starts in the side-by-side view without wrapping. Its controls switch between unified and side-by-side comparison, toggle line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, and highlights each rendered hunk side independently with the code preview's filename grammar and Shiki token colours. Additions and deletions use aligned success or error markers and background tints. The tab also shows a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. @@ -87,11 +87,11 @@ Read these pages when the deliverables surface is not enough. They move from the #### What the model sees -The guidance asks the model to name primary outputs after successful creation or modification and link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. +The guidance prefers primary results in the final reply: file links open Sidebar previews, and image previews include a file link for clients without inline-image support. It recommends separate `present` cards for complete file deliverables, especially Office files, usually selecting the most important one or two, with at most four files per call when more are needed. It discourages cards solely for code edits and extra commands to inspect diff visibility. This guidance applies even with developer tools disabled: it accepts fewer file surfaces to avoid duplicate presentation and unnecessary tool calls. It asks the model to link every existing-file mention outside commands, configuration expressions, and code blocks, including repeats and tables. Labels default to filenames or clear aliases, with only enough parent directories to distinguish files. Precise references display `filename:24` or `filename:24–30`; their destinations retain full relative or absolute paths with `#L24` or `#L24-L30` anchors. The display suffix contains neither `#` nor `L`. #### Token effect -One fixed paragraph containing an output reminder and file-reference guidance whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text. +One fixed paragraph containing output-selection, rendering, and file-reference guidance whenever this package is loaded. The [present tool](../../deliverables/tool-present/README.md#model-experience) owns the delivery schema and result text. #### KV Cache effect @@ -104,11 +104,12 @@ The section is static at first-party order 9000 for the lifetime of the package These limits define the current deliverables vocabulary. They are current package constraints, not a general file-linking comparison or a task backlog. +- **Inline local images require HTTP(S) and POSIX absolute paths**: Desktop’s `dsh-app:` pages and Windows drive-letter paths do not support inline local images. The guidance retains a Markdown file link so users can open the Sidebar preview. - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Terminal-created files require explicit delivery** — the card lists them once git records the change, but delivery cards and inline-code references require `present`; explicit Markdown links can reference existing files directly. - **Declarations do not preserve file contents** — reopening or transferring a Session requires source files accessible through the viewed Session’s filesystem. Missing files, directories, and final symbolic links return 404. - **Native opening needs a Host desktop** — without one the review tab offers no native open; the comparison itself needs only the Host that recorded the turn. -- **Comparisons are unhighlighted** — the tab shows plain hunks without syntax colouring, and draws at most 5,000 lines before saying so. +- **Comparison highlighting is hunk-local** — each rendered old and new hunk side is tokenized independently because omitted source lines are unavailable; unknown suffixes remain plain, and the tab draws at most 5,000 lines before saying so. - **Comparisons carry whole file text** — the comparison route serves a listed file's complete text wherever the Host recorded it, including ignored files and files outside the workspace root the Sidebar previews are confined to. - **Package-local header glyph** — the card's angle-bracket mark lives in `src/client/icons.tsx` until the shared icon set carries it; its props already match the shared icon contract. - **Files outside the workspace open by absolute path only** — the recorded path is the Host path at recording time; a moved workspace or a different viewing Session cannot relocate it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 9704b73e53..3e59ed2b7d 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -38,11 +38,11 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 只有开启开发者工具才展示此卡片并请求其摘要。关闭该偏好会立即隐藏卡片,同时保留显式交付卡片、行内文件链接和已记录的工作区改动。 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计,每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示三行;下方的控件展开全部记录文件,展开后同一位置的控件从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。首个文件区块位于收尾正文下方 20px,后续显式交付区块位于卡片下方 16px,操作页脚位于最后一个文件区块下方 20px。最终文件交付仍需调用 `present`。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 ### review tab -行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比;所选文件的行数跟在后面。头部的工具在单栏视图和左右视图之间切换,后者把每一段删除与紧随其后的新增逐行配对;切换自动换行;在 Sidebar 中打开当前整个文件;Host 有桌面时用默认应用打开它,等待与可重试错误状态与卡片一致。视图与换行的选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,新增与删除分别用成功色和错误色,并在文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 +行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比。新 tab 默认使用不换行的左右对比。其控件用于切换单栏或左右对比、切换自动换行、在 Sidebar 中打开当前完整文件,并在 Host 有桌面时用默认应用打开文件;原生打开的等待与可重试错误状态与卡片一致,视图和换行选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,并按代码预览的文件名 grammar 与 Shiki token 色分别高亮每个已渲染 hunk 的两侧。新增与删除使用对齐的成功色或错误色标记和背景色。文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时,tab 还会给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 @@ -87,11 +87,11 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl #### 模型看到的内容 -提示词要求模型在成功创建或修改文件后点名主要产出,并链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 +提示词建议在最终回复中展示主要结果:文件链接打开 Sidebar 预览,图片预览同时保留文件链接,供不支持行内图片的客户端使用。对于需要完整文件的交付,尤其是 Office 文件,建议使用独立的 `present` 卡片,通常选最重要的一两项,需要时可以更多,但每次调用最多四项。提示词建议避免仅为列出代码改动生成卡片,也不为确认 diff 是否显示额外运行命令。开发者工具关闭时仍采用这条建议,接受较少的文件展示入口,以减少重复展示和不必要的工具调用。文件引用要求链接命令、配置表达式和代码块以外的每次现有文件提及,包括重复提及和表格。标签默认使用文件名或清楚的别名,仅添加足以区分文件的父目录。精确引用显示为 `filename:24` 或 `filename:24–30`;目标保留完整相对路径或绝对路径,以及 `#L24` 或 `#L24-L30` 锚点。显示后缀不含 `#` 或 `L`。 #### Token 影响 -加载本包时增加一段包含产出提醒和文件引用指导的固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 +加载本包时增加一段包含产出选择、渲染效果和文件引用指导的固定提示词。[present 工具](../../deliverables/tool-present/README.zh.md#model-experience)拥有交付 schema 和结果文本。 #### KV Cache 影响 @@ -104,11 +104,12 @@ Node 半部注册[模型体验](#model-experience)所述的静态 `ui:deliverabl 这些限制界定了当前产出物词表。它们是当前包约束,不是通用文件链接对比或任务积压。 +- **本地行内图片要求 HTTP(S) 页面和 POSIX 绝对路径**:Desktop 的 `dsh-app:` 页面和 Windows 盘符路径不支持本地行内图片。提示词要求保留 Markdown 文件链接,让用户仍可打开 Sidebar 预览。 - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端创建的文件需要显式交付**——git 记录到改动后卡片会列出它们,但交付卡片和行内代码引用需要调用 `present`;显式 Markdown 链接可以直接引用现有文件。 - **声明不保存文件内容**:重新打开或转移 Session 后,源文件仍需能被当前查看的 Session 文件系统访问。文件缺失、为目录或最终路径为符号链接时返回 404。 - **原生打开需要 Host 桌面**——没有桌面时 review tab 不提供原生打开;对比本身只需要记录了该轮的 Host。 -- **对比没有语法高亮**——tab 只显示纯文本 hunk,最多绘制 5000 行并给出提示。 +- **对比高亮使用 hunk 内上下文**——省略的源码行不可用,因此每个已渲染 hunk 的新旧两侧分别分词;未知后缀保持纯文本,tab 最多绘制 5000 行并给出提示。 - **对比携带整个文件的文本**——对比路由会送出所列文件在 Host 上记录时的完整文本,包括被忽略的文件和 Sidebar 预览所限定的工作区根目录之外的文件。 - **包内的标题字形**——卡片的尖括号标记放在 `src/client/icons.tsx` 中,直到共享图标集收录它;其 props 已与共享图标契约一致。 - **工作区外的文件只按绝对路径打开**——记录的路径是记录时的 Host 路径;工作区移动或换一个查看 Session 都无法重新定位它。 diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 4ce481e979..536bed4f6d 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -1,26 +1,31 @@ /** Changed-files card: filled header, code-font rows with line counts, and a bottom fold control. */ -.card { --changes-fill: var(--dsw-static-neutral-50); --changes-hover: var(--dsw-static-neutral-100); display: flex; flex-direction: column; min-width: 0; margin-top: 4px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 16px; color: var(--dsw-alias-label-primary); } +.card { --changes-fill: var(--dsw-static-neutral-50); --changes-hover: var(--dsw-static-neutral-100); display: flex; flex-direction: column; min-width: 0; margin-top: 4px; overflow: hidden; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 18px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-primary); } :global(body[data-ds-dark-theme]) .card { --changes-fill: var(--dsw-static-neutral-850); --changes-hover: var(--dsw-static-neutral-800); } -.header { display: flex; align-items: center; gap: 12px; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 12px 16px; border: 0; background: var(--changes-fill); color: inherit; font: inherit; text-align: left; } +.header { display: flex; align-items: center; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; height: 60px; margin: 0; padding: 8px 10px; border: 0; background: var(--changes-fill); color: inherit; font: inherit; text-align: left; } button.header { cursor: pointer; transition: background-color 120ms ease; } button.header:hover:not(:disabled), button.header:focus-visible { background: var(--changes-hover); } button.header:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); } button.header:disabled { cursor: progress; } -.tile { display: grid; flex: none; place-items: center; width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-link); color: var(--dsw-static-neutral-00); } -.titles { display: flex; flex: 1; flex-direction: column; gap: 2px; min-width: 0; } -.title { overflow: hidden; font-size: 14px; font-weight: 500; line-height: 22px; text-overflow: ellipsis; white-space: nowrap; } -.stat { display: inline-flex; gap: 6px; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; color: var(--dsw-alias-label-tertiary); } +.tile { display: grid; flex: none; place-items: center; box-sizing: border-box; width: 40px; height: 40px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 10px; background: var(--changes-fill); } +.tileMark { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 8px; background: var(--dsw-alias-link); color: var(--dsw-static-neutral-00); } +.titles { display: flex; flex: 1; flex-direction: column; min-width: 0; } +.title { overflow: hidden; font-size: 13px; font-weight: 500; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } +.stat { display: inline-flex; font-size: 10px; line-height: 16px; color: var(--dsw-alias-label-tertiary); } +.statCounts { display: inline-flex; gap: 6px; font-family: var(--ds-font-family-code); } +.previewHint { display: none; } +.header:hover .statCounts, .header:focus-visible .statCounts { display: none; } +.header:hover .previewHint, .header:focus-visible .previewHint { display: inline; } .stat[data-error='true'], .counts[data-error='true'] { color: var(--dsw-alias-state-error-primary); } .added { color: var(--dsw-alias-state-success-primary); } .deleted { color: var(--dsw-alias-state-error-primary); } -.list { margin: 0; padding: 4px 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l1); } -.row { display: flex; align-items: center; justify-content: space-between; gap: 12px; box-sizing: border-box; width: 100%; min-width: 0; margin: 0; padding: 6px 16px; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; text-align: left; } +.list { margin: 0; padding: 0; list-style: none; border-top: 0.5px solid var(--dsw-alias-border-l2); } +.row { display: flex; align-items: center; justify-content: space-between; gap: 10px; box-sizing: border-box; width: 100%; min-width: 0; min-height: 24px; margin: 0; padding: 7px 18px 7px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font-family: var(--ds-font-family-code); font-size: 11px; line-height: 18px; text-align: left; } .row:hover:not(:disabled), .row:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } .row:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--dsw-alias-border-l3); } .row:disabled { cursor: progress; } -.path { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.path { min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .counts { display: inline-flex; flex: none; gap: 6px; white-space: nowrap; color: var(--dsw-alias-label-tertiary); } -.toggle { display: inline-flex; align-items: center; gap: 4px; box-sizing: border-box; width: 100%; margin: 0; padding: 8px 16px; border: 0; border-top: 0.5px solid var(--dsw-alias-border-l1); background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; text-align: left; } +.toggle { display: inline-flex; align-items: center; justify-content: flex-start; gap: 4px; box-sizing: border-box; width: 100%; margin: 0; padding: 10px 18px 10px 14px; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; text-align: left; } .toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } .toggle svg { flex: none; width: 14px; height: 14px; } @media (pointer: coarse) { .row, .toggle { min-height: 44px; } } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 6fd4e72ace..2db82abd1f 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,4 +1,4 @@ -/** The changed-files card: a header and per-file rows that open the turn's review, and a three-row fold. */ +/** The changed-files card: a header and per-file rows that open the turn's review, and a four-row fold. */ import { useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' @@ -9,7 +9,7 @@ import type { NS } from './locales.ts' import css from './ChangedFiles.module.css' /** Rows shown before the fold; the design's summary height for a closing message. */ -const COLLAPSED_ROWS = 3 +const COLLAPSED_ROWS = 4 const GROUPED = new Intl.NumberFormat('en-US') @@ -39,10 +39,15 @@ export function ChangedFiles({ changes, cwd, openReview, t }: { const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files return
    diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index aef4abf94f..8d9fd9ede4 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,14 +1,16 @@ /** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll together on both axes; wrapped ones share rows so the sides stay aligned. */ -.root { display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } +.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } +:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .selector { flex: 0 1 auto; min-width: 0; } .selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } -.selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; } +.selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; } .selectorButton:hover, .selectorButton[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } -.selectorText { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.selectorButton svg { display: block; flex: none; } +.selectorText { min-width: 0; overflow: hidden; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } .item { display: flex; gap: 12px; align-items: center; justify-content: space-between; min-width: 0; } -.itemPath { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.itemCounts, .counts { display: inline-flex; flex: none; gap: 6px; font-family: var(--ds-font-family-code); font-size: 12px; color: var(--dsw-alias-label-tertiary); } +.itemPath { min-width: 0; overflow: hidden; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; } +.itemCounts, .counts { display: inline-flex; flex: none; gap: 6px; align-items: center; font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; color: var(--dsw-alias-label-tertiary); } .counts { min-width: 0; margin-right: auto; } .added { color: var(--dsw-alias-state-success-primary); } .deleted { color: var(--dsw-alias-state-error-primary); } @@ -16,7 +18,8 @@ .tools { display: inline-flex; flex: none; gap: 2px; align-items: center; margin-left: auto; } .tool { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 6px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; } .tool svg { width: 15px; height: 15px; } -.tool:hover:not(:disabled), .tool[aria-pressed='true'] { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +.tool:hover:not(:disabled) { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +.tool[aria-pressed='true'] .compareIcon { transform: rotate(90deg); } .tool:disabled { cursor: progress; } .tool[data-error] { color: var(--dsw-alias-state-error-primary); } .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } @@ -28,18 +31,18 @@ .note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } .hunk { margin-bottom: 8px; } .hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; } -.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; } +.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); box-sizing: border-box; min-height: 22px; line-height: 22px; white-space: pre; } .splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; } -.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); min-width: 0; } +.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); box-sizing: border-box; min-width: 0; } .cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); } .number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; } .sign { text-align: center; user-select: none; } .text { padding-right: 16px; } .body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } .body[data-review-wrap] .text { overflow-wrap: anywhere; } -.add { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); } +.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } .add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } -.del { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } +.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } .del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } .context .text { color: var(--dsw-alias-label-secondary); } -.empty { background: var(--dsw-alias-interactive-bg-hover); } +.empty { background: var(--diff-empty-fill); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index cebabaccd3..01ac90f1fa 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -6,9 +6,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, UIEvent } from 'react' import { - Button, IconChevronDownOutlineRegular, IconCodeOutlineRegular, IconPanelLeftOutlineRegular, - IconRightUpOutlineRegular, IconWrapLinesOutlineRegular, Menu, Tooltip, + Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, + IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, Tooltip, + languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' +import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' @@ -59,6 +61,36 @@ export interface SplitRow { right?: { no: number; text: string; kind: 'add' | 'context' } } +interface HunkHighlights { + old: ReadonlyMap | undefined + new: ReadonlyMap | undefined +} + +function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] { + const source = rows.flatMap((row) => { + const no = row[side] + return no === undefined ? [] : [{ no, text: row.text }] + }) + if (source.length === 0) return new Map() + const highlighted = highlighter(source.map(line => line.text).join('\n')) + if (highlighted === undefined) return undefined + return new Map(source.map((line, index) => { + // Shiki omits one terminal empty token row; retain an aligned empty run for that source line. + return [line.no, highlighted[index] ?? []] + })) +} + +function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights { + const rows = hunkRows(hunk) + return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) } +} + +function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode { + return + {spans === undefined ? text : spans.map((span, index) => {span.text})} + +} + /** * Number a hunk's lines: context lines count on both sides, deletions on the * old side, additions on the new side. @@ -220,15 +252,19 @@ export function ReviewTab({ + onClick={() => { actions.toggledSplit(tab.id) }}> + + + onClick={() => { actions.toggledWrap(tab.id) }}> + {wrap ? : } + {file !== undefined && + onClick={() => { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}> } {file !== undefined && native &&
) } diff --git a/packages/client/ui-deliverables/src/client/review-store.ts b/packages/client/ui-deliverables/src/client/review-store.ts index b4edbc4f4f..5c7c202c86 100644 --- a/packages/client/ui-deliverables/src/client/review-store.ts +++ b/packages/client/ui-deliverables/src/client/review-store.ts @@ -11,7 +11,7 @@ import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit' export interface ReviewTabState { /** Original index of the shown file in the summary's files array. */ index: number - /** Whether deletions and additions are drawn in two columns. */ + /** Whether deletions and additions are drawn in two columns; new tabs start enabled. */ split: boolean /** Whether long lines wrap instead of scrolling. */ wrap: boolean @@ -49,7 +49,7 @@ export function createReviewStore(): EngineStoreHandle ({ byTab: {} }), actions: { /** - * Apply a navigation: seed the tab on its first one, then show the navigated file. + * Apply a navigation: seed a side-by-side, unwrapped tab on its first one, then show the navigated file. * @param d - draft state. * @param tabId - the tab being drawn. * @param revision - the navigation revision being applied. @@ -57,7 +57,7 @@ export function createReviewStore(): EngineStoreHandle { const tab = d.byTab[tabId] - if (tab === undefined) d.byTab[tabId] = { index, split: false, wrap: false, navigated: revision } + if (tab === undefined) d.byTab[tabId] = { index, split: true, wrap: false, navigated: revision } else { tab.index = index; tab.navigated = revision } }, /** diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts index d6fd183ac5..33ede599ca 100644 --- a/packages/client/ui-deliverables/src/index.ts +++ b/packages/client/ui-deliverables/src/index.ts @@ -14,7 +14,11 @@ import { registerPresentOpen } from './present-open.ts' export const inject = ['systemPrompt', 'connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'] /** Static Web guidance for primary outputs and existing-file references. */ -const FILE_REFERENCE_PROMPT = 'When you successfully create or modify files, mention the primary outputs in your final response. ' +const FILE_REFERENCE_PROMPT = 'Prefer showing the primary results within your final response alongside a brief explanation. ' + + 'Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. ' + + 'Do not call present just to list edited source files, or run commands to check whether a diff view will appear. ' + + 'Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. ' + + 'Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. ' + 'Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. ' + 'Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix.' diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 74f0b871d8..75d1004233 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -582,17 +582,21 @@ describe('ChangedFiles card', () => { expect(summaries.state.getSnapshot()[changesSummaryUrl(SessionId('child-session'), 5)]).toBe('loading') }) - it('summarizes the turn, folds after three rows, and opens the review from the header and each row', () => { + it('summarizes the turn, folds after four rows, and opens the review from the header and each row', () => { const { props, openFile, view } = renderCard() const card = view.container.querySelector('[data-changed-files]') if (!(card instanceof HTMLElement)) throw new Error('changed-files card missing') expect(within(card).getByText('Edited 11 files')).toBeTruthy() expect(within(card).getByText('+1,232')).toBeTruthy() expect(within(card).getByText('-326')).toBeTruthy() - expect(within(card).getAllByRole('listitem')).toHaveLength(3) + expect(within(card).getByText('Preview in sidebar')).toBeTruthy() + expect(within(card).getAllByRole('listitem')).toHaveLength(4) expect(within(card).getByText('config/design-token')).toBeTruthy() expect(within(card).getByText('+42')).toBeTruthy() - expect(within(card).queryByText('src/index.ts')).toBeNull() + expect(within(card).getByText('src/index.ts')).toBeTruthy() + expect(within(card).queryByText('~/.zshrc')).toBeNull() + expect(within(card).getByRole('button', { name: 'Review this turn’s changes in the sidebar' }) + .querySelector('svg')?.getAttribute('width')).toBe('10') fireEvent.click(within(card).getByRole('button', { name: 'View changes to config/feature-flags.json' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 1) expect(props.openChanged).not.toHaveBeenCalled() @@ -612,7 +616,7 @@ describe('ChangedFiles card', () => { expect(collapse.getAttribute('aria-expanded')).toBe('true') expect(card.lastElementChild).toBe(collapse) fireEvent.click(collapse) - expect(within(card).getAllByRole('listitem')).toHaveLength(3) + expect(within(card).getAllByRole('listitem')).toHaveLength(4) }) it('opens the review the same way without a desktop', () => { diff --git a/packages/client/ui-deliverables/tests/prompt.host.spec.ts b/packages/client/ui-deliverables/tests/prompt.host.spec.ts index 0b7e989104..9758dba3c5 100644 --- a/packages/client/ui-deliverables/tests/prompt.host.spec.ts +++ b/packages/client/ui-deliverables/tests/prompt.host.spec.ts @@ -29,9 +29,10 @@ describe('ui-deliverables node plugin', () => { const section = (await ctx.systemPrompt.assemble()).sections .find(entry => entry.name === 'ui:deliverable-file-references') - expect(section?.text).toMatchInlineSnapshot('"When you successfully create or modify files, mention the primary outputs in your final response. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') + expect(section?.text).toMatchInlineSnapshot('"Prefer showing the primary results within your final response alongside a brief explanation. Markdown file links such as [Report](path/to/report.html) open the file in the sidebar preview. For images, you can add an inline preview such as ![Preview](/absolute/path/image.png); include a file link as well so the image remains accessible in clients that cannot display it inline. Do not call present just to list edited source files, or run commands to check whether a diff view will appear. Use present when a separate file card helps the user open the complete deliverable, especially Office documents, spreadsheets, and slide decks. Each presented file adds a card below the reply, with preview and native-open actions. Usually select the 1-2 most important deliverables; include more when needed, but at most 4 files in a single present call. Avoid repeating results already shown inline unless the separate card adds useful access. Outside commands, configuration expressions, and code blocks, link every mention of an existing file, including repeats and tables, to its full path relative to the working directory or absolute; append #L24 or #L24-L30 to the target for known lines. Use the filename or a clear alias as the label, adding only enough parent directories to distinguish files; keep full paths out of labels. Default to the name alone; when precise locations matter, append :24 or :24–30, with no # or L in the line suffix."') - for (const scenario of ['cordis-tool-round', 'fresh-round-trip', 'ptc-round', 'schedule-catalog']) { + // Only live request pins track current guidance; seeded sessions retain their recorded prompt. + for (const scenario of ['fresh-round-trip', 'ptc-round']) { const sidecar = await readFile(new URL(`../../../../snapshots/web/${scenario}/system-prompt.expected.md`, import.meta.url), 'utf8') expect(sidecar, scenario).toContain(section!.text) } diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 5ae0970da4..749f19d515 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -84,12 +84,12 @@ describe('review store', () => { it('seeds a tab on its first navigation, applies later ones, toggles views, and forgets', () => { const store = createReviewStore().create() store.actions.navigated(TAB, 1, 2) - expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 2, split: false, wrap: false, navigated: 1 }) + expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 2, split: true, wrap: false, navigated: 1 }) store.actions.toggledSplit(TAB) store.actions.toggledWrap(TAB) store.actions.selected(TAB, 0) store.actions.navigated(TAB, 2, 1) - expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 1, split: true, wrap: true, navigated: 2 }) + expect(store.getSnapshot().byTab[TAB]).toEqual({ index: 1, split: false, wrap: true, navigated: 2 }) store.actions.forget(TAB) expect(store.getSnapshot().byTab[TAB]).toBeUndefined() expect(() => { store.actions.selected(TAB, 0) }).toThrow('no review state') @@ -214,6 +214,8 @@ describe('ReviewTab', () => { expect(view.getByRole('button', { name: en['review.selectFile'] }).getAttribute('data-review-file')).toBe('src/app/main.ts') expect(injected.loadChangesDiff).toHaveBeenLastCalledWith('viewed', 5, 0) act(() => { diffs.state.set({ [changesDiffUrl(SESSION, 5, 0)]: text }) }) + expect(view.container.querySelector('[data-review-view]')?.getAttribute('data-review-view')).toBe('split') + fireEvent.click(view.getByRole('button', { name: en['review.splitAria'] })) const lines = [...view.container.querySelectorAll('[data-diff-line]')] expect(lines.map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'add', 'context', 'del', 'add', 'del']) expect(lines[1]?.textContent).toBe('2-b') @@ -221,14 +223,13 @@ describe('ReviewTab', () => { expect(view.container.querySelector('[data-review-view]')?.getAttribute('data-review-view')).toBe('unified') }) - it('draws the split view and wraps lines on request, and keeps both choices in the tab store', () => { + it('draws the split view by default and wraps lines on request, and keeps both choices in the tab store', () => { const summaries = new ChangesSummaryStore() summaries.state.set({ [SUMMARY_URL]: summary }) const diffs = new ChangesDiffStore() diffs.state.set({ [changesDiffUrl(SESSION, 5, 0)]: text }) const { view, store } = mount({ summaries, diffs }) expect(store.getSnapshot().byTab[TAB]?.index).toBe(0) - fireEvent.click(view.getByRole('button', { name: en['review.splitAria'] })) expect(store.getSnapshot().byTab[TAB]?.split).toBe(true) const body = view.container.querySelector('[data-review-view]') expect(body?.getAttribute('data-review-view')).toBe('split') @@ -269,6 +270,39 @@ describe('ReviewTab', () => { expect(store.getSnapshot().byTab[TAB]).toMatchObject({ split: true, wrap: true }) }) + it('syntax-highlights recognized source files with the shared code grammar', () => { + const summaries = new ChangesSummaryStore() + summaries.state.set({ [SUMMARY_URL]: summary }) + const diffs = new ChangesDiffStore() + diffs.state.set({ + [changesDiffUrl(SESSION, 5, 0)]: { + ...text, + hunks: [{ oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, lines: ['-const before = 1', '-', '+const after = 2', '+'] }], + }, + }) + const { view } = mount({ summaries, diffs }) + const highlighted = [...view.container.querySelectorAll('[data-diff-code]')] + expect(highlighted.map(line => line.textContent)).toEqual(['const before = 1', '', 'const after = 2', '']) + expect(view.container.querySelectorAll('[data-diff-code] span[style]').length).toBeGreaterThan(2) + }) + + it('keeps unknown source files as plain text', () => { + const summaries = new ChangesSummaryStore() + summaries.state.set({ [SUMMARY_URL]: { ...summary, files: [{ ...summary.files[0]!, path: 'notes.unknown' }] } }) + const diffs = new ChangesDiffStore() + diffs.state.set({ + [changesDiffUrl(SESSION, 5, 0)]: { + ...text, + path: 'notes.unknown', + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-before', '+after'] }], + }, + }) + const { view } = mount({ summaries, diffs }) + expect(view.container.querySelector('[data-diff-code]')).toBeNull() + expect(view.getByText('before')).toBeTruthy() + expect(view.getByText('after')).toBeTruthy() + }) + it('opens the whole file in the sidebar and the native open only with a desktop', () => { const summaries = new ChangesSummaryStore() summaries.state.set({ [SUMMARY_URL]: summary }) @@ -276,6 +310,7 @@ describe('ReviewTab', () => { controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) const { view, injected, tabActions } = mount({ summaries, controller, params: { index: 1 } }) expect(injected.reloadPresentedHost).not.toHaveBeenCalled() + expect(view.container.querySelector('[data-review-tool="open-file"] svg')?.getAttribute('width')).toBe('12') fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in sidebar' })) expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin')) fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' })) @@ -326,7 +361,7 @@ describe('ReviewTab', () => { act(() => { diffs.state.set({ [url]: { ...text, hunks: [{ oldStart: 1, oldLines: 0, newStart: 1, newLines: long.length, lines: long }] } }) }) - expect(view.container.querySelectorAll('[data-diff-line]')).toHaveLength(MAX_RENDERED_LINES) + expect(view.container.querySelectorAll('[data-diff-side="right"] [data-diff-line]')).toHaveLength(MAX_RENDERED_LINES) expect(view.container.querySelector('[data-diff-truncated]')?.textContent).toBe(`只显示前 ${MAX_RENDERED_LINES} 行`) }) diff --git a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts index 8c718b5b53..6bd7af1bc5 100644 --- a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts @@ -21,4 +21,5 @@ describe('deliverables layout', () => { expect(deliveries).toMatch(/\.open\s*\{[^}]*padding:\s*4px 8px;[^}]*font-size:\s*12px;[^}]*line-height:\s*18px/s) expect(deliveries).toMatch(/\.presented\s*\{[^}]*gap:\s*10px/s) }) + }) diff --git a/packages/client/ui-message-feedback/README.i18n.yaml b/packages/client/ui-message-feedback/README.i18n.yaml index bdd5f1e664..1c4d3e76a5 100644 --- a/packages/client/ui-message-feedback/README.i18n.yaml +++ b/packages/client/ui-message-feedback/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-message-feedback/README.md -README.md: b37278e8d115a2ad61bb17ef8d884b0d888cd6a7 -README.zh.md: cd233061640f5e52ea9ca54ffe973bff3b957197 +README.md: b6c6f227ebbeb3d6862d10658b1b125517b25581 +README.zh.md: a294c73c87e8e2da8139fe965e70d9eaf34096a6 diff --git a/packages/client/ui-message-feedback/README.md b/packages/client/ui-message-feedback/README.md index b37278e8d1..b6c6f227eb 100644 --- a/packages/client/ui-message-feedback/README.md +++ b/packages/client/ui-message-feedback/README.md @@ -25,7 +25,7 @@ This package is the Web GUI's feedback surface: the Like/Dislike pair in the fin ## Use this package -Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and the Feedback row of the composer menu opens the dialog. A recorded rating shows the filled glyph and stays visible without hover. Like and Dislike both open the dialog: seven category chips and a detail box are optional, and Submit records the selected judgment with whatever was filled in before the toast thanks the user; the conversation log travels with every feedback event. Clicking the recorded rating retracts it without opening the dialog. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback ` keeps the Host command path and its acknowledgement row. +Mount this plugin alongside `ui-conversation` and `ui-commands`; the Like/Dislike pair then appears in the action row of each turn's closing assistant message, between copy and branch, and Feedback in the composer menu opens the same dialog. Mounting `session-log-export` also adds Feedback to the Session Header's more-actions menu. A recorded rating shows the filled glyph and stays visible without hover. Like and Dislike both open the dialog: seven category chips and a detail box are optional, and Submit records the selected judgment with whatever was filled in before the toast thanks the user; the conversation log travels with every feedback event. Clicking the recorded rating retracts it without opening the dialog. A bare `/feedback`, picked from the menu or typed and sent without text, opens the same dialog for the Session; `/feedback ` keeps the Host command path and its acknowledgement row. ### Failures @@ -39,7 +39,7 @@ A rating or list-load failure shows inline in the row; a submission failure show
Implementation internals — click to expand -The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row, and the `feedback-dialog` entry (order 2) of `conversation.input.overlay`, which renders the Modal and Toast primitives through body portals and centers the toast over the composer card it mounts inside. The `/feedback` decoration is an `action` registered through `ctx.commandUi.decorate`, so a menu pick or a bare Enter consumes the trigger token and opens the dialog while an argued line still reaches the Host command. +The package contributes the `feedback` entry (order 10) of `conversation.chat.assistant-actions`, declared by ui-conversation and rendered inside the finalized assistant message's IconActions row, and the `feedback-dialog` entry (order 2) of `conversation.input.overlay`, which renders the Modal and Toast primitives through body portals and centers the toast over the composer card it mounts inside. The `feedbackUi.openSession(sessionId)` Client service opens that same Session-scoped dialog without recording feedback; the Header menu and command decoration both use it. The `/feedback` decoration is an `action` registered through `ctx.commandUi.decorate`, so a menu pick or a bare Enter consumes the trigger token and opens the dialog while an argued line still reaches the Host command. Per Session, one `MessageFeedbackController` backs every message control and one `FeedbackDialogController` owns the dialog draft, the submission, and the toast sequence. The message controller reads `messageFeedback.list` once, deferred to the first hover or focus rather than fired on mount, and serializes mutations so each carries the version last observed; a `version-conflict` reply carries the authoritative item and reconciles the view without refetching. Before either rating action proceeds, the row checks the committed item: the matching rating calls `retract`, which rechecks the rating in the serialized queue and becomes a no-op after a concurrent change, while any other state opens the dialog with the requested rating. The dialog controller submits by target: a message target puts that rating with the dialog's note and category through the message controller, and the Session target records through `ctx.remote.sessionFeedback`. Success closes the draft and raises the acknowledgement toast; a late success from a superseded draft raises that toast without closing the new draft; a failure keeps the draft open and raises a longer-lived warning toast. @@ -90,4 +90,4 @@ None.
-**Runtime invariant:** No companion is published. The plugin owns two slot registrations, one command decoration, and one per-session controller-pair map, all released by the plugin fiber's effect disposers. The lifecycle spec proves the registrations are withdrawn and every controller pair is dropped when the owning fiber is disposed, so no second authority exists to check at runtime. +**Runtime invariant:** No companion is published. The plugin owns two slot registrations, one command decoration, the `feedbackUi` service, and one per-session controller-pair map, all released with the owning plugin fiber. The lifecycle spec verifies that the registrations and service are withdrawn and every controller pair is dropped when the fiber is disposed, so no second authority exists to check at runtime. diff --git a/packages/client/ui-message-feedback/README.zh.md b/packages/client/ui-message-feedback/README.zh.md index cd23306164..a294c73c87 100644 --- a/packages/client/ui-message-feedback/README.zh.md +++ b/packages/client/ui-message-feedback/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞和点踩都会打开弹窗:七个分类标签和一个详情框都可不填;提交后才会记录带所填内容的对应评分并弹出感谢 toast,对话日志随每个反馈事件一起投递。再次点击已记录的评分会直接撤回,不打开弹窗。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback ` 仍走宿主命令路径并显示确认行。 +与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」会打开同一个弹窗。挂载 `session-log-export` 后,会话标题栏的更多操作菜单也会增加「反馈」。已记录的评分显示实心图标,不需要悬停也一直可见。点赞和点踩都会打开弹窗:七个分类标签和一个详情框都可不填;提交后才会记录带所填内容的对应评分并弹出感谢 toast,对话日志随每个反馈事件一起投递。再次点击已记录的评分会直接撤回,不打开弹窗。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback ` 仍走宿主命令路径并显示确认行。 ### 失败 @@ -39,7 +39,7 @@ kind: "package-reference"
实现细节——点击展开 -本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内;同时贡献 `conversation.input.overlay` 的 `feedback-dialog` 条目(order 2),它通过 body portal 渲染 Modal 与 Toast 基元,并让 toast 以其所在的输入框卡片为中心。`/feedback` 装饰是经 `ctx.commandUi.decorate` 注册的 `action`,因此菜单选中或不带参数的回车会消费触发 token 并打开弹窗,而带参数的命令行仍到达宿主命令。 +本包贡献 `conversation.chat.assistant-actions` 的 `feedback` 条目(order 10),由 ui-conversation 声明并渲染在已定稿助手消息的 IconActions 行内;同时贡献 `conversation.input.overlay` 的 `feedback-dialog` 条目(order 2),它通过 body portal 渲染 Modal 与 Toast 基元,并让 toast 以其所在的输入框卡片为中心。`feedbackUi.openSession(sessionId)` Client 服务打开同一个 Session 级弹窗,不记录反馈;标题栏菜单和命令装饰共用此方法。`/feedback` 装饰是经 `ctx.commandUi.decorate` 注册的 `action`,因此菜单选中或不带参数的回车会消费触发 token 并打开弹窗,而带参数的命令行仍到达宿主命令。 每个 Session 有一个 `MessageFeedbackController` 支撑所有消息控件,以及一个 `FeedbackDialogController` 拥有弹窗草稿、提交与 toast 序号。消息控制器只读取一次 `messageFeedback.list`,且延迟到首次 hover 或 focus 才发起,而非挂载时触发;变更串行执行,每次都携带最后观察到的版本,`version-conflict` 响应带回权威条目,据此对账视图而不重新拉取。任一评分操作执行前,该行都会检查已提交条目:评分相同则调用 `retract`,它会在串行队列内重新检查评分并在并发变更后变为无操作;其他状态则携带所选评分打开弹窗。弹窗控制器按目标提交:消息目标通过消息控制器 put 一条带弹窗备注与分类的对应评分,Session 目标通过 `ctx.remote.sessionFeedback` 记录。成功会关闭草稿并弹出确认 toast;被替换的旧草稿迟到的成功只弹确认 toast、不关闭新草稿;失败会保留弹窗并弹出停留时间更长的警告 toast。 @@ -90,4 +90,4 @@ kind: "package-reference"
-**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰,以及一个按 Session 划分的控制器对 map;它们都由插件 fiber 的同一个 effect disposer 释放。生命周期规格测试证明,所属 fiber 释放时会撤销所有注册并丢弃所有控制器,因此不存在需要在运行时检查的第二权威来源。 +**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰、`feedbackUi` 服务,以及一个按 Session 划分的控制器对 map;它们都随所属插件 fiber 释放。生命周期规格测试验证,fiber 释放时会撤销所有注册和服务并丢弃所有控制器,因此不存在需要在运行时检查的第二权威来源。 diff --git a/packages/client/ui-message-feedback/src/client/index.ts b/packages/client/ui-message-feedback/src/client/index.ts index 91e6718f62..4f26c3bb0d 100644 --- a/packages/client/ui-message-feedback/src/client/index.ts +++ b/packages/client/ui-message-feedback/src/client/index.ts @@ -3,7 +3,8 @@ * conversation.chat.assistant-actions strip, the feedback dialog and its * acknowledgement and failure toasts in conversation.input.overlay, and the `/feedback` * decoration that opens the dialog from the composer menu or a bare typed - * command. One FeedbackSurface per Session backs every entry in that Session. + * command. The feedbackUi service opens the same dialog for other plugins. + * One FeedbackSurface per Session backs every entry in that Session. * @module @deepseek-ai/dsh-client-ui-message-feedback/client */ @@ -37,6 +38,21 @@ export type { } from './slots.ts' export type { MessageFeedbackKey } from './locales.ts' +/** Opens the existing Session feedback form without submitting feedback. */ +export interface FeedbackUi { + /** + * Open the Session feedback draft without recording feedback. + * @param sessionId - Session whose feedback draft to open. + */ + openSession(sessionId: SessionId): void +} + +declare module '@deepseek-ai/cordis' { + interface Context { + feedbackUi: FeedbackUi + } +} + /** Dictionary namespace owned by this plugin. */ const NS = 'feedback' @@ -45,7 +61,8 @@ export const inject = ['slots', 'remote', 'remote.messageFeedback', 'remote.sess /** * Client plugin body: the per-message feedback entry, the Session's dialog - * entry, the `/feedback` decoration, and their per-session surfaces. + * entry, the `feedbackUi` service, the `/feedback` decoration, and their + * per-session surfaces. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { @@ -65,6 +82,11 @@ export function apply(ctx: ClientContext): void { surfaces.clear() }, 'ui-message-feedback: per-session surfaces') + const feedbackUi: FeedbackUi = { + openSession: (sessionId) => { surfaceFor(sessionId).dialog.open({ kind: 'session' }) }, + } + ctx.provide('feedbackUi', feedbackUi) + // A reconnect can only invalidate what was already read; a cold Session // stays cold until something asks for it. ctx.on('connection/reset', () => { @@ -114,7 +136,7 @@ export function apply(ctx: ClientContext): void { scope.effect(() => scope.commandUi.decorate({ name: 'feedback', available: () => true, - ui: { kind: 'action', run: (session) => { surfaceFor(session.sessionId).dialog.open({ kind: 'session' }) } }, + ui: { kind: 'action', run: (session) => { feedbackUi.openSession(session.sessionId) } }, }), 'ui-message-feedback: /feedback decoration') }) } diff --git a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx index 2bad94b036..e41e7d599a 100644 --- a/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx +++ b/packages/client/ui-message-feedback/tests/browser-plugin.client.spec.tsx @@ -164,6 +164,20 @@ describe('ui-message-feedback browser plugin', () => { expect(b.dialogEntry()!.inject!(sid('s2')).hooks.dialog.getSnapshot()).toMatchObject({ target: null, toast: 0 }) }) + it('opens the shared Session dialog through feedbackUi without recording anything', async () => { + const b = await bench() + await b.fiber.await() + const dialog = b.dialogEntry()!.inject!(sid('s1')) + b.ctx.feedbackUi.openSession(sid('s1')) + expect(dialog.hooks.dialog.getSnapshot().target).toEqual({ kind: 'session' }) + expect(b.dialogEntry()!.inject!(sid('s2')).hooks.dialog.getSnapshot().target).toBeNull() + expect(b.calls).toEqual([]) + dialog.dismiss() + expect(b.calls).toEqual([]) + await b.fiber.dispose() + expect(b.ctx.get('feedbackUi')).toBeUndefined() + }) + it('records a Session remark through the sessionFeedback Remote and a message judgment through put', async () => { const b = await bench() await b.fiber.await() diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 9dd41a794a..5b9d5c624f 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-plugin-manager/README.md -README.md: 80de28b3000dfacf07ecfa805b7396d149249aba -README.zh.md: 3368205f2ef9083e558ac34626f298c14304e995 +README.md: 89b23be375432e2de0874331ed30b85d3451335b +README.zh.md: 7985f7fdcc50d7a5db0be62cec3653fe7d72b67c diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 80de28b300..89b23be375 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -31,7 +31,7 @@ The Agent Teams and Auto Authorization Review packages have localized names and ### Installing a bundle -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Not sure what to enter?** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. **Install guide and examples** under the field opens a guide that shows the three common forms with an example each; **Use example** drops one into the field. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`): a name the list already shows, a name the registry does not have, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. A failed install says what went wrong in one line — the registry or network could not be reached, the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During installation, **Cancel install** asks the Host to stop the run and shows **Stopping installation…** until the Host confirms. Loading the bundle cannot be cancelled. Once confirmed, the dialog returns to the spec, ready to install again, and a toast says the installation was cancelled; the manifest and lockfile are back as they were, while downloaded files can remain. Closing the dialog while the run is in progress asks the Host to stop it the same way, and the dialog closes once the Host confirms; while the Host prepares, stops, or loads, the dialog cannot be closed. A connection error does not confirm cancellation: the running screen says so and cancelling can be tried again. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 3368205f2e..7985f7fdcc 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -31,7 +31,7 @@ Agent Teams 和 Auto Authorization Review 两个包使用随界面语言切换 ### 安装一个组合包 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**不知道该填什么?**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。输入框下方的**插件安装引导和示例**展开一段引导,给出三种常见形式各一个示例;**填入示例**把示例填进输入框。**安装**先让 Host 读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、注册表没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。安装失败时用一行话说明原因——注册表或网络不可达、包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 安装期间可点击**取消安装**,对话框显示**正在停止安装…**,直到 Host 确认。加载组合包的阶段不可取消。确认后对话框回到 spec 输入界面,可再次安装,并用 toast 说明安装已取消;manifest 与 lockfile 已恢复原样,已下载文件可能保留。安装进行中关闭对话框,同样会请求 Host 停止安装,Host 确认后对话框才关闭;Host 正在准备、停止或加载时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 9fa832d04a..f9fe73dd3f 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -124,8 +124,9 @@ /* Center the status label geometrically: beside larger titles the capsule's line-box offset shows. */ .statusTag { - height: 20px; - padding: 0 8px; + height: 18px; + padding: 0 7px; + font-size: 10px; line-height: 1; } @@ -245,7 +246,10 @@ border: 0; background: transparent; color: inherit; + /* font: inherit resets the size, so the card title's own size follows. */ font: inherit; + font-size: 14px; + line-height: 20px; font-weight: 500; text-align: left; cursor: pointer; @@ -310,6 +314,15 @@ cursor: default; } +/* The toolbar's Add plugin action, one step up from the 28px sm capsule. */ +.addButton { + height: 32px; + font-size: 13px; + line-height: 20px; + padding: 0 12px; + border-radius: 16px; +} + .iconWrap { display: inline-flex; } @@ -391,7 +404,7 @@ /* Wider than the default dialog: pnpm's lines are long, and the terminal keeps them unwrapped. The card never outgrows the viewport; the running screens scroll inside it so the head's controls stay reachable. */ .installDialog { - width: min(600px, 100%); + width: min(560px, 100%); max-height: calc(100vh - 48px); } @@ -419,16 +432,22 @@ } .installField input[type='text'] { - height: 36px; - padding: 0 12px; + height: 40px; + padding: 0 14px; border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 10px; + border-radius: 14px; + outline: none; background: var(--dsw-alias-bg-layer-3); font: inherit; font-size: 13px; color: var(--dsw-alias-label-primary); } +.installField input[type='text']:focus { + border-color: var(--dsw-alias-brand-primary); + box-shadow: inset 0 0 0 0.5px var(--dsw-alias-brand-primary); +} + .installField input[aria-invalid='true'] { border-color: var(--dsw-alias-state-error-primary); } @@ -439,6 +458,7 @@ align-items: center; align-self: flex-start; gap: 4px; + margin-top: 4px; padding: 0; border: 0; background: none; @@ -464,13 +484,12 @@ display: flex; flex-direction: column; gap: 10px; - padding: 12px 14px; + padding: 8px 14px 14px; border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 12px; + border-radius: 14px; background: var(--dsw-alias-bg-layer-1); } -.guideIntro, .guideHint { margin: 0; font-size: 12px; @@ -482,10 +501,10 @@ .guideSafety { display: flex; align-items: flex-start; - gap: 6px; + gap: 8px; margin: 0; - padding: 8px 10px; - border-radius: 8px; + padding: 10px 12px; + border-radius: 10px; background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 12%, transparent); font-size: 12px; line-height: 18px; @@ -497,19 +516,10 @@ margin-top: 2px; } -.guideNote { - margin: 0; - padding: 8px 10px; - border-radius: 8px; - background: var(--dsw-alias-bg-layer-3); - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-secondary); -} - .guideList { display: flex; flex-direction: column; + gap: 2px; margin: 0; padding: 0; list-style: none; @@ -520,38 +530,41 @@ align-items: flex-start; gap: 10px; padding: 8px 0; - border-top: 0.5px solid var(--dsw-alias-border-l4); } .guideIndex { + display: inline-flex; flex: none; + align-items: center; + justify-content: center; width: 20px; height: 20px; border-radius: 999px; corner-shape: round; - background: var(--dsw-alias-bg-layer-3); + background: var(--dsw-alias-bg-module-platform); font-size: 11px; - line-height: 20px; - text-align: center; - color: var(--dsw-alias-label-tertiary); + font-weight: 500; + color: var(--dsw-alias-label-secondary); } .guideMain { display: flex; flex: 1; flex-direction: column; - gap: 2px; + gap: 0; min-width: 0; } .guideTitle { - font-size: 12.5px; - font-weight: 600; + font-size: 13px; + font-weight: 500; + line-height: 20px; color: var(--dsw-alias-label-primary); } .guideExample { font-size: 12px; + line-height: 20px; color: var(--dsw-alias-label-secondary); overflow-wrap: anywhere; } @@ -575,7 +588,9 @@ /* The footer's only action spans the dialog, as the primary action of a short form does. */ .wide { width: 100%; + height: 40px; justify-content: center; + border-radius: 14px; } /* The installing, installed, and failed screens: a head row, the centred @@ -587,7 +602,7 @@ gap: 16px; min-width: 0; min-height: 0; - padding: 16px 20px 20px; + padding: 16px 20px 0; } /* Everything under the head: it scrolls when the runs' output makes the screen taller than the card. */ @@ -658,6 +673,14 @@ color: var(--dsw-alias-label-secondary); } +.wizardIcon[data-state='done'] { + color: var(--dsw-alias-state-success-secondary); +} + +.wizardIcon[data-state='error'] { + color: var(--dsw-alias-state-warn-label); +} + .wizardTitle { margin: 0; font-size: 18px; @@ -708,6 +731,11 @@ gap: 12px; } +.footAction { + height: 32px; + border-radius: 16px; +} + .detailsToggle { display: inline-flex; align-items: center; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 65eece8d3f..51097f6ae1 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -12,11 +12,13 @@ import { useEffect, useId, useState, type ReactNode } from 'react' import type { PluginInstallFailureKind } from '@deepseek-ai/dsh-api-remotes/client' import { - Button, IconChevronDownOutlineRegular, IconChevronLeftOutlineRegular, - IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCordisPluginOutlineRegular, - IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, - IconWarningOutlineRegular, Input, Modal, StateDot, Switch, Tag, TerminalBlock, Toast, - type StateDotState, type TerminalBlockLabels, + Button, IconCheckCircleFillRegular, IconChevronDownOutlineRegular, IconChevronLeftOutlineMedium, + IconChevronRightOutlineRegular, IconCloseOutlineMedium, + IconPlusOutlineRegular, IconRefreshOutlineRegular, IconTrashOutlineRegular, + IconWarningOutlineRegular, Input, Modal, + PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal, + StateDot, Switch, Tag, TerminalBlock, Toast, + type IconProps, type StateDotState, type TerminalBlockLabels, } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { rowConfigKey, type OfficialItem } from './config-ledger.ts' @@ -112,6 +114,37 @@ interface RowConfigure { /** Rows beyond this count get a filter box above the list. */ const ROW_FILTER_THRESHOLD = 10 +/** The size the 36-viewBox plugin artwork renders at inside a card's 48px frame. */ +const CARD_ARTWORK_SIZE = 36 + +/** The size the artwork renders at inside a row's 40px frame. */ +const ROW_ARTWORK_SIZE = 30 + +/** The artwork of the official plugins that registered their configuration, by registration id. */ +const ITEM_ARTWORK = new Map ReactNode>([ + ['bash', PluginArtworkTerminal], + ['agent-loop', PluginArtworkLoop], + ['subagent', PluginArtworkSubagent], + ['web-search', PluginArtworkSearch], +]) + +/** The artwork of the official bundles with artwork of their own, by package name. */ +const PACKAGE_ARTWORK = new Map ReactNode>([ + ['@deepseek-ai/dsh-experimental-agent-team-profile', PluginArtworkTeam], +]) + +/** An official plugin's card and page artwork; plugins without their own get the default. */ +function itemArtwork(id: string): ReactNode { + const Artwork = ITEM_ARTWORK.get(id) ?? PluginArtworkDefault + return +} + +/** A package's card and page artwork; packages without their own get the default. */ +function packageArtwork(name: string): ReactNode { + const Artwork = PACKAGE_ARTWORK.get(name) ?? PluginArtworkDefault + return +} + /** A row's switch: locked, saying why, when the Host refuses to address the row through the profile patch. */ function RowSwitch({ row, t, busy, onChange }: { readonly row: PackageRow @@ -190,7 +223,7 @@ function RowsSection({ rows, t, toggle, configure }: { {...row.phase === 'failed' ? { 'data-state': 'failed' } : row.enabled ? {} : { 'data-state': 'off' }} >
- +
{configure?.has(row) === true ? ( @@ -246,18 +279,19 @@ function packageStatus(pkg: PackageView): 'running' | 'disabled' | 'problem' { return pkg.enabled ? 'running' : 'disabled' } -/** The head every card shares: the pinwheel icon, the name that opens the page beside its tags, its one-liner, and what sits at the end. */ -function CardHead({ title, t, onOpen, tags, description, end }: { +/** The head every card shares: the artwork, the name that opens the page beside its tags, its one-liner, and what sits at the end. */ +function CardHead({ title, t, onOpen, icon, tags, description, end }: { readonly title: string readonly t: Translate readonly onOpen: () => void + readonly icon: ReactNode readonly tags?: ReactNode readonly description: ReactNode readonly end?: ReactNode }): ReactNode { return (
- +
@@ -275,7 +309,7 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { readonly crumbLabel: string readonly crumbText: string readonly onBack: () => void - readonly icon?: ReactNode + readonly icon: ReactNode readonly actions?: ReactNode }): ReactNode { return ( @@ -285,7 +319,7 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: { {crumbText}
- + {actions}
@@ -314,6 +348,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: { title={title} t={t} onOpen={onOpen} + icon={packageArtwork(pkg.name)} tags={( <> {beta ? {t('statusBeta')} : null} @@ -339,7 +374,7 @@ function ItemCard({ item, t, onOpen, renderSlot }: { }): ReactNode { return (
  • - +
  • ) } @@ -353,7 +388,7 @@ function ItemDetail({ item, t, onBack, renderSlot }: { }): ReactNode { return (
    - +

    {item.label}

    @@ -382,7 +417,7 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: { const key = rowConfigKey(pkg.name, row.rowId) return (
    - } /> + } />

    {row.rowId}

    @@ -431,6 +466,7 @@ function PackageDetail({ crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} + icon={packageArtwork(pkg.name)} actions={(
    {pkg.installed @@ -635,19 +671,19 @@ function InstallDialog({ )} >
    -