Merge remote-tracking branch 'origin/master' into feat/macos-sidebar-ui-and-drag-refine
This commit is contained in:
commit
8837c532d1
128 files changed
+1753
-476
No files matched your search
+2
-2
@@ -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
|
||||
+5
-5
@@ -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<SessionId>` (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).
|
||||
+5
-5
@@ -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<SessionId>`(归属 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 消费方尚未构建)。
|
||||
+6
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 场景在提交已录制的提示词前刷新空白会话。
|
||||
+2
-2
@@ -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
|
||||
@@ -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 <text>` 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 <text>`.** 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.
|
||||
|
||||
@@ -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 <text>` 仍到达宿主;它使用 `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 <text>` 提交。** 命令拒绝空文本、带不了分类,还会写一行设计稿已用 toast 取代的确认;Remote 记录同一个事件且没有这两个约束。
|
||||
|
||||
+2
-2
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`。原生与嵌套调用遵循同一规则。外层程序随后失败不会撤销已完成的嵌套声明;被阻止的结果不发布声明,同名作用域替换也不能发布其他实例的结果。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof watchConsole>[] = []
|
||||
|
||||
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<Page> {
|
||||
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<string | undefined> {
|
||||
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([])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 = <T extends Element>(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<HTMLElement>('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<HTMLElement>('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<HTMLElement>('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<HTMLElement>('[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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "安装"
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ; 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.
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -631,7 +631,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
|
||||
|
||||
### `present`
|
||||
|
||||
声明交付 Session 文件系统可访问的已有文件。如果你创建或更新的文件是用户要求接收的成果,则必须在写入完成后、最终回复前调用 present,包括通过 Bash 或代码执行创建的文件。在回复中提到文件路径不能替代这次调用。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
|
||||
选择 Session 文件系统可访问的已有文件,声明为最终交付物。当用户需要独立的文件交付物时使用 present,尤其是 Office 文档、电子表格和演示文稿。如果最终回复已经足够展示结果,优先在回复中展示;创建或编辑文件本身不要求调用 present。通常选择最重要的 1 至 2 项交付物,任务需要时可以更多,但一次 present 调用最多 4 个文件。文件必须已存在。用户打开当前源文件;不复制或保存其内容。
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 与历史操作,不提供文件传输。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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) | 在已安装应用中打开工作区目录的会话标题栏拆分按钮,以及文档预览里用默认应用打开单个文件的控件 | — |
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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 都无法重新定位它。
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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 <div className={css.card} data-changed-files>
|
||||
<button type="button" className={css.header} aria-label={t('changes.openReview')} onClick={() => { openReview(0) }}>
|
||||
<span className={css.tile}><IconCodeBracketsOutline16 size={18} /></span>
|
||||
<span className={css.tile}>
|
||||
<span className={css.tileMark}><IconCodeBracketsOutline16 size={10} /></span>
|
||||
</span>
|
||||
<span className={css.titles}>
|
||||
<span className={css.title}>{t('changes.title', { count: String(changes.total) })}</span>
|
||||
<span className={css.stat}><Counts t={t} added={changes.added} deleted={changes.deleted} /></span>
|
||||
<span className={css.stat}>
|
||||
<span className={css.statCounts}><Counts t={t} added={changes.added} deleted={changes.deleted} /></span>
|
||||
<span className={css.previewHint}>{t('presented.preview')}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<ul className={css.list}>
|
||||
|
||||
@@ -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); }
|
||||
@@ -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<number, readonly HighlightSpan[]> | undefined
|
||||
new: ReadonlyMap<number, readonly HighlightSpan[]> | 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 <span className={css.text} data-diff-code={spans === undefined ? undefined : ''}>
|
||||
{spans === undefined ? text : spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)}
|
||||
</span>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
<span className={css.tools}>
|
||||
<Tooltip label={t(split ? 'review.unified' : 'review.split')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.tool} aria-pressed={split} aria-label={t('review.splitAria')} data-review-tool="split"
|
||||
onClick={() => { actions.toggledSplit(tab.id) }}><IconPanelLeftOutlineRegular /></button>
|
||||
onClick={() => { actions.toggledSplit(tab.id) }}>
|
||||
<IconCompareSplitOutlineRegular className={css.compareIcon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t(wrap ? 'review.nowrap' : 'review.wrap')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.tool} aria-pressed={wrap} aria-label={t('review.wrapAria')} data-review-tool="wrap"
|
||||
onClick={() => { actions.toggledWrap(tab.id) }}><IconWrapLinesOutlineRegular /></button>
|
||||
onClick={() => { actions.toggledWrap(tab.id) }}>
|
||||
{wrap ? <IconNowrapFillRegular /> : <IconWrapFillRegular />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{file !== undefined && <Tooltip label={t('review.openFile')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.tool} aria-label={t('review.openFileAria', { name: file.display })} data-review-tool="open-file"
|
||||
onClick={() => { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}><IconCodeOutlineRegular /></button>
|
||||
onClick={() => { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}><IconInspectOutlineRegular /></button>
|
||||
</Tooltip>}
|
||||
{file !== undefined && native && <Tooltip label={t(phase === 'error' ? 'diff.openNativeError' : 'diff.openNative')} side="bottom" delayMs={500}>
|
||||
<button type="button" className={css.tool} disabled={phase === 'opening'} data-review-tool="open-native"
|
||||
@@ -277,7 +313,7 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string {
|
||||
* runs under the other and both sides show the same rows and columns of text.
|
||||
* Every line is one fixed-height row, which keeps the sides aligned.
|
||||
*/
|
||||
function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode {
|
||||
function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode {
|
||||
const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks])
|
||||
const columns = useRef<Record<'left' | 'right', HTMLDivElement | null>>({ left: null, right: null })
|
||||
const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } })
|
||||
@@ -305,10 +341,11 @@ function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): React
|
||||
<div className={css.hunkHeader}>{hunk.header}</div>
|
||||
{hunk.rows.map((row, at) => {
|
||||
const cell = row[side]
|
||||
const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no)
|
||||
return (
|
||||
<div key={at} className={`${css.sideLine} ${cell === undefined ? css.empty : css[cell.kind]}`} data-diff-line={splitRowKind(row)}>
|
||||
<span className={css.number}>{cell?.no ?? ''}</span>
|
||||
<span className={css.text}>{cell?.text ?? ''}</span>
|
||||
<DiffText text={cell?.text ?? ''} spans={spans} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -324,23 +361,26 @@ function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): React
|
||||
function TextDiff({ diff, split, wrap, t }: { diff: Extract<ChangesDiff, { kind: 'text' }>; split: boolean; wrap: boolean } & PropsLocale<typeof NS>): ReactNode {
|
||||
const note = noteOf(diff)
|
||||
const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks])
|
||||
const highlighter = useCodeHighlighter(languageForPath(diff.path))
|
||||
const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter])
|
||||
return (
|
||||
<div className={css.body} data-review-view={split ? 'split' : 'unified'} data-review-wrap={wrap || undefined}>
|
||||
{note !== undefined && <p className={css.note}>{t(note)}</p>}
|
||||
{diff.coarse && <p className={css.note} data-diff-coarse>{t('diff.coarse')}</p>}
|
||||
{truncated && <p className={css.note} data-diff-truncated>{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}</p>}
|
||||
{split && !wrap ? <SplitColumns hunks={hunks} /> : hunks.map((hunk, position) => (
|
||||
<section key={position} className={css.hunk}>
|
||||
{split && !wrap ? <SplitColumns hunks={hunks} highlights={highlights} /> : hunks.map((hunk, position) => {
|
||||
const highlighted = highlights[position]
|
||||
return <section key={position} className={css.hunk}>
|
||||
<div className={css.hunkHeader}>{hunkHeader(hunk)}</div>
|
||||
{split ? splitRows(hunk).map((row, at) => (
|
||||
<div key={at} className={css.splitLine} data-diff-line={splitRowKind(row)}>
|
||||
<span className={`${css.cell} ${row.left === undefined ? css.empty : css[row.left.kind]}`}>
|
||||
<span className={css.number}>{row.left?.no ?? ''}</span>
|
||||
<span className={css.text}>{row.left?.text ?? ''}</span>
|
||||
<DiffText text={row.left?.text ?? ''} spans={row.left === undefined ? undefined : highlighted?.old?.get(row.left.no)} />
|
||||
</span>
|
||||
<span className={`${css.cell} ${row.right === undefined ? css.empty : css[row.right.kind]}`}>
|
||||
<span className={css.number}>{row.right?.no ?? ''}</span>
|
||||
<span className={css.text}>{row.right?.text ?? ''}</span>
|
||||
<DiffText text={row.right?.text ?? ''} spans={row.right === undefined ? undefined : highlighted?.new?.get(row.right.no)} />
|
||||
</span>
|
||||
</div>
|
||||
)) : hunkRows(hunk).map((row, at) => (
|
||||
@@ -348,11 +388,11 @@ function TextDiff({ diff, split, wrap, t }: { diff: Extract<ChangesDiff, { kind:
|
||||
<span className={css.number}>{row.old ?? ''}</span>
|
||||
<span className={css.number}>{row.new ?? ''}</span>
|
||||
<span className={css.sign}>{row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '}</span>
|
||||
<span className={css.text}>{row.text}</span>
|
||||
<DiffText text={row.text} spans={row.kind === 'add' ? highlighted?.new?.get(row.new as number) : highlighted?.old?.get(row.old as number)} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<ReviewState, ReviewAction
|
||||
init: (): ReviewState => ({ 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<ReviewState, ReviewAction
|
||||
*/
|
||||
navigated: (d, tabId: TabId, revision: number, index: number) => {
|
||||
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 }
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -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 ; 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.'
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 ; 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)
|
||||
}
|
||||
|
||||
@@ -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} 行`)
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -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
|
||||
@@ -25,7 +25,7 @@ This package is the Web GUI's feedback surface: the Like/Dislike pair in the fin
|
||||
<a id="use-this-package"></a>
|
||||
## 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 <text>` 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 <text>` 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
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
**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.
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」行则打开弹窗。已记录的评分显示实心图标,不需要悬停也一直可见。点赞和点踩都会打开弹窗:七个分类标签和一个详情框都可不填;提交后才会记录带所填内容的对应评分并弹出感谢 toast,对话日志随每个反馈事件一起投递。再次点击已记录的评分会直接撤回,不打开弹窗。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback <text>` 仍走宿主命令路径并显示确认行。
|
||||
与 `ui-conversation`、`ui-commands` 一起挂载本插件;Like/Dislike 对随即出现在每个轮次收尾助手消息的动作行中,位于复制与分支之间,输入框菜单里的「反馈」会打开同一个弹窗。挂载 `session-log-export` 后,会话标题栏的更多操作菜单也会增加「反馈」。已记录的评分显示实心图标,不需要悬停也一直可见。点赞和点踩都会打开弹窗:七个分类标签和一个详情框都可不填;提交后才会记录带所填内容的对应评分并弹出感谢 toast,对话日志随每个反馈事件一起投递。再次点击已记录的评分会直接撤回,不打开弹窗。不带文本的 `/feedback`,无论是从菜单选中还是直接输入后发送,都会为 Session 打开同一个弹窗;`/feedback <text>` 仍走宿主命令路径并显示确认行。
|
||||
|
||||
### 失败
|
||||
|
||||
@@ -39,7 +39,7 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
本包贡献 `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"
|
||||
|
||||
</details>
|
||||
|
||||
**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰,以及一个按 Session 划分的控制器对 map;它们都由插件 fiber 的同一个 effect disposer 释放。生命周期规格测试证明,所属 fiber 释放时会撤销所有注册并丢弃所有控制器,因此不存在需要在运行时检查的第二权威来源。
|
||||
**运行时不变式:** 不发布伴生入口。插件持有两个 slot 注册、一个命令装饰、`feedbackUi` 服务,以及一个按 Session 划分的控制器对 map;它们都随所属插件 fiber 释放。生命周期规格测试验证,fiber 释放时会撤销所有注册和服务并丢弃所有控制器,因此不存在需要在运行时检查的第二权威来源。
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 正在准备、停止或加载时不能关闭对话框。连接错误不代表取消成功:安装中界面会如此说明,可以再次尝试取消。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, (props: IconProps) => 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<string, (props: IconProps) => 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 <Artwork size={CARD_ARTWORK_SIZE} />
|
||||
}
|
||||
|
||||
/** 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 <Artwork size={CARD_ARTWORK_SIZE} />
|
||||
}
|
||||
|
||||
/** 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' }}
|
||||
>
|
||||
<div className={css.rowLine}>
|
||||
<span className={css.rowIcon} aria-hidden="true"><IconCordisPluginOutlineRegular /></span>
|
||||
<span className={css.rowIcon} aria-hidden="true"><PluginArtworkSubagent size={ROW_ARTWORK_SIZE} /></span>
|
||||
<div className={css.rowMain}>
|
||||
{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 (
|
||||
<div className={css.cardHead}>
|
||||
<span className={css.cardIcon} aria-hidden="true"><IconPluginPinwheelOutlineRegular size={20} /></span>
|
||||
<span className={css.cardIcon} aria-hidden="true">{icon}</span>
|
||||
<div className={css.cardMain}>
|
||||
<div className={css.titleRow}>
|
||||
<button type="button" className={`${css.cardTitle} ${css.cardOpen}`} aria-label={t('openDetail', { name: title })} onClick={onOpen}>{title}</button>
|
||||
@@ -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 }: {
|
||||
<span>{crumbText}</span>
|
||||
</button>
|
||||
<div className={css.detailHead}>
|
||||
<span className={css.cardIcon} aria-hidden="true">{icon ?? <IconPluginPinwheelOutlineRegular size={20} />}</span>
|
||||
<span className={css.cardIcon} aria-hidden="true">{icon}</span>
|
||||
{actions}
|
||||
</div>
|
||||
</>
|
||||
@@ -314,6 +348,7 @@ function PackageCard({ pkg, t, busy, highlighted, onOpen, onSetEnabled }: {
|
||||
title={title}
|
||||
t={t}
|
||||
onOpen={onOpen}
|
||||
icon={packageArtwork(pkg.name)}
|
||||
tags={(
|
||||
<>
|
||||
{beta ? <Tag className={css.statusTag} tone="info">{t('statusBeta')}</Tag> : null}
|
||||
@@ -339,7 +374,7 @@ function ItemCard({ item, t, onOpen, renderSlot }: {
|
||||
}): ReactNode {
|
||||
return (
|
||||
<li className={`${css.card} ${css.cardLink}`} data-plugin-item={item.id}>
|
||||
<CardHead title={item.label} t={t} onOpen={onOpen} description={renderSlot('plugins.item', { view: 'summary' }, { only: item.id })} />
|
||||
<CardHead title={item.label} t={t} onOpen={onOpen} icon={itemArtwork(item.id)} description={renderSlot('plugins.item', { view: 'summary' }, { only: item.id })} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -353,7 +388,7 @@ function ItemDetail({ item, t, onBack, renderSlot }: {
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={css.detail} data-plugin-item-detail={item.id}>
|
||||
<DetailTop crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} />
|
||||
<DetailTop crumbLabel={t('backToList')} crumbText={t('crumbRoot')} onBack={onBack} icon={itemArtwork(item.id)} />
|
||||
<div className={css.detailMain}>
|
||||
<div className={css.titleRow}>
|
||||
<h3 className={css.detailTitle}>{item.label}</h3>
|
||||
@@ -382,7 +417,7 @@ function RowDetail({ pkg, row, t, onBack, renderSlot }: {
|
||||
const key = rowConfigKey(pkg.name, row.rowId)
|
||||
return (
|
||||
<div className={css.detail} data-plugin-row-detail={key}>
|
||||
<DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<IconCordisPluginOutlineRegular size={20} />} />
|
||||
<DetailTop crumbLabel={t('backToPackage', { name: title })} crumbText={title} onBack={onBack} icon={<PluginArtworkSubagent size={CARD_ARTWORK_SIZE} />} />
|
||||
<div className={css.detailMain}>
|
||||
<div className={css.titleRow}>
|
||||
<h3 className={css.detailTitle}>{row.rowId}</h3>
|
||||
@@ -431,6 +466,7 @@ function PackageDetail({
|
||||
crumbLabel={t('backToList')}
|
||||
crumbText={t('crumbRoot')}
|
||||
onBack={onBack}
|
||||
icon={packageArtwork(pkg.name)}
|
||||
actions={(
|
||||
<div className={css.detailActions}>
|
||||
{pkg.installed
|
||||
@@ -635,19 +671,19 @@ function InstallDialog({
|
||||
)}
|
||||
>
|
||||
<div className={css.installBody}>
|
||||
<label className={css.installField}>
|
||||
<span>{t('installSpecLabel')}</span>
|
||||
<div className={css.installField}>
|
||||
<input
|
||||
type="text"
|
||||
value={install.spec}
|
||||
placeholder={t('installSpecPlaceholder')}
|
||||
disabled={checking}
|
||||
aria-label={t('installSpecLabel')}
|
||||
aria-invalid={install.inputError !== null}
|
||||
aria-describedby={install.inputError === null ? undefined : errorId}
|
||||
onChange={(event) => { onEditSpec(event.currentTarget.value) }}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' && !empty && !checking) onRun() }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{install.inputError === null
|
||||
? null
|
||||
: <p id={errorId} className={css.inputError} role="alert">{t(INPUT_PROBLEM_KEYS[install.inputError.problem], { reason: install.inputError.reason })}</p>}
|
||||
@@ -664,19 +700,17 @@ function InstallDialog({
|
||||
{guideOpen
|
||||
? (
|
||||
<div id={guideId} className={css.guide} data-install-guide>
|
||||
<p className={css.guideIntro}>{t('installGuideIntro')}</p>
|
||||
<p className={css.guideNote}>{t('installGuideIdNote')}</p>
|
||||
<ol className={css.guideList}>
|
||||
{GUIDE_EXAMPLES.map(({ key, titleKey, exampleKey, hintKey }, index) => (
|
||||
<li key={key} className={css.guideItem}>
|
||||
<span className={css.guideIndex} aria-hidden="true">{index + 1}</span>
|
||||
<div className={css.guideMain}>
|
||||
<span className={css.guideTitle}>{t(titleKey)}</span>
|
||||
<span className={css.guideHint}>{t(hintKey)}</span>
|
||||
<span className={css.guideExample}>
|
||||
<span className={css.guideExampleLabel}>{t('installGuideExampleLabel')}</span>
|
||||
<code>{t(exampleKey)}</code>
|
||||
</span>
|
||||
<span className={css.guideHint}>{t(hintKey)}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -717,7 +751,7 @@ function InstallDialog({
|
||||
? <span />
|
||||
: (
|
||||
<button type="button" className={css.wizardBack} aria-label={t('installEditAria')} disabled={!stoppable} onClick={onCancel}>
|
||||
<IconChevronLeftOutlineRegular aria-hidden="true" />
|
||||
<IconChevronLeftOutlineMedium aria-hidden="true" />
|
||||
<span>{t('installEdit')}</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -728,13 +762,17 @@ function InstallDialog({
|
||||
disabled={pending && phase !== 'running'}
|
||||
onClick={phase === 'running' ? onCancelAndClose : onClose}
|
||||
>
|
||||
<IconCloseOutlineRegular size={14} />
|
||||
<IconCloseOutlineMedium size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.wizardScroll}>
|
||||
<div className={css.wizardHero}>
|
||||
<span className={css.wizardIcon} aria-hidden="true">
|
||||
<StateDot state={pending ? 'ongoing' : phase === 'done' ? 'done' : 'error'} />
|
||||
<span className={css.wizardIcon} data-state={pending ? 'ongoing' : phase === 'done' ? 'done' : 'error'} aria-hidden="true">
|
||||
{pending
|
||||
? <StateDot state="ongoing" size={28} />
|
||||
: phase === 'done'
|
||||
? <IconCheckCircleFillRegular size={28} />
|
||||
: <IconWarningOutlineRegular size={28} />}
|
||||
</span>
|
||||
<h2 className={css.wizardTitle} role={phase === 'failed' ? 'alert' : 'status'}>{heading}</h2>
|
||||
{phase === 'failed' ? <p className={css.wizardSub}>{failureText(install.failure, t)}</p> : null}
|
||||
@@ -771,12 +809,12 @@ function InstallDialog({
|
||||
</button>
|
||||
{pending
|
||||
? (
|
||||
<Button variant="outline" size="sm" disabled={phase !== 'running'} onClick={onCancel}>
|
||||
<Button variant="outline" size="sm" className={css.footAction} disabled={phase !== 'running'} onClick={onCancel}>
|
||||
{t(phase === 'cancelling' ? 'installCancelling' : 'installCancel')}
|
||||
</Button>
|
||||
)
|
||||
: null}
|
||||
{phase === 'failed' && !approvable ? <Button variant="primary" size="sm" onClick={onRun}>{t('installRetry')}</Button> : null}
|
||||
{phase === 'failed' && !approvable ? <Button variant="primary" size="sm" className={css.footAction} onClick={onRun}>{t('installRetry')}</Button> : null}
|
||||
</div>
|
||||
{install.detailsOpen
|
||||
? (
|
||||
@@ -918,31 +956,21 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
<button type="button" className={css.iconButton} aria-label={t('refresh')} title={t('refresh')} disabled={!loaded} onClick={props.refresh}>
|
||||
<span className={css.iconWrap} aria-hidden="true"><IconRefreshOutlineRegular /></span>
|
||||
</button>
|
||||
<Button variant="primary" size="sm" icon={<IconPlusOutlineRegular size={13} />} disabled={!loaded} onClick={props.openInstall}>{t('addPlugin')}</Button>
|
||||
<Button variant="primary" size="sm" className={css.addButton} icon={<IconPlusOutlineRegular size={13} />} disabled={!loaded} onClick={props.openInstall}>{t('addPlugin')}</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
: null}
|
||||
{state.status === 'loading' ? (
|
||||
{showsCards && state.status === 'loading' ? (
|
||||
<p className={`${css.status} ${css.statusWithDot}`} role="status">
|
||||
<StateDot state="ongoing" />{t('loading')}
|
||||
</p>
|
||||
) : null}
|
||||
{state.status === 'unavailable' ? (
|
||||
{showsCards && state.status === 'unavailable' ? (
|
||||
<p className={`${css.status} ${css.statusWithDot}`} role="status">
|
||||
<StateDot state="idle" />{t('unavailable')}
|
||||
</p>
|
||||
) : null}
|
||||
{state.status === 'error'
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t('error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{state.notice === null || noticeLine === null
|
||||
? null
|
||||
: (
|
||||
@@ -954,6 +982,16 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
onDone={props.dismissNotice}
|
||||
/>
|
||||
)}
|
||||
{!showsCards && state.status === 'error'
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t('error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{loaded && openPkg !== undefined && openRow !== undefined
|
||||
? (
|
||||
<RowDetail
|
||||
@@ -986,12 +1024,24 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
? <ItemDetail item={openItem} t={t} renderSlot={renderSlot} onBack={() => { setView({ kind: 'list' }) }} />
|
||||
: null}
|
||||
{loaded && showsCards
|
||||
? officialCards.length === 0 && mine.length === 0
|
||||
? officialCards.length === 0 && mine.length === 0 && state.status !== 'error'
|
||||
? <p className={css.empty}>{t('empty')}</p>
|
||||
: (
|
||||
<>
|
||||
{renderGroup('official', t('officialTitle'), officialCards)}
|
||||
{renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
|
||||
{/* A failed package read trails the groups it left incomplete: right under Official on a
|
||||
first-load failure, and after the kept cards when a refresh fails over stale data. */}
|
||||
{state.status === 'error'
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t('error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const zh = {
|
||||
title: '插件',
|
||||
intro: '添加和管理插件',
|
||||
loading: '正在读取插件…',
|
||||
error: '暂时无法读取插件。',
|
||||
error: '可能由于网络问题,无法读取全部插件',
|
||||
unavailable: '本部署没有可管理的 profile,无法安装或启停插件。',
|
||||
retry: '重试',
|
||||
refresh: '刷新',
|
||||
@@ -53,13 +53,11 @@ export const zh = {
|
||||
installDescription: '输入插件的包名、GitHub 仓库地址或本地目录路径。',
|
||||
installSpecLabel: '包名或地址',
|
||||
installSpecPlaceholder: '例如 @deepseek-ai/dsh-experimental-auto-review',
|
||||
installGuideToggle: '不知道该填什么?',
|
||||
installGuideToggle: '插件安装引导和示例',
|
||||
installGuideHide: '收起引导',
|
||||
installGuideIntro: '从插件的 README 或发布页面复制以下任意一项。',
|
||||
installGuideIdNote: '包名即 npm 包名,常见形式为 dsh-xxx 或 @作者/插件名;插件的显示名称不是包名。',
|
||||
installGuideIdTitle: '包名',
|
||||
installGuideIdExample: '@deepseek-ai/dsh-experimental-auto-review',
|
||||
installGuideIdHint: 'README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。',
|
||||
installGuideIdHint: '插件包名即 npm 包名(如 dsh-xxx 或 @作者/插件名),社区插件的 README 安装命令中 dsh plugin add 或 pnpm add 之后的部分。',
|
||||
installGuideGitTitle: 'GitHub 仓库地址',
|
||||
installGuideGitExample: 'https://github.com/author/dsh-plugin',
|
||||
installGuideGitHint: '插件在 GitHub 上的开源仓库地址,也支持其他 Git 仓库。',
|
||||
@@ -166,7 +164,7 @@ export const en = {
|
||||
title: 'Plugins',
|
||||
intro: 'Add and manage plugins',
|
||||
loading: 'Reading plugins…',
|
||||
error: 'Plugins are temporarily unavailable.',
|
||||
error: 'Could not read all plugins, possibly due to a network problem',
|
||||
unavailable: 'This deployment runs without a manageable profile, so plugins cannot be installed or switched here.',
|
||||
retry: 'Retry',
|
||||
refresh: 'Refresh',
|
||||
@@ -213,13 +211,11 @@ export const en = {
|
||||
installDescription: 'Enter the plugin\'s package name, GitHub repository address, or local directory path.',
|
||||
installSpecLabel: 'Package name or address',
|
||||
installSpecPlaceholder: 'for example @deepseek-ai/dsh-experimental-auto-review',
|
||||
installGuideToggle: 'Not sure what to enter?',
|
||||
installGuideToggle: 'Install guide and examples',
|
||||
installGuideHide: 'Hide the guide',
|
||||
installGuideIntro: 'Copy any one of these from the plugin\'s README or release page.',
|
||||
installGuideIdNote: 'The package name is the npm package name, usually dsh-xxx or @author/plugin; the display name is not one.',
|
||||
installGuideIdTitle: 'Package name',
|
||||
installGuideIdExample: '@deepseek-ai/dsh-experimental-auto-review',
|
||||
installGuideIdHint: 'The part after dsh plugin add or pnpm add in the README\'s install command.',
|
||||
installGuideIdHint: 'The plugin package name is the npm package name (like dsh-xxx or @author/plugin): the part after dsh plugin add or pnpm add in a community plugin\'s README install command.',
|
||||
installGuideGitTitle: 'GitHub repository address',
|
||||
installGuideGitExample: 'https://github.com/author/dsh-plugin',
|
||||
installGuideGitHint: 'The address of the plugin\'s open-source repository on GitHub; other Git hosts work too.',
|
||||
|
||||
@@ -117,6 +117,18 @@ describe('PluginManagerPage', () => {
|
||||
expect(actions.openInstall).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the read failure and its retry visible while a detail page is open', () => {
|
||||
const { actions, set } = renderTab({ packages: [pkg()] })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
set({ status: 'error' })
|
||||
expect(screen.getByRole('alert').querySelector('[data-state="error"]')).not.toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.retry }))
|
||||
expect(actions.refresh).toHaveBeenCalledTimes(1)
|
||||
// The detail keeps showing the kept data behind the alert.
|
||||
expect(document.querySelector('[data-plugin-detail]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('lists the installed bundles as cards, the installation\'s offered ones as official, and tags a problem the Host reports', () => {
|
||||
const { actions } = renderTab({
|
||||
packages: [
|
||||
@@ -271,6 +283,13 @@ describe('PluginManagerPage', () => {
|
||||
expect(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'Shell') })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('gives an official plugin without artwork of its own the default artwork', () => {
|
||||
renderTab({ packages: [] }, { items: [{ id: 'custom-tool', label: 'Custom' }] })
|
||||
const card = document.querySelector('[data-plugin-item="custom-tool"]') as HTMLElement
|
||||
const stops = [...card.querySelectorAll('stop')].map(stop => stop.getAttribute('stop-color'))
|
||||
expect(stops).toEqual(['#54ECE7', '#658EFF'])
|
||||
})
|
||||
|
||||
it('renders a bundle\'s own configuration on its page, and no configure control on a row without one', () => {
|
||||
renderTab({ packages: [pkg({ rows: [row()] })] }, { bundles: new Set(['dsh-better-sidebar']) }, bodies)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.openDetail.replace('{name}', 'better-sidebar') }))
|
||||
@@ -308,17 +327,17 @@ describe('PluginManagerPage', () => {
|
||||
|
||||
it('opens a guide under the field and drops an example into it', () => {
|
||||
const { actions } = renderTab({ install: { ...IDLE_INSTALL, open: true } })
|
||||
expect(screen.queryByText(en.installGuideIntro)).toBeNull()
|
||||
expect(screen.queryByText(en.installGuideIdHint)).toBeNull()
|
||||
const toggle = screen.getByRole('button', { name: en.installGuideToggle })
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(toggle)
|
||||
expect(screen.getByRole('button', { name: en.installGuideHide }).getAttribute('aria-expanded')).toBe('true')
|
||||
expect(screen.getByText(en.installGuideIdNote)).toBeTruthy()
|
||||
expect(screen.getByText(en.installGuideIdHint)).toBeTruthy()
|
||||
expect(screen.getByText(en.installGuideGitExample)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installGuideFillAria.replace('{example}', en.installGuideIdExample) }))
|
||||
expect(actions.editInstallSpec).toHaveBeenCalledExactlyOnceWith(en.installGuideIdExample)
|
||||
fireEvent.click(screen.getByRole('button', { name: en.installGuideHide }))
|
||||
expect(screen.queryByText(en.installGuideIntro)).toBeNull()
|
||||
expect(screen.queryByText(en.installGuideIdHint)).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a bundle\'s page with its facts and rows, and uninstalls from it', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
|
||||
README.md: 62f3346050ac106b9324f19666560347d5b37928
|
||||
README.zh.md: d30726da1881affb589709d9bf667c78875cf16d
|
||||
README.md: 09c56614799b30c2c6829620bbf14d7cc55cb0b0
|
||||
README.zh.md: ced758f9b43605365b43a62363bac0b0ca8a6df2
|
||||
@@ -57,7 +57,9 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
|
||||
| `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. |
|
||||
| `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. |
|
||||
| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | Fixed-palette 36×36 plugin artwork for the plugin-management surfaces; `Default` marks plugins without artwork of their own. Def ids are per-instance, so the same artwork repeats safely on one page. |
|
||||
| `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIconMedium` for link-leading glyphs and image previews for image content. |
|
||||
| `languageForPath`, `CODE_HIGHLIGHT_EXTENSIONS`, `useCodeHighlighter` | The filename grammar selection and lazy line-token highlighter shared by code preview and diff review. |
|
||||
|
||||
Three pairs are easy to confuse:
|
||||
|
||||
@@ -107,6 +109,8 @@ The package enforces one separation: presentational React atoms with zero Cordis
|
||||
| [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | Read and diff cards |
|
||||
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards |
|
||||
| [`src/icons/`](src/icons/) | Size-neutral `Regular` and `Medium` product glyph components |
|
||||
| [`src/code-highlighting.ts`](src/code-highlighting.ts) | Shared filename grammar selection and lazy line highlighting |
|
||||
| [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | Fixed-palette plugin artwork with per-instance SVG def ids |
|
||||
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | Floating-panel and overlay geometry hooks |
|
||||
|
||||
### Streaming markdown
|
||||
|
||||
@@ -57,7 +57,9 @@ kind: "package-library"
|
||||
| `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
|
||||
| `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 |
|
||||
| `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 |
|
||||
| `PluginArtworkTeam`/`Terminal`/`Loop`/`Subagent`/`Search`/`Default` | 插件管理界面使用的固定配色 36×36 插件插画;`Default` 用于没有自有插画的插件。def id 按实例生成,同一插画可在一页中安全重复。 |
|
||||
| `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIconMedium`,图片内容使用图片预览。 |
|
||||
| `languageForPath`、`CODE_HIGHLIGHT_EXTENSIONS`、`useCodeHighlighter` | 代码预览与 diff review 共用的文件名 grammar 选择和惰性逐行 token 高亮。 |
|
||||
|
||||
有三组容易混淆:
|
||||
|
||||
@@ -107,6 +109,8 @@ kind: "package-library"
|
||||
| [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | 读取与差异卡片 |
|
||||
| [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 |
|
||||
| [`src/icons/`](src/icons/) | 与尺寸无关的 `Regular` 和 `Medium` 产品图标组件 |
|
||||
| [`src/code-highlighting.ts`](src/code-highlighting.ts) | 共享的文件名 grammar 选择与惰性逐行高亮 |
|
||||
| [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | 固定配色插件插画,SVG def id 按实例生成 |
|
||||
| [`src/useAnchoredPosition.ts`](src/useAnchoredPosition.ts) / [`src/useAnchoredMaxHeight.ts`](src/useAnchoredMaxHeight.ts) | 浮动面板与浮层几何钩子 |
|
||||
|
||||
### 流式 Markdown
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/** Shared filename-to-grammar selection and lazy line highlighter for source views. */
|
||||
import { useCallback, useSyncExternalStore } from 'react'
|
||||
import {
|
||||
grammarLoadCount, highlightLines, subscribeGrammarLoaded, type HighlightSpan,
|
||||
} from './markdown/highlight.ts'
|
||||
|
||||
const LANGUAGE_EXTENSIONS: Readonly<Record<string, readonly string[]>> = {
|
||||
typescript: ['ts', 'tsx', 'mts', 'cts'],
|
||||
javascript: ['js', 'jsx', 'mjs', 'cjs'],
|
||||
shellscript: ['sh', 'bash', 'zsh'],
|
||||
json: ['json', 'jsonc', 'jsonl', 'ndjson'],
|
||||
python: ['py', 'pyw', 'pyi'],
|
||||
ruby: ['rb', 'rake', 'gemspec'],
|
||||
go: ['go'],
|
||||
rust: ['rs'],
|
||||
java: ['java'],
|
||||
c: ['c', 'h'],
|
||||
cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'],
|
||||
csharp: ['cs'],
|
||||
kotlin: ['kt', 'kts'],
|
||||
swift: ['swift'],
|
||||
php: ['php'],
|
||||
yaml: ['yaml', 'yml'],
|
||||
toml: ['toml'],
|
||||
ini: ['ini'],
|
||||
markdown: ['md', 'markdown'],
|
||||
mdx: ['mdx'],
|
||||
html: ['html', 'htm', 'xhtml'],
|
||||
css: ['css'],
|
||||
scss: ['scss'],
|
||||
less: ['less'],
|
||||
sql: ['sql'],
|
||||
xml: ['xml', 'xsd', 'xsl', 'xslt'],
|
||||
lua: ['lua'],
|
||||
}
|
||||
|
||||
const LANGUAGES = new Map(Object.entries(LANGUAGE_EXTENSIONS)
|
||||
.flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const)))
|
||||
|
||||
/** Recognized filename suffixes whose source can use the shared syntax highlighter. */
|
||||
export const CODE_HIGHLIGHT_EXTENSIONS: readonly string[] = [...LANGUAGES.keys()]
|
||||
|
||||
/**
|
||||
* Select the shared syntax highlighter's grammar from a filename.
|
||||
* @param path - decoded source filename or path.
|
||||
* @returns a supported grammar hint, or `undefined` for other suffixes.
|
||||
*/
|
||||
export function languageForPath(path: string): string | undefined {
|
||||
const extension = /\.([^./]+)$/u.exec(path.replaceAll('\\', '/'))?.[1]?.toLowerCase()
|
||||
return extension === undefined ? undefined : LANGUAGES.get(extension)
|
||||
}
|
||||
|
||||
/** Highlight one source fragment into one token list per line. */
|
||||
export type CodeHighlighter = (code: string) => HighlightSpan[][] | undefined
|
||||
|
||||
/**
|
||||
* Bind the shared lazy highlighter to one language and refresh after its grammar loads.
|
||||
* @param language - grammar hint selected from the source filename.
|
||||
* @returns a stable fragment highlighter; unknown and loading grammars return `undefined` for plain-text fallback.
|
||||
*/
|
||||
export function useCodeHighlighter(language: string | undefined): CodeHighlighter {
|
||||
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
|
||||
return useCallback(code => highlightLines(code, language), [language, loaded])
|
||||
}
|
||||
|
||||
export type { HighlightSpan }
|
||||
@@ -586,6 +586,23 @@ export const IconWarningOutlineMedium = (props: IconProps) => (
|
||||
<IconWarningOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
|
||||
const IconCheckCircleFillArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
|
||||
<path d="M28.1936 14.6936L19.8066 23.0806C19.2373 23.65 18.7159 24.1742 18.24 24.5571C17.7389 24.9602 17.1365 25.3359 16.3657 25.458C15.9581 25.5225 15.5428 25.5225 15.1353 25.458C14.3645 25.3359 13.7621 24.9602 13.261 24.5571C12.7851 24.1742 12.2637 23.65 11.6943 23.0806L7.80737 19.1936L10.1936 16.8074L14.0806 20.6943C14.7033 21.317 15.0763 21.6873 15.377 21.9292C15.6523 22.1507 15.7109 22.1325 15.6626 22.1248C15.7208 22.1339 15.7802 22.1339 15.8384 22.1248C15.7901 22.1325 15.8486 22.1507 16.124 21.9292C16.4247 21.6873 16.7977 21.317 17.4204 20.6943L25.8074 12.3074L28.1936 14.6936Z" fill="currentColor" />
|
||||
<path d="M32.8496 18.0005C32.8496 9.79906 26.2019 3.15137 18.0005 3.15137C9.79906 3.15137 3.15137 9.79906 3.15137 18.0005C3.15137 26.2019 9.79906 32.8496 18.0005 32.8496C26.2019 32.8496 32.8496 26.2019 32.8496 18.0005ZM35.7764 18.0005C35.7764 27.8173 27.8173 35.7764 18.0005 35.7764C8.18363 35.7764 0.224609 27.8173 0.224609 18.0005C0.224609 8.18363 8.18363 0.224609 18.0005 0.224609C27.8173 0.224609 35.7764 8.18363 35.7764 18.0005Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Regular IconCheckCircleFill artwork (circled check); its fill-only geometry is weight-independent. */
|
||||
export const IconCheckCircleFillRegular = (props: IconProps) => (
|
||||
<IconCheckCircleFillArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
|
||||
)
|
||||
|
||||
/** Medium IconCheckCircleFill artwork; it matches Regular because the geometry is fill-only. */
|
||||
export const IconCheckCircleFillMedium = (props: IconProps) => (
|
||||
<IconCheckCircleFillArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
|
||||
const IconWarningTriangleOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.87 2.6a1.33 1.33 0 0 1 2.26 0l5.34 9.33A1.33 1.33 0 0 1 13.33 14H2.67a1.33 1.33 0 0 1-1.14-2.07Z" stroke="currentColor" />
|
||||
@@ -1295,6 +1312,51 @@ export const IconWrapLinesOutlineMedium = (props: IconProps) => (
|
||||
<IconWrapLinesOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
|
||||
const IconNowrapFillArtwork = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M2 15H1V1H2V15Z" fill="currentColor" />
|
||||
<path d="M12.3535 7.64645C12.5487 7.84171 12.5487 8.15829 12.3535 8.35355L9.85352 10.8535L9.14648 10.1465L10.793 8.5H3.5V7.5H10.793L9.14648 5.85352L9.85352 5.14648L12.3535 7.64645Z" fill="currentColor" />
|
||||
<path d="M15 15H14V1H15V15Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Regular IconNowrapFill artwork; fill-only weights render identically. */
|
||||
export const IconNowrapFillRegular = (props: IconProps) => <IconNowrapFillArtwork {...props} />
|
||||
|
||||
/** Medium IconNowrapFill artwork; fill-only weights render identically. */
|
||||
export const IconNowrapFillMedium = (props: IconProps) => <IconNowrapFillArtwork {...props} />
|
||||
|
||||
const IconWrapFillArtwork = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M10.9999 8C10.9999 6.89543 10.1046 6 9 6H4.5V5H9C10.6568 5 11.9999 6.34315 11.9999 8C11.9999 9.65685 10.6568 11 9 11H6.20703L6.85351 11.6465L6.14648 12.3535L4.64652 10.8536C4.45126 10.6583 4.45126 10.3417 4.64652 10.1464L6.14648 8.64648L6.85351 9.35352L6.20703 10H9C10.1046 10 10.9999 9.10457 10.9999 8Z" fill="currentColor" />
|
||||
<path d="M2 15H1V1H2V15Z" fill="currentColor" />
|
||||
<path d="M15 15H14V1H15V15Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Regular IconWrapFill artwork; fill-only weights render identically. */
|
||||
export const IconWrapFillRegular = (props: IconProps) => <IconWrapFillArtwork {...props} />
|
||||
|
||||
/** Medium IconWrapFill artwork; fill-only weights render identically. */
|
||||
export const IconWrapFillMedium = (props: IconProps) => <IconWrapFillArtwork {...props} />
|
||||
|
||||
const IconCompareSplitOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
|
||||
<path d="M6 1.5H2.5C1.94772 1.5 1.5 1.94772 1.5 2.5V13.5C1.5 14.0523 1.94772 14.5 2.5 14.5H6C6.55228 14.5 7 14.0523 7 13.5V2.5C7 1.94772 6.55228 1.5 6 1.5Z" stroke="currentColor" />
|
||||
<path d="M13.5 1.5H10C9.44772 1.5 9 1.94772 9 2.5V13.5C9 14.0523 9.44772 14.5 10 14.5H13.5C14.0523 14.5 14.5 14.0523 14.5 13.5V2.5C14.5 1.94772 14.0523 1.5 13.5 1.5Z" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Regular one-pixel IconCompareSplitOutline artwork. */
|
||||
export const IconCompareSplitOutlineRegular = (props: IconProps) => (
|
||||
<IconCompareSplitOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
|
||||
)
|
||||
|
||||
/** Medium IconCompareSplitOutline artwork with a 1.3px stroke. */
|
||||
export const IconCompareSplitOutlineMedium = (props: IconProps) => (
|
||||
<IconCompareSplitOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
|
||||
)
|
||||
|
||||
const IconPlanOutlineArtwork = (props: WeightedIconProps) => (
|
||||
<IconListPenOutlineArtwork {...props} size={props.size ?? 14} />
|
||||
)
|
||||
|
||||
@@ -49,6 +49,8 @@ export type { TooltipSide } from './Tooltip.tsx'
|
||||
export { Toast } from './Toast.tsx'
|
||||
export { fileSizeText } from './file-size.ts'
|
||||
export { writeClipboard } from './clipboard.ts'
|
||||
export { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from './code-highlighting.ts'
|
||||
export type { CodeHighlighter, HighlightSpan } from './code-highlighting.ts'
|
||||
export { relativeTime } from './relative-time.ts'
|
||||
export { rankByName } from './rank-by-name.ts'
|
||||
export { isDarwinDesktop } from './darwin-desktop.ts'
|
||||
@@ -80,3 +82,6 @@ export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, Markdown
|
||||
export { extractMarkdownPlainText } from './markdown/plain-text.ts'
|
||||
export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts'
|
||||
export * from './icons/index.tsx'
|
||||
export {
|
||||
PluginArtworkTeam, PluginArtworkTerminal, PluginArtworkLoop, PluginArtworkSubagent, PluginArtworkSearch, PluginArtworkDefault,
|
||||
} from './plugin-artwork.tsx'
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Fixed-palette plugin artwork for the plugin management surfaces. Unlike the
|
||||
* ic_ds_* set these glyphs carry their own brand colors and gradients instead
|
||||
* of riding currentColor; all draw on a 36×36 viewBox and take {size, className}.
|
||||
*/
|
||||
import { useId } from 'react'
|
||||
import type { IconProps } from './icons/props.ts'
|
||||
|
||||
/**
|
||||
* A per-instance SVG def id prefix: the artwork repeats across cards and rows,
|
||||
* and duplicate document ids would make every `url(#…)` resolve to the first instance.
|
||||
*/
|
||||
const useArtworkId = (): string => `dsh_plugin_art_${useId().replaceAll(':', '')}`
|
||||
|
||||
/** Agent-team plugin artwork (four interlocking quadrants). */
|
||||
export const PluginArtworkTeam = ({ size = 36, className }: IconProps) => {
|
||||
const uid = useArtworkId()
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.3027 17.1077H9.03125V12.8372H13.3027V17.1077ZM17.5742 12.8372C17.574 15.1958 15.6623 17.1075 13.3037 17.1077V8.56567H17.5742V12.8372Z" fill={`url(#${uid}a)`} />
|
||||
<path d="M18.5229 12.8372V8.56567H22.7944V12.8372H27.0659V17.1077H22.7944C20.4357 17.1077 18.5232 15.1959 18.5229 12.8372Z" fill="#F2AF63" />
|
||||
<path d="M17.5742 26.6072H13.3037V18.0642C15.6625 18.0644 17.5742 19.9769 17.5742 22.3357V26.6072ZM13.3027 22.3357H9.03125V18.0642H13.3027V22.3357Z" fill="#7CB7FF" />
|
||||
<path d="M22.7944 26.6072H18.5229V22.3357C18.5229 19.9768 20.4355 18.0642 22.7944 18.0642H27.0659V22.3357H22.7944V26.6072Z" fill="#45D9E7" />
|
||||
<defs>
|
||||
<linearGradient id={`${uid}a`} x1="16.1247" y1="6.6613" x2="9.03125" y2="19.2206" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#7CB7FF" />
|
||||
<stop offset="1" stopColor="#145AF3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Terminal plugin artwork (prompt chevron and cursor bar). */
|
||||
export const PluginArtworkTerminal = ({ size = 36, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10 11L16.606 17.606C16.6841 17.6841 16.6841 17.8107 16.606 17.8888L10 24.4948" stroke="#145AF3" strokeWidth="3.5" />
|
||||
<path d="M20.1211 24.4946H26.8685" stroke="#145AF3" strokeWidth="3.5" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Agent-loop plugin artwork (four leaves circling a center). */
|
||||
export const PluginArtworkLoop = ({ size = 36, className }: IconProps) => {
|
||||
const uid = useArtworkId()
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.0486 28.4901C12.0858 28.4901 7.25195 23.6562 7.25195 17.6934C13.2148 17.6934 18.0486 22.5272 18.0486 28.4901Z" fill="#A797FC" />
|
||||
<path d="M18.0486 6.89667C12.0858 6.89667 7.25195 11.7305 7.25195 17.6934C13.2148 17.6934 18.0486 12.8595 18.0486 6.89667Z" fill={`url(#${uid}a)`} />
|
||||
<path d="M18.0485 28.4901C24.0114 28.4901 28.8452 23.6562 28.8452 17.6934C22.8824 17.6934 18.0485 22.5272 18.0485 28.4901Z" fill="#4561EE" />
|
||||
<path d="M18.0485 6.89667C24.0114 6.89667 28.8452 11.7305 28.8452 17.6934C22.8824 17.6934 18.0485 12.8595 18.0485 6.89667Z" fill="#658EFF" />
|
||||
<defs>
|
||||
<linearGradient id={`${uid}a`} x1="16.7911" y1="16.1655" x2="8.98192" y2="8.74289" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#A23AE7" />
|
||||
<stop offset="1" stopColor="#E2E2E2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Subagent plugin artwork (two stacked rounded squares); also marks every row inside a bundle. */
|
||||
export const PluginArtworkSubagent = ({ size = 36, className }: IconProps) => {
|
||||
const uid = useArtworkId()
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="14.9893" y="15.1877" width="12.2365" height="12.2365" rx="2" fill={`url(#${uid}a)`} fillOpacity="0.8" />
|
||||
<rect x="8.87109" y="9.0697" width="12.2365" height="12.2365" rx="2" fill={`url(#${uid}b)`} fillOpacity="0.8" />
|
||||
<defs>
|
||||
<linearGradient id={`${uid}a`} x1="21.1075" y1="15.1877" x2="21.1075" y2="27.4243" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#45E7A4" />
|
||||
<stop offset="1" stopColor="#05909D" />
|
||||
</linearGradient>
|
||||
<linearGradient id={`${uid}b`} x1="14.9894" y1="9.0697" x2="14.9894" y2="21.3063" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#69B9FF" />
|
||||
<stop offset="1" stopColor="#324DE2" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Web-search plugin artwork (conic-gradient ring and handle). SVG has no
|
||||
* native conic gradient, so the ring clips an HTML div painted with CSS
|
||||
* `conic-gradient` — the same emulation Figma exports; it renders inline in
|
||||
* the browser UI but would stay empty in an `<img>` or mask context.
|
||||
*/
|
||||
export const PluginArtworkSearch = ({ size = 36, className }: IconProps) => {
|
||||
const uid = useArtworkId()
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M26.5362 26.9865L22.3813 22.8317" stroke="#2F2295" strokeWidth="3" />
|
||||
<g clipPath={`url(#${uid}ring)`}>
|
||||
<g transform="matrix(0.0119394 -0.00173904 0.00173904 0.0119394 15.7661 16.2159)">
|
||||
<foreignObject x="-958.94" y="-958.94" width="1917.88" height="1917.88">
|
||||
<div
|
||||
style={{
|
||||
background: 'conic-gradient(from 90deg, rgb(65, 225, 172) 0deg, rgb(85, 71, 210) 62.0619deg, rgb(65, 225, 172) 360deg)',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</foreignObject>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id={`${uid}ring`}>
|
||||
<path d="M21.9717 16.2159H19.9717C19.9717 18.5386 18.0888 20.4215 15.7661 20.4215V22.4215V24.4215C20.2979 24.4215 23.9717 20.7478 23.9717 16.2159H21.9717ZM15.7661 22.4215V20.4215C13.4434 20.4215 11.5605 18.5386 11.5605 16.2159H9.56055H7.56055C7.56055 20.7478 11.2343 24.4215 15.7661 24.4215V22.4215ZM9.56055 16.2159H11.5605C11.5605 13.8933 13.4434 12.0104 15.7661 12.0104V10.0104V8.01038C11.2343 8.01038 7.56055 11.6841 7.56055 16.2159H9.56055ZM15.7661 10.0104V12.0104C18.0888 12.0104 19.9717 13.8933 19.9717 16.2159H21.9717H23.9717C23.9717 11.6841 20.2979 8.01038 15.7661 8.01038V10.0104Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Default plugin artwork for plugins without one of their own (connector blocks and a node). */
|
||||
export const PluginArtworkDefault = ({ size = 36, className }: IconProps) => {
|
||||
const uid = useArtworkId()
|
||||
return (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.6294 8.63696C26.2862 8.63705 27.6294 9.98016 27.6294 11.637V12.7825C27.6292 14.4391 26.2861 15.7824 24.6294 15.7825H23.4839C22.6519 15.7825 21.5454 16.3459 21.5454 17.1778V18.1573C21.5454 18.7757 22.216 19.1966 22.8345 19.1965H24.6294C26.2861 19.1965 27.6292 20.5398 27.6294 22.1965V23.9924C27.6292 25.6491 26.2861 26.9924 24.6294 26.9924H22.8345C21.1778 26.9924 19.8347 25.649 19.8345 23.9924V22.1965C19.8345 21.7148 19.4957 21.2327 19.014 21.2327H16.4792C15.7975 21.2327 15.3374 22.0061 15.3374 22.6877V23.8333C15.3373 25.49 13.9942 26.8333 12.3374 26.8333H11.1919C9.53509 26.8333 8.19198 25.49 8.19189 23.8333V22.6877C8.19189 21.0309 9.53504 19.6877 11.1919 19.6877H12.3374C13.1964 19.6877 14.3999 19.0959 14.3999 18.2369V16.0185C14.3999 14.9518 15.2646 14.0872 16.3312 14.0872H19.435C20.0596 14.0872 20.484 13.4071 20.4839 12.7825V11.637C20.4839 9.98011 21.827 8.63696 23.4839 8.63696H24.6294ZM13.4116 11.2468C13.4116 12.6882 12.2431 13.8567 10.8018 13.8567C9.36037 13.8567 8.19189 12.6882 8.19189 11.2468C8.19189 9.80544 9.36037 8.63696 10.8018 8.63696C12.2431 8.63696 13.4116 9.80544 13.4116 11.2468Z" fill={`url(#${uid}a)`} />
|
||||
<defs>
|
||||
<linearGradient id={`${uid}a`} x1="15.1481" y1="9.94188" x2="15.1481" y2="13.6375" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#54ECE7" />
|
||||
<stop offset="1" stopColor="#658EFF" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
+22
-6
@@ -1,8 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { supportsHighlighting } from '../../ui-primitives/src/markdown/highlight.ts'
|
||||
import { CODE_EXTENSIONS, languageForPath } from '../src/client/code/languages.ts'
|
||||
// @vitest-environment jsdom
|
||||
|
||||
describe('code preview languages', () => {
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { CODE_HIGHLIGHT_EXTENSIONS, languageForPath, useCodeHighlighter } from '../src/code-highlighting.ts'
|
||||
import { supportsHighlighting } from '../src/markdown/highlight.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('code highlighting', () => {
|
||||
it.each([
|
||||
['component.TSX', 'typescript'], ['module.mts', 'typescript'], ['module.cts', 'typescript'],
|
||||
['client.jsx', 'javascript'], ['module.mjs', 'javascript'], ['module.cjs', 'javascript'],
|
||||
@@ -20,8 +25,8 @@ describe('code preview languages', () => {
|
||||
})
|
||||
|
||||
it('keeps every registered suffix highlightable by the shared primitive', () => {
|
||||
expect(new Set(CODE_EXTENSIONS).size).toBe(CODE_EXTENSIONS.length)
|
||||
for (const extension of CODE_EXTENSIONS) {
|
||||
expect(new Set(CODE_HIGHLIGHT_EXTENSIONS).size).toBe(CODE_HIGHLIGHT_EXTENSIONS.length)
|
||||
for (const extension of CODE_HIGHLIGHT_EXTENSIONS) {
|
||||
expect(supportsHighlighting(languageForPath(`file.${extension}`)), extension).toBe(true)
|
||||
}
|
||||
})
|
||||
@@ -34,4 +39,15 @@ describe('code preview languages', () => {
|
||||
it.each(['README', 'dir.ts/README', 'notes.txt', 'main.ts.backup', 'file.unknown', 'file.constructor', 'file.__proto__'])('does not claim %s', (path) => {
|
||||
expect(languageForPath(path)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns plain text for an absent language and refreshes after a lazy grammar loads', async () => {
|
||||
const plain = renderHook(() => useCodeHighlighter(undefined))
|
||||
expect(plain.result.current('plain')).toBeUndefined()
|
||||
|
||||
const lazy = renderHook(() => useCodeHighlighter('lua'))
|
||||
const loading = lazy.result.current
|
||||
expect(loading('local answer = 42')).toBeUndefined()
|
||||
await waitFor(() => { expect(lazy.result.current).not.toBe(loading) })
|
||||
expect(lazy.result.current('local answer = 42')).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -17,15 +17,15 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('product icon set', () => {
|
||||
it('exports regular and medium variants for all 77 public glyphs', () => {
|
||||
expect(iconNames.length).toBe(154)
|
||||
it('exports regular and medium variants for all 81 public glyphs', () => {
|
||||
expect(iconNames.length).toBe(162)
|
||||
expect(iconNames.some(name => /\d+$/.test(name))).toBe(false)
|
||||
const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length))
|
||||
const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length))
|
||||
expect(medium.sort()).toEqual(regular.sort())
|
||||
expect(iconNames).toEqual(expect.arrayContaining([
|
||||
'IconPlanOutlineRegular', 'IconCompactOutlineRegular', 'IconShieldOutlineRegular', 'IconDeliverDocRegular',
|
||||
'IconWarningTriangleOutlineRegular',
|
||||
'IconWarningTriangleOutlineRegular', 'IconCompareSplitOutlineRegular',
|
||||
]))
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,21 @@ describe('Markdown file links', () => {
|
||||
expect(view.container.querySelector('a')).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['/workspace/chart.png', 'C:/work/chart.png'])('keeps %s accessible when inline images are unavailable', (path) => {
|
||||
const openFile = vi.fn()
|
||||
const view = render(
|
||||
<MarkdownDelegateProvider openFile={openFile}>
|
||||
<MarkdownText
|
||||
text={` [Open image](${path})`}
|
||||
pathImages={{ resolve: () => undefined }}
|
||||
/>
|
||||
</MarkdownDelegateProvider>,
|
||||
)
|
||||
expect(view.container.querySelector('img')).toBeNull()
|
||||
fireEvent.click(view.getByRole('button', { name: 'Open image' }))
|
||||
expect(openFile).toHaveBeenCalledExactlyOnceWith(path, undefined)
|
||||
})
|
||||
|
||||
it.each(['', ''])('names an empty label %s with the decoded path', (label) => {
|
||||
const view = render(
|
||||
<MarkdownDelegateProvider openFile={vi.fn()}>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PluginArtworkDefault, PluginArtworkLoop, PluginArtworkSearch, PluginArtworkSubagent, PluginArtworkTeam, PluginArtworkTerminal,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const artworks = {
|
||||
PluginArtworkTeam,
|
||||
PluginArtworkTerminal,
|
||||
PluginArtworkLoop,
|
||||
PluginArtworkSubagent,
|
||||
PluginArtworkSearch,
|
||||
PluginArtworkDefault,
|
||||
} as const
|
||||
|
||||
describe('plugin artwork', () => {
|
||||
it.each(Object.entries(artworks))('%s renders its 36-viewBox svg honoring size and className', (_name, Artwork) => {
|
||||
const { container } = render(<Artwork size={30} className="deco" />)
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg?.getAttribute('viewBox')).toBe('0 0 36 36')
|
||||
expect(svg?.getAttribute('width')).toBe('30')
|
||||
expect(svg?.getAttribute('height')).toBe('30')
|
||||
expect(svg?.getAttribute('class')).toBe('deco')
|
||||
})
|
||||
|
||||
it('clips the search ring through its per-instance clipPath around the conic-gradient div', () => {
|
||||
const { container } = render(<PluginArtworkSearch />)
|
||||
const clipId = container.querySelector('clipPath')?.id ?? ''
|
||||
expect(clipId).not.toBe('')
|
||||
expect(container.querySelector('g[clip-path]')?.getAttribute('clip-path')).toBe(`url(#${clipId})`)
|
||||
expect(container.querySelector('foreignObject div')).not.toBeNull()
|
||||
})
|
||||
|
||||
it.each(Object.entries(artworks))('%s renders at 36 by default and repeats without document-global ids', (_name, Artwork) => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Artwork />
|
||||
<Artwork />
|
||||
</>,
|
||||
)
|
||||
for (const svg of container.querySelectorAll('svg')) expect(svg.getAttribute('width')).toBe('36')
|
||||
const ids = [...container.querySelectorAll('[id]')].map(node => node.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
for (const reference of container.querySelectorAll('[fill^="url("], [clip-path^="url("]')) {
|
||||
const target = (reference.getAttribute('fill') ?? reference.getAttribute('clip-path') ?? '').slice('url(#'.length, -1)
|
||||
expect(ids).toContain(target)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -42,17 +42,17 @@ export const en: Record<PluginsSettingsLocaleKey, string> = {
|
||||
saveFailed: 'The deployment did not accept these values; they were left for you to correct.',
|
||||
invalidNumber: 'Enter a number, or leave blank to use the default.',
|
||||
bashTitle: 'Shell',
|
||||
bashDescription: 'Limits every command the agent runs.',
|
||||
bashDescription: 'Limit how long each command may run and how much it may output.',
|
||||
bashTimeoutMs: 'Command timeout (ms)',
|
||||
bashTimeoutMsHint: 'How long one command may run before it is terminated.',
|
||||
bashMaxOutputBytes: 'Output cap per stream (bytes)',
|
||||
bashMaxOutputBytesHint: 'Output beyond this spills to a temporary file rather than being lost.',
|
||||
agentLoopTitle: 'Agent loop',
|
||||
agentLoopDescription: 'How the agent dispatches tool calls.',
|
||||
agentLoopDescription: 'Control how the Agent dispatches tool calls.',
|
||||
agentLoopMaxParallel: 'Parallel tool calls',
|
||||
agentLoopMaxParallelHint: 'Upper bound on parallel-safe calls running at once within one step.',
|
||||
webSearchTitle: 'Web search',
|
||||
webSearchDescription: 'The DeepSeek search provider.',
|
||||
webSearchDescription: 'Set up the DeepSeek search provider.',
|
||||
webSearchApiKey: 'API key',
|
||||
webSearchApiKeyHint: 'Stored outside the settings file. Leave blank to keep the current key.',
|
||||
webSearchApiKeySet: 'A key is configured.',
|
||||
@@ -107,17 +107,17 @@ export const zh: Record<PluginsSettingsLocaleKey, string> = {
|
||||
saveFailed: '本部署没有接受这些值,已保留供你修改。',
|
||||
invalidNumber: '请填数字;留空表示使用默认值。',
|
||||
bashTitle: '终端',
|
||||
bashDescription: '限制 agent 运行的每一条命令。',
|
||||
bashDescription: '限制每条命令最多能跑多久、最多输出多少内容。',
|
||||
bashTimeoutMs: '命令超时(毫秒)',
|
||||
bashTimeoutMsHint: '单条命令允许运行多久,超时即终止。',
|
||||
bashMaxOutputBytes: '单流输出上限(字节)',
|
||||
bashMaxOutputBytesHint: '超出部分会转存到临时文件,而不是被丢弃。',
|
||||
agentLoopTitle: 'Agent 循环',
|
||||
agentLoopDescription: 'Agent 如何派发工具调用。',
|
||||
agentLoopDescription: '控制 Agent 派发工具调用的方式。',
|
||||
agentLoopMaxParallel: '并行工具调用数',
|
||||
agentLoopMaxParallelHint: '同一步内最多同时运行多少个可并行的调用。',
|
||||
webSearchTitle: '网页搜索',
|
||||
webSearchDescription: 'DeepSeek 搜索提供方。',
|
||||
webSearchDescription: '设置 DeepSeek 的搜索提供方。',
|
||||
webSearchApiKey: 'API Key',
|
||||
webSearchApiKeyHint: '不写入设置文件。留空表示保持当前密钥。',
|
||||
webSearchApiKeySet: '已配置密钥。',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-documentpreview/README.md
|
||||
README.md: d635e241ae15ccbdd4a8a9946360f080ec3263a1
|
||||
README.zh.md: e0ec8d6815b467bb8898d17a9ae0a69e15310ebd
|
||||
README.md: 4127b45bdfaea6c866efe457b7be0a3942cb5e86
|
||||
README.zh.md: 05eefb854ba3da54559f5bc24290cd795fac58d1
|
||||
@@ -110,7 +110,6 @@ No direct effect; what the user reads here never enters a model request.
|
||||
- **Sequential text and bounded complete files.** Deep source lines require the preceding pages; PDF, HTML, and images require a complete result within the Host's `maxFileBytes` cap.
|
||||
- **Byte-view scroll state is not restored.** PDF, HTML, and images can return to the top when their renderer remounts or reloads; images fit the pane's width and never scroll horizontally, and HTML iframe scrolling belongs to its opaque browsing context.
|
||||
- **Finite local HTML dependencies.** Only direct classic `.js` and stylesheet `.css` references are packed. Browser-resolved resources retain browser origin and network restrictions; no runtime file-read bridge is exposed to the iframe.
|
||||
- **Package-local wrap glyphs.** `IconWrapFill16` and `IconNowrapFill16` live in `src/client/icons.tsx` until the shared icon set carries them; their props already match the shared icon contract.
|
||||
- **Scroll writes are unthrottled.** Every scroll event records its offset in the store; the line blocks are memoized so the resulting re-render hands React the same elements back.
|
||||
- **A failed PDF chunk load requires a page reload.** React caches a rejected lazy import for the page lifetime; ordinary PDF open or render failures remain retryable inside the loaded body.
|
||||
|
||||
|
||||
@@ -110,7 +110,6 @@ Office 注册、加载、缓存和字体提示位于 `src/client/office/`。Offi
|
||||
- **文本顺序分页,完整文件受限。** 定位到较深处的源码行需要先加载此前各页;PDF、HTML 和图片必须取得 Host `maxFileBytes` 上限内的完整结果。
|
||||
- **字节视图不恢复滚动位置。** PDF、HTML 与图片的渲染器重新挂载或重新载入时可能回到顶部;图片适配面板宽度、不产生横向滚动,HTML iframe 的滚动属于其不透明浏览上下文。
|
||||
- **本地 HTML 依赖集合有限。** 只打包直接引用的经典 `.js` 脚本和 `.css` 样式表。浏览器解析的资源仍受浏览器源与网络规则限制;iframe 不获得运行时文件读取桥接。
|
||||
- **换行图标为包内自绘。** `IconWrapFill16` 与 `IconNowrapFill16` 住在 `src/client/icons.tsx`,直到共享图标集提供为止;它们的 props 已与共享图标契约一致。
|
||||
- **滚动写入未节流。** 每次滚动事件都把偏移记进 store;行块已 memo 化,于是由此引发的重渲染交还给 React 的是同一批元素。
|
||||
- **PDF chunk 加载失败后需要刷新页面。** React 会在页面生命周期内缓存被拒绝的 lazy import;已加载正文中的普通 PDF 打开或渲染失败仍可重试。
|
||||
|
||||
|
||||
@@ -15,11 +15,12 @@ import type { ReactNode, RefObject } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { FileTypeIcon, IconRefreshOutlineRegular, Menu, Tooltip, classifyFileType } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import {
|
||||
FileTypeIcon, IconNowrapFillRegular, IconRefreshOutlineRegular, IconWrapFillRegular, Menu, Tooltip, classifyFileType,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { TextInjected } from './face.ts'
|
||||
import { emptyFailureRecourse, failureLine } from './failure-line.ts'
|
||||
import { IconNowrapFill16, IconWrapFill16 } from './icons.tsx'
|
||||
import { LoadingIndicator } from './LoadingIndicator.tsx'
|
||||
import { hostFileOf } from './rpc.ts'
|
||||
import type { TextStore } from './store.ts'
|
||||
@@ -320,7 +321,7 @@ export function TextPreview({
|
||||
data-textpreview-tool="wrap"
|
||||
onClick={() => { actions.toggledWrap(tab.id) }}
|
||||
>
|
||||
{state.wrap ? <IconNowrapFill16 /> : <IconWrapFill16 />}
|
||||
{state.wrap ? <IconNowrapFillRegular /> : <IconWrapFillRegular />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/** Incrementally highlighted source; the document owner supplies the accumulated text and wrap preference. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { CodeBlock, languageForPath } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { parseFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { DocumentPreviewProps } from '../document/contract.ts'
|
||||
import { languageForPath } from './languages.ts'
|
||||
import type {} from './locales.ts'
|
||||
import css from './CodeBody.module.css'
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Code preview metadata and body registered through the public document extension points. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { CODE_HIGHLIGHT_EXTENSIONS } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type {} from '../index.ts'
|
||||
import { CodeBody } from './CodeBody.tsx'
|
||||
import { CODE_EXTENSIONS } from './languages.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
|
||||
const ID = '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/code'
|
||||
@@ -14,7 +14,7 @@ export function apply(ctx: Context): void {
|
||||
const t = ctx.locale.bind(NS)
|
||||
ctx.effect(() => ctx.documentPreviews.register({
|
||||
id: ID,
|
||||
extensions: CODE_EXTENSIONS,
|
||||
extensions: CODE_HIGHLIGHT_EXTENSIONS,
|
||||
priority: 'builtin',
|
||||
title: () => t('title'),
|
||||
loading: 'text-pages',
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/** File suffixes mapped to grammars already supported by the shared CodeBlock. */
|
||||
const languageExtensions: Readonly<Record<string, readonly string[]>> = {
|
||||
typescript: ['ts', 'tsx', 'mts', 'cts'],
|
||||
javascript: ['js', 'jsx', 'mjs', 'cjs'],
|
||||
shellscript: ['sh', 'bash', 'zsh'],
|
||||
json: ['json', 'jsonc', 'jsonl', 'ndjson'],
|
||||
python: ['py', 'pyw', 'pyi'],
|
||||
ruby: ['rb', 'rake', 'gemspec'],
|
||||
go: ['go'],
|
||||
rust: ['rs'],
|
||||
java: ['java'],
|
||||
c: ['c', 'h'],
|
||||
cpp: ['cc', 'cpp', 'cxx', 'hh', 'hpp', 'hxx'],
|
||||
csharp: ['cs'],
|
||||
kotlin: ['kt', 'kts'],
|
||||
swift: ['swift'],
|
||||
php: ['php'],
|
||||
yaml: ['yaml', 'yml'],
|
||||
toml: ['toml'],
|
||||
ini: ['ini'],
|
||||
markdown: ['md', 'markdown'],
|
||||
mdx: ['mdx'],
|
||||
html: ['html', 'htm', 'xhtml'],
|
||||
css: ['css'],
|
||||
scss: ['scss'],
|
||||
less: ['less'],
|
||||
sql: ['sql'],
|
||||
xml: ['xml', 'xsd', 'xsl', 'xslt'],
|
||||
lua: ['lua'],
|
||||
}
|
||||
|
||||
const languages = new Map(Object.entries(languageExtensions)
|
||||
.flatMap(([language, extensions]) => extensions.map(extension => [extension, language] as const)))
|
||||
|
||||
/** Recognized suffixes shared by renderer selection and language hints. */
|
||||
export const CODE_EXTENSIONS: readonly string[] = [...languages.keys()]
|
||||
|
||||
/**
|
||||
* Select the shared highlighter's grammar for a filename.
|
||||
* @param path - decoded source filename or path.
|
||||
* @returns a supported grammar hint, or undefined for other suffixes.
|
||||
*/
|
||||
export function languageForPath(path: string): string | undefined {
|
||||
const extension = /\.([^./]+)$/u.exec(path.replaceAll('\\', '/'))?.[1]?.toLowerCase()
|
||||
return extension === undefined ? undefined : languages.get(extension)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Glyphs this package draws that the shared icon set does not carry yet.
|
||||
* Same props contract as `@deepseek-ai/dsh-client-ui-primitives` icons, so a
|
||||
* shared replacement is a one-line import change.
|
||||
*
|
||||
* The wrap control swaps between the two glyphs below to preview the mode a
|
||||
* click switches to, so neither needs a pressed style.
|
||||
*/
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** Two margin bars, a straight arrow running to the right one: lines run past the edge. */
|
||||
export const IconNowrapFill16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M2 15H1V1H2V15Z" fill="currentColor" />
|
||||
<path d="M12.3535 7.64645C12.5487 7.84171 12.5487 8.15829 12.3535 8.35355L9.85352 10.8535L9.14648 10.1465L10.793 8.5H3.5V7.5H10.793L9.14648 5.85352L9.85352 5.14648L12.3535 7.64645Z" fill="currentColor" />
|
||||
<path d="M15 15H14V1H15V15Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** Two margin bars, an arrow sweeping around and back left: lines turn under themselves. */
|
||||
export const IconWrapFill16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
className={className}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M10.9999 8C10.9999 6.89543 10.1046 6 9 6H4.5V5H9C10.6568 5 11.9999 6.34315 11.9999 8C11.9999 9.65685 10.6568 11 9 11H6.20703L6.85351 11.6465L6.14648 12.3535L4.64652 10.8536C4.45126 10.6583 4.45126 10.3417 4.64652 10.1464L6.14648 8.64648L6.85351 9.35352L6.20703 10H9C10.1046 10 10.9999 9.10457 10.9999 8Z" fill="currentColor" />
|
||||
<path d="M2 15H1V1H2V15Z" fill="currentColor" />
|
||||
<path d="M15 15H14V1H15V15Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
@@ -4,9 +4,9 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { act } from '@testing-library/react'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { CODE_HIGHLIGHT_EXTENSIONS } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { apply } from '../src/client/code/index.ts'
|
||||
import { CodeBody } from '../src/client/code/CodeBody.tsx'
|
||||
import { CODE_EXTENSIONS } from '../src/client/code/languages.ts'
|
||||
import { DocumentPreviewRegistry } from '../src/client/document/registry.ts'
|
||||
import { documentTabInfoFactory } from '../src/client/document/contract.ts'
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('code renderer registration', () => {
|
||||
const h = await boot()
|
||||
await h.rt.mount(plugin)
|
||||
expect(h.previews.getSnapshot()).toHaveLength(1)
|
||||
expect(h.previews.getSnapshot()[0]).toMatchObject({ id: ID, extensions: CODE_EXTENSIONS, priority: 'builtin', loading: 'text-pages', wrap: true })
|
||||
expect(h.previews.getSnapshot()[0]).toMatchObject({ id: ID, extensions: CODE_HIGHLIGHT_EXTENSIONS, priority: 'builtin', loading: 'text-pages', wrap: true })
|
||||
expect(h.rt.slots.entries(SLOT)).toEqual([])
|
||||
await h.declare()
|
||||
const entries = h.rt.slots.entries(SLOT)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md
|
||||
README.md: 3a14aeb2d20e964a53200a6c55b9c0c347a22e65
|
||||
README.zh.md: 3da2c3dc27c3e7390a4c54ed6e21911fc8e5b01d
|
||||
README.md: 136829a6f5ea7f40e291f913651cd98adac9d916
|
||||
README.zh.md: 9a4633d7ba3b6c66527ec79f5850f42b1ec2a449
|
||||
@@ -31,7 +31,7 @@ This package registers the `dsh-resource://subagentchat/session/<child>?parent=<
|
||||
|
||||
### Browsing the tree
|
||||
|
||||
Rows display mode plus `running`/`inactive` activity and an optional log-backed title; running uses the shared ongoing loader, inactive uses the shared idle dot, and diagnostic rows use the shared error dot. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled.
|
||||
Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, other inactive children use the shared idle dot, and diagnostic rows use the shared error dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id; corrupt, unsupported, or unavailable rows remain readable but disabled.
|
||||
|
||||
### Continuing a conversation
|
||||
|
||||
@@ -55,9 +55,9 @@ The catalog and composer behavior are specified by the [Web subagent conversatio
|
||||
|
||||
The header lineage renderer reads `subagentsByParent` and session summaries through the standard `useSessions` hook. The compact tree remains direct-catalog authoritative: each healthy row's `hasChildren` hint determines disclosure before interaction, a catalog level reserves the disclosure column only when at least one healthy row is a branch, and expanding a branch immediately reserves one disabled loading row per known direct descendant before lazily replacing them with that child's authoritative catalog. Every visible branch is reported to the runtime so membership frames cause a debounced refresh only where the tree is being consumed.
|
||||
|
||||
### Duration and tokens
|
||||
### Duration, completion, and tokens
|
||||
|
||||
Token totals sum the four disjoint `tokenUsage` buckets. Duration sums completed `subagentTiming` turns, advances once per second only for an open turn on a running child, and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata.
|
||||
Token totals sum the four disjoint `tokenUsage` buckets. The `subagentTiming` projection sums completed-turn duration and records whether the latest closed turn ended with `completed`; opening another turn clears that completion until its own `turn/end`. Duration advances once per second only for an open turn on a running child and freezes after the child becomes inactive; an interrupted open turn is bounded by its same-cut `active.through`, never by newer session metadata.
|
||||
|
||||
### Composer election
|
||||
|
||||
@@ -104,7 +104,7 @@ Append-only. This package never edits earlier request tokens.
|
||||
|
||||
These limits define what the catalog can show and what `@` references mean; they are current package constraints.
|
||||
|
||||
- **The catalog has no durable outcome** — activity and timing do not distinguish completion, failure, or cancellation, and the UI exposes no Activation identity; stopping is limited to the composer's current-turn Stop for a running continuable child.
|
||||
- **Non-completed inactive outcomes remain grouped** — the catalog distinguishes a latest normal completion from other inactive states, but does not distinguish failure, cancellation, refusal, token exhaustion, or a child with no closed turn; the UI exposes no Activation identity, and stopping is limited to the composer's current-turn Stop for a running continuable child.
|
||||
- **`@` references remain display-title text** — duplicate or renamed labels are ambiguous, so they intentionally do not acquire continuation semantics.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-reference"
|
||||
|
||||
### 浏览目录
|
||||
|
||||
行显示 mode、`running`/`inactive` 活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,inactive 使用共享 idle 灰点,诊断行使用共享 error 红点。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。
|
||||
行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点,诊断行使用共享 error 红点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id;损坏、不受支持或不可用的行仍保持可读但禁用。
|
||||
|
||||
### 续接对话
|
||||
|
||||
@@ -55,9 +55,9 @@ kind: "package-reference"
|
||||
|
||||
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `subagentsByParent` 与会话摘要。紧凑树仍以直接目录为权威依据:每个健康行的 `hasChildren` 提示在交互前决定是否显示展开控件;每层目录仅在其中至少一个健康行是分支时才预留展开列;展开分支时会立即为每个已知直接后代预留一行禁用的加载行,随后再用该 child 的权威目录懒加载结果替换。每个可见分支都会上报给运行时,使成员帧只在树正被消费的位置触发去抖动刷新。
|
||||
|
||||
### 耗时与 token
|
||||
### 耗时、完成状态与 token
|
||||
|
||||
token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。耗时会累加已完成的 `subagentTiming` 轮次,仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。
|
||||
token 用量总计为四个互不重叠的 `tokenUsage` 桶之和。`subagentTiming` 投影会累加已结束轮次的耗时,并记录最近一个已结束轮次是否以 `completed` 结束;新轮次开始时会清除该完成状态,直到自身的 `turn/end` 到达。耗时仅在运行中 child 存在未结束轮次时每秒递增一次,并在 child 变为 inactive 后冻结;被中断的未结束轮次以其同一切面的 `active.through` 为上界,绝不使用更新的会话元数据。
|
||||
|
||||
### 编辑器选举
|
||||
|
||||
@@ -104,7 +104,7 @@ one-shot child 始终选用只读编辑器。可继续 child 仅在其确切 par
|
||||
|
||||
这些限制定义目录能显示什么、`@` 引用意味着什么;它们是当前包约束。
|
||||
|
||||
- **目录没有持久化结果**:活动状态与计时无法区分完成、失败或取消,且 UI 不公开 Activation 身份;停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。
|
||||
- **非完成的 inactive 结果仍合并显示**:目录能区分最近一次正常完成与其他 inactive 状态,但不区分失败、取消、拒绝、token 耗尽或尚无已结束轮次的 child;UI 不公开 Activation 身份,停止能力仅限编辑器上针对运行中可继续 child 的当前轮次 Stop。
|
||||
- **`@` 引用仍是显示标题文本**:重复或改名后的 label 会有歧义,因此它们刻意不获得继续执行语义。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -64,8 +64,10 @@
|
||||
.activitySlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.trigger:hover,
|
||||
@@ -158,9 +160,13 @@
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.row > :global([data-state]),
|
||||
.clickarea > :global([data-state]) {
|
||||
margin-top: 3px;
|
||||
.rowActivitySlot {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
|
||||
@@ -228,7 +228,9 @@ function CatalogLoadingRows({
|
||||
className={`${css.row} ${css.disabled} ${css.loadingRow}`}
|
||||
>
|
||||
<span className={css.disclosureSpace} />
|
||||
<StateDot state="ongoing" />
|
||||
<span className={css.rowActivitySlot}>
|
||||
<StateDot state="ongoing" />
|
||||
</span>
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{t('loading.label')}</span>
|
||||
</span>
|
||||
@@ -283,7 +285,9 @@ function CatalogRows({
|
||||
title={reason}
|
||||
>
|
||||
{reserveDisclosure && <span className={css.disclosureSpace} />}
|
||||
<StateDot state="error" />
|
||||
<span className={css.rowActivitySlot}>
|
||||
<StateDot state="error" />
|
||||
</span>
|
||||
<span className={css.content}>
|
||||
<span className={css.label}>{entry.id}</span>
|
||||
<span className={css.summary}>{reason}</span>
|
||||
@@ -302,7 +306,13 @@ function CatalogRows({
|
||||
const summary = summaries[entry.id]
|
||||
const label = entry.label ?? entry.id
|
||||
const mode = entry.mode === 'one-shot' ? t('mode.oneShot') : t('mode.continuable')
|
||||
const activity = entry.activity === 'running' ? t('activity.running') : t('activity.inactive')
|
||||
const completed = entry.activity === 'inactive'
|
||||
&& summary?.projectionValues?.subagentTiming?.lastTurnCompleted === true
|
||||
const activity = entry.activity === 'running'
|
||||
? t('activity.running')
|
||||
: completed
|
||||
? t('activity.completed')
|
||||
: t('activity.inactive')
|
||||
const secondary = [summary?.title, mode, activity]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
@@ -382,7 +392,9 @@ function CatalogRows({
|
||||
</button>
|
||||
)}
|
||||
<div className={css.clickarea}>
|
||||
<StateDot state={entry.activity === 'running' ? 'ongoing' : 'idle'} />
|
||||
<span className={css.rowActivitySlot}>
|
||||
<StateDot state={entry.activity === 'running' ? 'ongoing' : completed ? 'done' : 'idle'} />
|
||||
</span>
|
||||
<span className={css.content}>
|
||||
<span className={`${css.label} ${isCurrent ? css.currentLabel : ''}`}>{label}</span>
|
||||
<span className={css.summary}>{secondary}</span>
|
||||
|
||||
@@ -29,6 +29,7 @@ export const zh = {
|
||||
'mode.oneShot': '一次性',
|
||||
'mode.continuable': '可继续',
|
||||
'activity.running': '正在运行',
|
||||
'activity.completed': '已完成',
|
||||
'activity.inactive': '当前未运行',
|
||||
'branch.collapse': '收起 {label} 的下级子代理',
|
||||
'branch.expand': '展开 {label} 的下级子代理',
|
||||
@@ -72,6 +73,7 @@ export const en: Record<SubagentKey, string> = {
|
||||
'mode.oneShot': 'one-shot',
|
||||
'mode.continuable': 'continuable',
|
||||
'activity.running': 'running',
|
||||
'activity.completed': 'completed',
|
||||
'activity.inactive': 'not running',
|
||||
'branch.collapse': 'Collapse {label} descendants',
|
||||
'branch.expand': 'Expand {label} descendants',
|
||||
|
||||
@@ -352,7 +352,7 @@ describe('SubagentHeaderLineage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows durable token totals, ticks active duration by seconds, and freezes inactive rows', async () => {
|
||||
it('shows durable completion and token totals, ticks active duration, and freezes inactive rows', async () => {
|
||||
const now = 2_000_000_000_000
|
||||
const minute = 60_000
|
||||
const hour = 60 * minute
|
||||
@@ -410,6 +410,11 @@ describe('SubagentHeaderLineage', () => {
|
||||
projectionValues: {
|
||||
subagentTiming: {
|
||||
settledMs,
|
||||
...(id === 'finished'
|
||||
? { lastTurnCompleted: true }
|
||||
: id === 'interrupted'
|
||||
? { lastTurnCompleted: false }
|
||||
: {}),
|
||||
...(activeSince === undefined || activeThrough === undefined
|
||||
? {}
|
||||
: { active: { since: activeSince, through: activeThrough } }),
|
||||
@@ -432,8 +437,13 @@ describe('SubagentHeaderLineage', () => {
|
||||
const durationMetric = runningMetrics.getByText('1分10秒')
|
||||
expect(tokenMetric.parentElement).toBe(durationMetric.parentElement)
|
||||
expect(tokenMetric.nextElementSibling).toBe(durationMetric)
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy()
|
||||
const finishedRow = screen.getByRole('treeitem', { name: /finished.*已完成.*123 tok · 1小时02分03秒/ })
|
||||
expect(finishedRow.querySelector('[data-state="done"]')).not.toBeNull()
|
||||
const interruptedRow = screen.getByRole('treeitem', { name: /interrupted.*当前未运行.*123M tok · 6秒/ })
|
||||
expect(interruptedRow.querySelector('[data-state="idle"]')).not.toBeNull()
|
||||
for (const row of [runningRow, finishedRow, interruptedRow]) {
|
||||
expect(row.querySelector('[data-state]')?.parentElement?.className).toContain('rowActivitySlot')
|
||||
}
|
||||
expect(screen.getByRole('treeitem', { name: /days.*12天05小时06分07秒/ })).toBeTruthy()
|
||||
expect(screen.getByText('12天5小时').getAttribute('title'))
|
||||
.toBe('总活跃耗时:12天05小时06分07秒')
|
||||
@@ -445,8 +455,8 @@ describe('SubagentHeaderLineage', () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(screen.getByRole('treeitem', { name: /running.*4\.6K tok · 1分11秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*123 tok · 1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*123M tok · 6秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /finished.*已完成.*123 tok · 1小时02分03秒/ })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem', { name: /interrupted.*当前未运行.*123M tok · 6秒/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('lazily expands and collapses descendant catalogs with direct-parent navigation', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
|
||||
README.md: 92c29b2758817c639268d77d504fec3423d0f05b
|
||||
README.zh.md: f61007aca1e3631f26144a864b6911d5da20646e
|
||||
README.md: eabd5dc9eb8cad987da5eb7b6a747c3650ce69a4
|
||||
README.zh.md: 4358a00617376259a85b0e995aff3f95b295a2ff
|
||||
@@ -61,6 +61,8 @@ The value is intentionally best effort for cold Sessions. An identity-matching u
|
||||
|
||||
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
|
||||
|
||||
New Session tries to acquire the first eligible blank in catalog order; startup restoration tries the saved blank. If that writer is held, navigation creates a new Session without trying other blanks. Other acquisition failures abort the request and are currently reported only to the console. Released blanks retain their slash-command state when reused. Later navigation cancels a pending startup selection.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
|
||||
@@ -61,6 +61,8 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
|
||||
|
||||
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
|
||||
|
||||
新建会话尝试获取列表中第一个符合条件的空白会话;启动恢复尝试已保存的空白会话。若该写锁被占用,导航直接新建 Session,不再尝试其他空白会话。其他获取错误会中止请求,目前只报告到控制台。已释放的空白会话被复用时保留 slash 命令状态。后续导航会取消尚未完成的启动选择。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type { ClientRemote, DirectoryListing, RemoteFailure } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {
|
||||
ISessions,
|
||||
SessionCreateError,
|
||||
SessionReference,
|
||||
SessionTarget,
|
||||
SessionListState,
|
||||
@@ -11,7 +12,7 @@ import type {
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type {
|
||||
IWorkspaces, WorkspaceId, WorkspaceView,
|
||||
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
|
||||
} from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
@@ -143,19 +144,33 @@ class UiWorkspaceService extends Service implements UiWorkspace {
|
||||
const inflight = this.connecting.get(workspaceId)
|
||||
if (inflight !== undefined) return inflight
|
||||
|
||||
const attempt = this.reuseOrCreateBlank(workspace)
|
||||
.finally(() => { this.connecting.delete(workspaceId) })
|
||||
this.connecting.set(workspaceId, attempt)
|
||||
return attempt
|
||||
}
|
||||
|
||||
private reuseOrCreateBlank(workspace: WorkspaceView): Promise<SessionId> {
|
||||
const archived = this.workspaces.list.getSnapshot().archivedSessionIds
|
||||
const sessions = this.sessions.list.getSnapshot()
|
||||
for (const id of sessions.ids) {
|
||||
const summary = sessions.byId[id]
|
||||
if (summary !== undefined && summary.blank && summary.cwd === workspace.path
|
||||
&& workspace.sessionIds.includes(summary.id)
|
||||
&& !archived.includes(summary.id)) return summary.id
|
||||
if (summary === undefined || !summary.blank || summary.cwd !== workspace.path
|
||||
|| !workspace.sessionIds.includes(id) || archived.includes(id)) continue
|
||||
return this.reuseBlank(workspace.workspaceId, id)
|
||||
}
|
||||
return this.sessions.create({ workspaceId: workspace.workspaceId })
|
||||
}
|
||||
|
||||
const attempt = this.sessions.create({ workspaceId })
|
||||
.finally(() => { this.connecting.delete(workspaceId) })
|
||||
this.connecting.set(workspaceId, attempt)
|
||||
return attempt
|
||||
private async reuseBlank(workspaceId: WorkspaceId, sessionId: SessionId): Promise<SessionId> {
|
||||
try {
|
||||
return await this.sessions.create({ workspaceId, sessionId })
|
||||
} catch (error: unknown) {
|
||||
// Client plugin bundles do not share error-class identity.
|
||||
if (!(error instanceof Error) || error.name !== 'SessionCreateError'
|
||||
|| (error as SessionCreateError).rpcError.code !== 'session/writer-held') throw error
|
||||
return this.sessions.create({ workspaceId })
|
||||
}
|
||||
}
|
||||
|
||||
openSession(target: SessionTarget): void {
|
||||
@@ -235,44 +250,17 @@ class UiWorkspaceService extends Service implements UiWorkspace {
|
||||
initial = 'done'
|
||||
return
|
||||
}
|
||||
const saved = this.selection.getSnapshot()
|
||||
const savedTarget = saved.subagentAddress
|
||||
?? (saved.sessionId !== undefined && sessions.byId[saved.sessionId] !== undefined
|
||||
? saved.sessionId
|
||||
: undefined)
|
||||
if (savedTarget !== undefined) {
|
||||
initial = 'connecting'
|
||||
try {
|
||||
if (saved.subagentAddress !== undefined) {
|
||||
void this.sessions.refreshSubagents(saved.subagentAddress.parentSessionId)
|
||||
}
|
||||
this.openSession(savedTarget)
|
||||
initial = 'done'
|
||||
} catch (reason: unknown) {
|
||||
initial = 'waiting'
|
||||
console.warn('initial Session restoration failed:', reason)
|
||||
}
|
||||
return
|
||||
}
|
||||
const target = recentWorkspace(workspace.items, sessions.byId)
|
||||
if (target === undefined) {
|
||||
initial = 'done'
|
||||
return
|
||||
}
|
||||
initial = 'connecting'
|
||||
void this.connectWorkspace(target).then(
|
||||
(sessionId) => {
|
||||
if (this.mainReference === undefined) this.openSession(sessionId)
|
||||
},
|
||||
).then(
|
||||
void this.restoreSelection(workspace, sessions).then(
|
||||
() => { initial = 'done' },
|
||||
(reason: unknown) => {
|
||||
if (this.lifetime.signal.aborted) return
|
||||
initial = 'waiting'
|
||||
console.warn('initial workspace selection failed:', reason)
|
||||
console.warn('initial Session restoration failed:', reason)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const disposeWorkspaces = this.workspaces.list.subscribe(reconcile)
|
||||
const disposeSessions = this.sessions.list.subscribe(reconcile)
|
||||
reconcile()
|
||||
@@ -283,6 +271,33 @@ class UiWorkspaceService extends Service implements UiWorkspace {
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreSelection(workspaces: WorkspaceSnapshot, sessions: SessionListState): Promise<void> {
|
||||
const saved = this.selection.getSnapshot()
|
||||
if (saved.subagentAddress !== undefined) {
|
||||
void this.sessions.refreshSubagents(saved.subagentAddress.parentSessionId)
|
||||
this.openSession(saved.subagentAddress)
|
||||
return
|
||||
}
|
||||
const summary = saved.sessionId === undefined ? undefined : sessions.byId[saved.sessionId]
|
||||
const workspace = summary === undefined ? undefined
|
||||
: workspaces.items.find(item => item.sessionIds.includes(summary.id))
|
||||
if (summary !== undefined && (!summary.blank || workspace === undefined)) {
|
||||
this.openSession(summary.id)
|
||||
return
|
||||
}
|
||||
const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
|
||||
let sessionId: SessionId | undefined
|
||||
if (summary !== undefined && workspace !== undefined && summary.cwd === workspace.path
|
||||
&& !workspaces.archivedSessionIds.includes(summary.id)) {
|
||||
sessionId = await this.reuseBlank(workspace.workspaceId, summary.id)
|
||||
}
|
||||
const target = workspace?.workspaceId ?? recentWorkspace(workspaces.items, sessions.byId)
|
||||
if (sessionId === undefined && target !== undefined) sessionId = await this.connectWorkspace(target)
|
||||
if (sessionId !== undefined && !navigation.aborted) {
|
||||
this.replaceMain(sessionId, navigation)
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns true when an archived current selection was cleared. */
|
||||
private clearArchivedCurrent(): boolean {
|
||||
const current = this.mainReference?.sessionId
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ISessions, SessionListState, SessionReference, SessionSummary,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import { SessionCreateError } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
|
||||
import type {
|
||||
IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView,
|
||||
@@ -389,11 +390,12 @@ describe('UiWorkspaceService', () => {
|
||||
summary('stray', { blank: true, cwd: '/w/a' }),
|
||||
summary('blank', { blank: true, cwd: '/w/a' }),
|
||||
summary('archived', { blank: true, cwd: '/w/b' }),
|
||||
]),
|
||||
], 'pending'),
|
||||
workspaces: workspaceState([workspace('a', [sid('blank')]), workspace('b', [sid('archived')])], [sid('archived')]),
|
||||
})
|
||||
await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('blank'))
|
||||
expect(b.sessions.create).not.toHaveBeenCalled()
|
||||
expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a'), sessionId: sid('blank') })
|
||||
b.sessions.create.mockClear()
|
||||
b.sessions.retain.mockClear()
|
||||
const created = Promise.withResolvers<SessionId>()
|
||||
b.sessions.create.mockReturnValue(created.promise)
|
||||
@@ -485,6 +487,174 @@ describe('UiWorkspaceService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses catalog order for Workspace connects without reading the saved selection', async () => {
|
||||
persistSelection({ sessionId: sid('saved') })
|
||||
const b = bench({
|
||||
sessions: sessionState([
|
||||
summary('first', { blank: true, cwd: '/w/a' }),
|
||||
summary('saved', { blank: true, cwd: '/w/a' }),
|
||||
], 'pending'),
|
||||
workspaces: workspaceState([workspace('a', [sid('first'), sid('saved')])]),
|
||||
})
|
||||
await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('first'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ archived: true, cwd: '/w/a' },
|
||||
{ archived: false, cwd: '/other' },
|
||||
])('does not reclaim an ineligible saved blank: %j', async ({ archived, cwd }) => {
|
||||
persistSelection({ sessionId: sid('saved') })
|
||||
const b = bench({
|
||||
sessions: sessionState([summary('saved', { blank: true, cwd })]),
|
||||
workspaces: workspaceState([workspace('a', [sid('saved')])], archived ? [sid('saved')] : []),
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-a'), { source: 'mainView' })
|
||||
})
|
||||
expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a') })
|
||||
})
|
||||
|
||||
it('restores a saved ungrouped blank directly', () => {
|
||||
persistSelection({ sessionId: sid('saved') })
|
||||
const b = bench({
|
||||
sessions: sessionState([summary('saved', { blank: true })]),
|
||||
workspaces: workspaceState(),
|
||||
})
|
||||
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('saved'), { source: 'mainView' })
|
||||
expect(b.sessions.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reclaims the saved blank before opening history, even when another blank is listed first', async () => {
|
||||
persistSelection({ sessionId: sid('saved') })
|
||||
const acquired = Promise.withResolvers<SessionId>()
|
||||
const b = bench({
|
||||
sessions: sessionState([
|
||||
summary('other', { blank: true, cwd: '/w/a' }),
|
||||
summary('saved', { blank: true, cwd: '/w/a' }),
|
||||
]),
|
||||
workspaces: workspaceState([workspace('a', [sid('other'), sid('saved')])]),
|
||||
configureSessions: (sessions) => { sessions.create.mockReturnValue(acquired.promise) },
|
||||
})
|
||||
expect(b.sessions.create).toHaveBeenCalledExactlyOnceWith({ workspaceId: wid('a'), sessionId: sid('saved') })
|
||||
expect(b.sessions.retain).not.toHaveBeenCalled()
|
||||
acquired.resolve(sid('saved'))
|
||||
await vi.waitFor(() => {
|
||||
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('saved'), { source: 'mainView' })
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['saved', 'workspace'])('creates after the selected %s blank is held without trying another blank', async (source) => {
|
||||
if (source === 'saved') persistSelection({ sessionId: sid('held') })
|
||||
const b = bench({
|
||||
sessions: sessionState([
|
||||
summary('held', { blank: true, cwd: '/w/a' }),
|
||||
summary('free', { blank: true, cwd: '/w/a' }),
|
||||
], source === 'saved' ? 'ready' : 'pending'),
|
||||
workspaces: workspaceState([workspace('a', [sid('held'), sid('free')])]),
|
||||
configureSessions: (sessions) => {
|
||||
sessions.create.mockRejectedValueOnce(new SessionCreateError(
|
||||
new RemoteError('session/writer-held', 'held', { sessionId: sid('held') }), sid('held'),
|
||||
))
|
||||
},
|
||||
})
|
||||
if (source === 'saved') {
|
||||
await vi.waitFor(() => {
|
||||
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('created-a'), { source: 'mainView' })
|
||||
})
|
||||
} else {
|
||||
await expect(b.uiWorkspace.connectWorkspace(wid('a'))).resolves.toBe(sid('created-a'))
|
||||
}
|
||||
expect(b.sessions.create.mock.calls).toEqual([
|
||||
[{ workspaceId: wid('a'), sessionId: sid('held') }],
|
||||
[{ workspaceId: wid('a') }],
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['workspace', 'panel'])('does not let saved blank restoration replace a later %s navigation', async (target) => {
|
||||
persistSelection({ sessionId: sid('saved') })
|
||||
const acquired = Promise.withResolvers<SessionId>()
|
||||
const created = Promise.withResolvers<SessionId>()
|
||||
const acquisitionDone = vi.fn()
|
||||
const b = bench({
|
||||
sessions: sessionState([summary('saved', { blank: true, cwd: '/w/a' })]),
|
||||
workspaces: workspaceState([workspace('a', [sid('saved')]), workspace('b')]),
|
||||
configureSessions: (sessions) => {
|
||||
sessions.create.mockImplementation(options => options?.sessionId === sid('saved')
|
||||
? acquired.promise.then((id) => { acquisitionDone(); return id }) : created.promise)
|
||||
},
|
||||
})
|
||||
const opening = target === 'workspace' ? b.uiWorkspace.openWorkspace(wid('b')) : undefined
|
||||
if (target === 'panel') b.layout.selectPanel('other-panel' as MainPanelId)
|
||||
acquired.resolve(sid('saved'))
|
||||
await vi.waitFor(() => { expect(acquisitionDone).toHaveBeenCalledOnce() })
|
||||
expect(b.sessions.retain).not.toHaveBeenCalled()
|
||||
created.resolve(sid('created-b'))
|
||||
await opening
|
||||
expect(b.sessions.retain.mock.calls.map(([id]) => id)).toEqual(target === 'workspace' ? [sid('created-b')] : [])
|
||||
})
|
||||
|
||||
it('coalesces overlapping blank acquisitions before starting history', async () => {
|
||||
const acquired = Promise.withResolvers<SessionId>()
|
||||
const b = bench({
|
||||
sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'),
|
||||
workspaces: workspaceState([workspace('a', [sid('blank')])]),
|
||||
configureSessions: (sessions) => { sessions.create.mockReturnValue(acquired.promise) },
|
||||
})
|
||||
const first = b.uiWorkspace.connectWorkspace(wid('a'))
|
||||
const second = b.uiWorkspace.connectWorkspace(wid('a'))
|
||||
expect(b.sessions.create).toHaveBeenCalledOnce()
|
||||
acquired.resolve(sid('blank'))
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([sid('blank'), sid('blank')])
|
||||
})
|
||||
|
||||
it.each([
|
||||
new Error('filesystem denied'),
|
||||
'failed transport',
|
||||
new SessionCreateError(new RemoteError('gateway/internal', 'read failed', {}), sid('blank')),
|
||||
])('does not replace a blank after a non-contention failure: %s', async (error) => {
|
||||
const b = bench({
|
||||
sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })], 'pending'),
|
||||
workspaces: workspaceState([workspace('a', [sid('blank')])]),
|
||||
configureSessions: (sessions) => { sessions.create.mockRejectedValue(error) },
|
||||
})
|
||||
await expect(b.uiWorkspace.connectWorkspace(wid('a'))).rejects.toBe(error)
|
||||
expect(b.sessions.create).toHaveBeenCalledOnce()
|
||||
expect(b.sessions.retain).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for a new catalog before retrying failed startup blank acquisition', async () => {
|
||||
persistSelection({ sessionId: sid('blank') })
|
||||
const failure = new Error('read failed')
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const b = bench({
|
||||
sessions: sessionState([summary('blank', { blank: true, cwd: '/w/a' })]),
|
||||
workspaces: workspaceState([workspace('a', [sid('blank')])]),
|
||||
configureSessions: (sessions) => { sessions.create.mockRejectedValueOnce(failure) },
|
||||
})
|
||||
await vi.waitFor(() => {
|
||||
expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure)
|
||||
})
|
||||
expect(b.sessions.retain).not.toHaveBeenCalled()
|
||||
b.sessions.list.set(b.sessions.list.getSnapshot())
|
||||
await vi.waitFor(() => {
|
||||
expect(b.sessions.retain).toHaveBeenCalledExactlyOnceWith(sid('blank'), { source: 'mainView' })
|
||||
})
|
||||
})
|
||||
|
||||
it('settles empty startup without creating a Session on later catalog updates', () => {
|
||||
const b = bench({ sessions: sessionState(), workspaces: workspaceState() })
|
||||
b.workspaces.list.set(workspaceState([workspace('a')]))
|
||||
expect(b.sessions.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clears a selected blank archived by a catalog update without reconnecting', () => {
|
||||
const b = bench()
|
||||
b.uiWorkspace.openSession(sid('blank'))
|
||||
b.workspaces.list.set(workspaceState([workspace('a', [sid('blank')])], [sid('blank')]))
|
||||
expect(b.sessions.retained[0]!.release).toHaveBeenCalledOnce()
|
||||
expect(b.sessions.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not let a pending startup Workspace replace a manual Session selection', async () => {
|
||||
const created = Promise.withResolvers<SessionId>()
|
||||
const b = bench({
|
||||
@@ -539,7 +709,7 @@ describe('UiWorkspaceService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('reports and retries a failed persisted Session restoration', () => {
|
||||
it('reports and retries a failed persisted Session restoration', async () => {
|
||||
const failure = new Error('restore failed')
|
||||
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const sessions = sessionState([summary('saved')])
|
||||
@@ -552,7 +722,9 @@ describe('UiWorkspaceService', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure)
|
||||
await vi.waitFor(() => {
|
||||
expect(warning).toHaveBeenCalledWith('initial Session restoration failed:', failure)
|
||||
})
|
||||
b.sessions.list.set(sessions)
|
||||
|
||||
expect(b.sessions.retain).toHaveBeenCalledTimes(2)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/deliverables/tool-present/README.md
|
||||
README.md: 93003711545f678336a02c9bf8589176f844af65
|
||||
README.zh.md: 161efcc3931fbf57175064ccf1179b195320d4b9
|
||||
README.md: a7eb9e7cf9060472258666de186a7ecefe7aa407
|
||||
README.zh.md: 5ba4ab5f100517708d80e4860d9e5eed0f43a801
|
||||
@@ -73,7 +73,7 @@ The pure `./types` entry declares `PresentedFile` and the Session event without
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The [present schema](../../../docs/tool-catalog.md#present) asks for existing accessible files: “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.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
|
||||
The [present schema](../../../docs/tool-catalog.md#present) asks for existing accessible files: “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.” Results report `Presented <path>` for each file; the program result and durable event contain paths and optional descriptions.
|
||||
|
||||
#### Token effect
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
[present schema](../../../docs/tool-catalog.zh.md#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.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
|
||||
[present schema](../../../docs/tool-catalog.zh.md#present)要求已有且可访问的文件:“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.” 每个文件的结果为 `Presented <path>`;程序结果和持久事件包含路径及可选说明。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
|
||||
@@ -37,10 +37,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const pending = new WeakMap<ToolExecution, { session: Session; turn: number; files: PresentedFile[] }>()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'present',
|
||||
description: '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.',
|
||||
description: '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.',
|
||||
parameters: {
|
||||
files: {
|
||||
type: 'array', required: true,
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user