Merge branch 'stack/agent-profiles-2-configs' into stack/agent-profiles-3-wire
# Conflicts: # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
This commit is contained in:
@@ -474,11 +474,14 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
push({ type: 'step/end', data: { turn, step: 0 } })
|
||||
push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
|
||||
}
|
||||
// Turn 67: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip.
|
||||
// Turn 71: todo_write sample — the TodoRow toolview in the flow plus the
|
||||
// todo/write snapshot event feeding the TodoPanel plan strip. Two items are
|
||||
// in_progress: this fixture chooses the parallel policy, so both surfaces
|
||||
// must render a parallel plan rather than the first active item alone.
|
||||
const fixtureTodos = [
|
||||
{ content: '梳理需求', status: 'completed' },
|
||||
{ content: '实现 fixture 样本', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '浏览器验收', status: 'pending' },
|
||||
]
|
||||
// Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
|
||||
@@ -531,7 +534,7 @@ function buildAlphaLog(): SessionEvent[] {
|
||||
toolTurn(70, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.')
|
||||
|
||||
const todoArgs = JSON.stringify({ todos: fixtureTodos })
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
|
||||
toolTurn(71, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.')
|
||||
// The real tool appends the snapshot mid-execution — between tool/call and
|
||||
// tool/result — so the fixture reproduces that exact ordering (the last
|
||||
// toolTurn events run ... tool/call, tool/result, step/end, turn/end).
|
||||
|
||||
@@ -242,6 +242,10 @@ describe('createFixtureApi', () => {
|
||||
const times = events.slice(todoAt - 1, todoAt + 2).map(e => e.time)
|
||||
expect(times[0]).toBeLessThanOrEqual(times[1] ?? 0)
|
||||
expect(times[1]).toBeLessThanOrEqual(times[2] ?? 0)
|
||||
// The sample is a parallel plan: this fixture chooses the parallel policy,
|
||||
// so the surfaces fed from here face more than one active item.
|
||||
const snapshot = events[todoAt] as { data: { todos: { status: string }[] } }
|
||||
expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('create adds a session and pushes host/session-added to open host streams', async () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: bbd115eac0eb914914dc11e504639633c801abdd
|
||||
README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc
|
||||
README.md: a75f25d8669cd688795842a655106e0e27bb7173
|
||||
README.zh.md: f0d744c31020730857d210d75749b907dbffca08
|
||||
|
||||
@@ -34,11 +34,11 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl
|
||||
|
||||
Tool rows use the keyed, session-scoped `'conversation.chat.toolview'` slot; its render site dispatches via `entryKey: toolName` with `GenericToolCard` as the call-site fallback. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`), and `ToolRowProps` composes it with the session standard kit. A registrant is a plain plugin with only the slot service edge: `ctx.slots.inject('conversation.chat.toolview', () => ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row))`. The declaration is the activation and reload dependency; `ConversationService` is required only by registrations that call its actions. Trajectory and waterfall toolview slots share this shape and use their own render sites; RendersCheck rejects a declaration nobody renders.
|
||||
|
||||
The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both using slot declaration injection without a `ConversationService` edge. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> completed · <active item>` plus a `+<n>` count of the other active ones, parsed from its args through `toolviews/plan-summary.ts` `planSummary`, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). When the deployment permits parallel work, several items may be `in_progress` at once, so `planSummary` names the first and counts the rest, and deliberately returns the two unjoined: the row ellipsizes its summary text, so a count concatenated onto the end of the task name would be the first thing a narrow row clips. The row hands the count to `ToolRow`'s `summarySuffix`, the shared row's non-shrinking slot beside that ellipsized text (an error row drops it, since its collapsed summary is the failure line). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 0` — before Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus its own `·`-joined per-status counts (localized, `1 completed · 2 in progress · 1 pending`, zero-count segments omitted; status glyphs are the figma check / progress / dashed-pending set), so it reports the parallel count without needing a name to truncate. The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `"<n> 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible ordinary-session row remains a single-line preview with its exact-occurrence edit, delete, and strict-steer actions; addressed subagents retain the rows as a read-only projection because their continuation transport does not expose queue mutation. If strict steer loses to a closed window, the original occurrence remains queued for normal delivery; if the driver already claimed it, normal delivery is already underway. Neither converged race displays a failure, while transport and unknown failures do.
|
||||
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork stays absent because the message has not entered a durable turn. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the branch control from the durable node, enables branch only when that node is the completed turn's transcript tail, and survives reconnect from the same authority.
|
||||
The Host's placement-aware `session/queue` snapshot also carries pending steering. QueueDock filters it out, while ChatView projects it as a user-style bubble with Copy at the conversation tail; non-user next-step items (injected context) carry the `context` placement instead and render nowhere until claimed. Fork is absent here as on every user-style bubble. The Host delays steering retirement until the durable `user/message` carrying the steering has entered the mux stream. On that accepted live event, the client runtime retires the first matching current steering occurrence before publishing the snapshot; historical events cannot hide later occurrences that reuse the same `MessageId`. The bubble therefore hands off without a gap or duplicate, immediately restores Copy and the clock from the durable node — a steering bubble, like a user bubble, carries no branch action ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)) — and survives reconnect from the same authority.
|
||||
|
||||
Keyboard message submission resolves delivery from the addressed session's running state and steering capability. While idle, Enter and Cmd/Ctrl+Enter both perform an ordinary Queue send. While a primary session is running, the browser-persisted General Settings preference assigns plain Enter to `Queue` (the default) or `Steer`, and Cmd/Ctrl+Enter performs the other behavior; Shift+Enter remains a newline. Addressed subagents keep both gestures on their Queue-only continuation transport even while running. The preference affects only the steer-capable busy-state gesture pair, and the send button and non-keyboard submit actions remain Queue. Composer Steer uses the existing best-effort `session.prompt(mode: 'steer')` contract: if the current next-step window closes before acceptance, AgentLoop admits the message as the next waking Queue turn without surfacing a failure or losing the draft transaction.
|
||||
|
||||
@@ -63,8 +63,8 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Compaction markers show no scale** — the row does not yet report how many messages or which range the checkpoint replaced.
|
||||
- **Stats-line durations and speeds cover the in-window flow only** — LLM and tool wall times plus the TTFT and throughput averages fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted.
|
||||
- **The details panel has no entry point** — `ChatViewInjected.openDetails` is implemented but uncalled, so the raw selected-call display is unreachable in the assembled application. There is no Input/Output/Metadata switch, Prev/Next stepping, or trajectory deep link.
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn only; mid-turn narration and Think-only nodes stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
- **Sent user messages cannot be edited** — user bubbles retain clock, copy, and branch; branch stays disabled unless a completed turn's transcript ends at that user message. Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
|
||||
- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / clock / branch) ships under the last content-text assistant of each turn that has ended; mid-turn narration, Think-only nodes, and every node of a turn still producing steps stay chrome-free. Branch stays disabled unless that message is also the last transcript node of a completed turn; when enabled, it forks through that turn, increments the inherited title on the client, and opens the child. A fork or rename failure leaves the source selected ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md)).
|
||||
- **Sent user messages cannot be edited** — user bubbles retain clock and copy; branch lives only under assistant answers ([decision](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md)). Editing returns with the capability behind it: a client mutation over a settled user message, plus the host behavior for the turn that already consumed it ([decision](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md)).
|
||||
- **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
|
||||
- **The approval panel has no durable grant control** — it supports allow-once and reject only.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
|
||||
@@ -34,11 +34,11 @@ Think 行默认保持折叠,并在不展开思维链的情况下暴露实时
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。运行时 manager 会将所有审批或问题等待通过 `SessionSummary.pendingInteraction` 投影出来,未实例化的 Session 也不例外;`ui-workspace` 负责其侧边栏呈现。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都使用 slot 声明注入,不依赖 `ConversationService`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 经 `toolviews/plan-summary.ts` 的 `planSummary` 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`,以及「其余活跃项的数量」`+<n>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。部署允许并行工作时,可以有多个条目同时处于 `in_progress`,因此 `planSummary` 给出第一个活跃条目并计数其余,且刻意不把两者拼成一个字符串:行会对摘要文本做省略号截断,把数量接在任务名末尾时,窄行最先裁掉的正是这个数量。该行把数量交给 `ToolRow` 的 `summarySuffix`——共享行在被截断文本旁的不收缩位(出错的行会丢弃它,因为其折叠摘要是失败首行)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加它自行计算的、以 `·` 连接的各状态计数(本地化,形如 `1 已完成 · 2 进行中 · 1 待处理`,计数为零的段落省略;状态图标为 figma 的勾选/进行中/虚线未开始一组),因此它无需一个可被截断的任务名即可报告并行数量。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。普通会话中的每条可见行仍是单行预览,并提供针对精确单次入队项的编辑、删除和严格 steering 操作;已寻址 subagent 则保留只读行,因为其继续执行传输不提供 Queue 变更。如果严格 steering 输给已关闭的窗口,原单次入队项会留在 Queue 中正常投递;如果驱动器已经认领该项,正常投递就已开始。这两种已收敛的竞态都不显示失败,传输和未知错误仍会显示。
|
||||
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。消息尚未进入持久轮次,因此不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与分支控件,仅当该节点是已完成轮次的 transcript 尾部时才启用分支,并能在重连后从同一权威恢复。
|
||||
Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。QueueDock 会将其过滤掉,ChatView 则把它投影为会话流末尾带复制操作的用户样式气泡;非用户来源的 next-step 项(注入上下文)改以 `context` placement 广播,领取前不在任何界面渲染。与所有用户样式气泡一样,这里不显示 fork。Host 会等携带该 steering 的持久 `user/message` 进入 mux 流之后再退役 steering。客户端运行时接纳该实时事件时,会在发布快照前退役第一个匹配的当前 steering 单次入队项;历史事件无法隐藏后来复用同一 `MessageId` 的单次入队项。气泡交接时因而不会产生空档或重复,会立即从持久节点恢复复制操作与时钟——steering 气泡与 user 气泡一样不带分支操作([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))——并能在重连后从同一权威恢复。
|
||||
|
||||
键盘消息提交会根据所寻址会话的运行状态和 steering 能力解析投递方式。空闲时,Enter 和 Cmd/Ctrl+Enter 都执行普通 Queue 发送。主会话运行期间,浏览器持久化的 General Settings 偏好会把普通 Enter 分配为 `Queue`(默认值)或 `Steer`,Cmd/Ctrl+Enter 则执行另一种行为;Shift+Enter 仍然换行。已寻址 subagent 即使正在运行,也会让这两个手势都使用其仅支持 Queue 的继续执行传输。该偏好只影响支持 steering 的繁忙态手势对,发送按钮与非键盘提交操作仍使用 Queue。Composer Steer 复用现有尽力而为的 `session.prompt(mode: 'steer')` 契约:如果当前 next-step 窗口在接纳前关闭,AgentLoop 会把消息接纳为下一条唤醒 Queue 轮次,不显示失败,也不会丢失草稿事务。
|
||||
|
||||
@@ -63,8 +63,8 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu
|
||||
- **压缩标记不显示规模**:该行尚不报告检查点替换了多少条消息或哪段范围。
|
||||
- **统计行的耗时与速率只覆盖窗口内消息流**:LLM 与工具墙钟时间以及 TTFT 与吞吐平均值由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。
|
||||
- **详情面板没有入口**:`ChatViewInjected.openDetails` 虽已实现却无人调用,因此以原始形式显示已选择调用的那部分在组装后的应用中不可达。没有 Input/Output/Metadata 切换、Prev/Next 步进,也没有 trajectory 深链接。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述与纯 Think 节点不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
- **已发送的 user 消息无法编辑**:user 气泡保留时钟、复制和分支;除非已完成轮次的 transcript 结束于该 user 消息,否则分支保持禁用。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
|
||||
- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/时钟/分支)只挂在每个已结束轮次中最后一条带 text 内容的 assistant 下;轮次中间的叙述、纯 Think 节点,以及仍在产出步骤的轮次里的所有节点都不带 chrome。除非该消息同时也是已完成轮次的最后一个 transcript 节点,否则分支保持禁用;启用后,它会 fork 到该轮次末尾,在 client 端递增继承标题并打开子会话。fork 或改名失败时源会话保持选中([决策](../../../.agents/notes/implemented/bug-fix/2026-08-02-message-fork-actions-require-completed-turn-tail.md))。
|
||||
- **已发送的 user 消息无法编辑**:user 气泡保留时钟和复制;分支只存在于 assistant 回答之下([决策](../../../.agents/notes/implemented/simplification/2026-08-06-user-bubbles-drop-the-branch-action.md))。编辑功能要与其背后的能力一起回归:既需要针对已定稿 user 消息的 client 变更,也需要 host 侧对已经消费过它的轮次给出行为([决策](../../../.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md))。
|
||||
- **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
// view groups them into tool rows through its keyed toolview slot (figma
|
||||
// step-summary flow). Shared by finalized nodes and the streaming partial;
|
||||
// the turn-level loading dots live in the chat view's tail, not here.
|
||||
// Finalized content (text) nodes append IconActions once streaming ends
|
||||
// (`time` is omitted for mid-turn narration); their branch action is enabled
|
||||
// only when the node is also the completed turn's transcript tail. Think /
|
||||
// tool-head-only nodes stay chrome-free.
|
||||
// Finalized content (text) nodes append IconActions once their turn ends
|
||||
// (`time` is omitted for mid-turn narration and while the turn still runs);
|
||||
// their branch action is enabled only when the node is also the completed
|
||||
// turn's transcript tail. Think / tool-head-only nodes stay chrome-free.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
IconThinkOutline14, JsonBlock, MarkdownText,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { hasContentText } from './chat-flow.ts'
|
||||
import { MessageIconActions } from './MessageIconActions.tsx'
|
||||
import { ToolRow } from './ToolRow.tsx'
|
||||
import css from './AssistantMarkdown.module.css'
|
||||
@@ -25,7 +26,8 @@ export interface AssistantMarkdownProps {
|
||||
/** Frozen partial of an aborted turn: rendered with a stopped marker. */
|
||||
interrupted?: boolean | undefined
|
||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
||||
* the parent withholds chrome (mid-turn content assistants). */
|
||||
* the parent withholds chrome (mid-turn content assistants and every node
|
||||
* of a turn that has not ended). */
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms for the IconActions run-time label; omitted when the
|
||||
* turn's triggering input is outside the loaded window. */
|
||||
@@ -65,11 +67,6 @@ function copyText(blocks: readonly AssistantBlock[]): string {
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
/** True when the node has model-visible text content worth chrome under. */
|
||||
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
/** Reasoning block as the Think variant summary row (figma 39:28304). */
|
||||
function ThinkRow({ text, running, t }: { text: string; running: boolean; t: AssistantMarkdownProps['t'] }) {
|
||||
return (
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -358,10 +358,11 @@ export function ChatView({
|
||||
[inbox],
|
||||
)
|
||||
const activeRetry = useMemo(() => activeRetrySeq(nodes, running), [nodes, running])
|
||||
// Only the last content assistant of each turn owns IconActions; mid-turn
|
||||
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
|
||||
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
// Only the last content assistant of each completed turn owns IconActions;
|
||||
// mid-turn text and every node of a running turn omit `time`, so
|
||||
// AssistantMarkdown stays chrome-free until the answer settles.
|
||||
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const branchSeqs = useMemo(() => assistantBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
|
||||
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
|
||||
const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes])
|
||||
|
||||
@@ -631,8 +632,6 @@ export function ChatView({
|
||||
<MessageItem
|
||||
node={node}
|
||||
retryActive={node.kind === 'model-retry' && node.seq === activeRetry}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -27,8 +27,6 @@ export interface MessageIconActionsProps {
|
||||
onBranch?: (() => void) | undefined
|
||||
/** The message is not a completed transcript tail, so branch stays visible but unavailable. */
|
||||
branchUnavailable?: boolean | undefined
|
||||
/** Additional branch visibility gate for transient message chrome; defaults to true. */
|
||||
showBranch?: boolean | undefined
|
||||
/** Parent layout class composed onto the actions row. */
|
||||
className?: string | undefined
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
@@ -41,7 +39,7 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
@@ -111,7 +109,7 @@ export function MessageIconActions({
|
||||
{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{showBranch && onBranch !== undefined && (
|
||||
{onBranch !== undefined && (
|
||||
<Tooltip label={branchUnavailable ? t('message.branchUnavailable') : t('message.branch')} side="bottom">
|
||||
{/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */}
|
||||
<button
|
||||
@@ -127,7 +125,7 @@ export function MessageIconActions({
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showBranch && onBranch !== undefined && branchUnavailable && (
|
||||
{onBranch !== undefined && branchUnavailable && (
|
||||
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
|
||||
)}
|
||||
{clock === 'end' ? clockEl : null}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// MessageItem: simple chat nodes — user and consumed-steering bubbles
|
||||
// (right-aligned, with clock + copy / branch IconActions; steering adds the
|
||||
// interjection caption that names it), pending steering (caption + copy only),
|
||||
// context injection, compaction marker, retry disclosure, and unknown-surface
|
||||
// JSON rows.
|
||||
// (right-aligned, with clock + copy IconActions; steering adds the
|
||||
// interjection caption that names it; branch lives only under assistant
|
||||
// answers), pending steering (caption + copy only), context injection,
|
||||
// compaction marker, retry disclosure, and unknown-surface JSON rows.
|
||||
|
||||
import { memo, useEffect, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -27,10 +27,6 @@ export interface MessageItemProps {
|
||||
| TurnErrorNode
|
||||
| UnknownSurfaceNode
|
||||
retryActive?: boolean
|
||||
/** Fork through this message's completed turn when eligible. */
|
||||
onFork?: (seq: number) => void
|
||||
/** The message is not the transcript tail of a completed turn. */
|
||||
forkUnavailable?: boolean
|
||||
/** The owning view's locale seat, passed down as a plain prop. */
|
||||
t: ChatViewSlotProps['t']
|
||||
}
|
||||
@@ -217,7 +213,6 @@ export function PendingSteeringBubble({ content, t }: {
|
||||
<MessageIconActions
|
||||
text={text}
|
||||
clock="start"
|
||||
showBranch={false}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
@@ -227,7 +222,7 @@ export function PendingSteeringBubble({ content, t }: {
|
||||
}
|
||||
|
||||
export const MessageItem = memo(function MessageItem({
|
||||
node, retryActive = false, onFork, forkUnavailable = false, t,
|
||||
node, retryActive = false, t,
|
||||
}: MessageItemProps) {
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
switch (node.kind) {
|
||||
@@ -243,8 +238,6 @@ export const MessageItem = memo(function MessageItem({
|
||||
text={text}
|
||||
time={node.time}
|
||||
clock="start"
|
||||
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
className={css.actions}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -89,6 +89,20 @@
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
/* Trailing summary fragment kept out of .summary's ellipsis, for a count whose
|
||||
whole value is that it survives a narrow row (the todo row's parallel-active
|
||||
`+n`). Repeats .summary's type because it sits beside that text, and its
|
||||
`nowrap` too: `flex: none` stops the box shrinking but not the text wrapping,
|
||||
which would break the one-line row in the narrow case the slot exists for. */
|
||||
.summarySuffix {
|
||||
flex: none;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* File-tool path: same geometry as .summary; hover underline + pointer. */
|
||||
.fileLink {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -46,6 +46,14 @@ export interface ToolRowProps {
|
||||
icon: ReactNode
|
||||
title: string
|
||||
summary: string
|
||||
/**
|
||||
* Trailing summary fragment rendered outside the ellipsized summary text, so
|
||||
* a narrow row clips the summary before this. For a fragment whose whole
|
||||
* value is surviving that clip — the todo row's parallel-active count.
|
||||
* null/absent = the summary is the whole collapsed content. Dropped on an
|
||||
* error row, whose collapsed summary is the failure line instead.
|
||||
*/
|
||||
summarySuffix?: string | null | undefined
|
||||
/** Expanded-body input text; null = no input section. */
|
||||
body: string | null
|
||||
/** Flattened result text for the expanded Output section; null/absent = no output section. */
|
||||
@@ -139,6 +147,7 @@ export function ToolRow({
|
||||
icon,
|
||||
title,
|
||||
summary,
|
||||
summarySuffix,
|
||||
body,
|
||||
output,
|
||||
errorSummary,
|
||||
@@ -173,6 +182,9 @@ export function ToolRow({
|
||||
// the error color outranks both the args summary and a terminal description.
|
||||
const failureLine = state === 'error' ? errorSummary ?? null : null
|
||||
const summaryText = failureLine ?? summary
|
||||
// The failure line replaces the summary wholesale, so a suffix derived from
|
||||
// the call args has nothing left to sit beside.
|
||||
const suffix = failureLine === null ? summarySuffix ?? null : null
|
||||
// The failure line is error prose, not the path: no open-file affordance.
|
||||
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
|
||||
const isThink = variant === 'think'
|
||||
@@ -249,6 +261,7 @@ export function ToolRow({
|
||||
{summaryText}
|
||||
</span>
|
||||
)}
|
||||
{suffix !== null && <span className={css.summarySuffix}>{suffix}</span>}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -17,8 +17,14 @@ export type ChatFlowItem =
|
||||
| { kind: 'node'; key: string; node: ConversationNode }
|
||||
| { kind: 'tool-group'; key: string; results: readonly ToolResultNode[] }
|
||||
|
||||
/** True when the node has model-visible text content worth IconActions chrome. */
|
||||
function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
/**
|
||||
* True when the node has model-visible text content worth IconActions chrome.
|
||||
* Shared with {@link AssistantMarkdown}'s mount gate so ownership and mounting
|
||||
* cannot diverge.
|
||||
* @param blocks - assistant blocks of one finalized node.
|
||||
* @returns Whether any text block carries non-blank content.
|
||||
*/
|
||||
export function hasContentText(blocks: readonly AssistantBlock[]): boolean {
|
||||
return blocks.some(block => block.kind === 'text' && block.text.trim() !== '')
|
||||
}
|
||||
|
||||
@@ -34,14 +40,20 @@ function rendersNothing(node: ConversationNode): boolean {
|
||||
|
||||
/**
|
||||
* Seq set of assistants that own IconActions: the last content-text assistant
|
||||
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
|
||||
* of each *completed* turn. A turn without a `turn/end` in the window is still
|
||||
* producing steps, so its latest narration is not the settled answer and owns
|
||||
* nothing; mid-turn narration of a completed turn stays chrome-free too.
|
||||
* @param nodes - snapshot nodes (surface order).
|
||||
* @param turnEnds - completed turn boundaries retained from the event window.
|
||||
* @returns Seq values ChatView may pass as `time` into AssistantMarkdown.
|
||||
*/
|
||||
export function assistantActionsSeqs(nodes: readonly ConversationNode[]): ReadonlySet<number> {
|
||||
export function assistantActionsSeqs(
|
||||
nodes: readonly ConversationNode[],
|
||||
turnEnds: ReadonlyMap<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
const lastByTurn = new Map<number, number>()
|
||||
for (const node of nodes) {
|
||||
if (node.kind !== 'assistant' || !hasContentText(node.blocks)) continue
|
||||
if (node.kind !== 'assistant' || !turnEnds.has(node.turn) || !hasContentText(node.blocks)) continue
|
||||
lastByTurn.set(node.turn, node.seq)
|
||||
}
|
||||
return new Set(lastByTurn.values())
|
||||
@@ -63,15 +75,18 @@ export function runningTurnStartTime(
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of message rows that may fork: the last transcript node of a
|
||||
* completed turn, when that node owns message chrome. A later tool, reasoning,
|
||||
* error, or other transcript node leaves the earlier message's branch action
|
||||
* unavailable because the Host would include the whole turn.
|
||||
* Seq set of assistant answers that may fork: the completed turn's transcript
|
||||
* tail, when that tail is the turn's own content-text assistant. A later tool,
|
||||
* reasoning, error, or other transcript node leaves the answer's branch action
|
||||
* unavailable because the Host would include the whole turn. User and steering
|
||||
* bubbles carry no branch action at all: a fork at their seq cuts at the same
|
||||
* `turn/end` as the answer's, so the affordance lives only under the settled
|
||||
* answer.
|
||||
* @param nodes - snapshot nodes in event order.
|
||||
* @param turnEnds - completed turn boundaries retained from the event window.
|
||||
* @returns Message seq values whose visible position matches the fork boundary.
|
||||
* @returns Assistant seq values whose visible position matches the fork boundary.
|
||||
*/
|
||||
export function messageBranchSeqs(
|
||||
export function assistantBranchSeqs(
|
||||
nodes: readonly ConversationNode[],
|
||||
turnEnds: ReadonlyMap<number, number>,
|
||||
): ReadonlySet<number> {
|
||||
@@ -86,8 +101,7 @@ export function messageBranchSeqs(
|
||||
tail = candidate
|
||||
nodeIndex++
|
||||
}
|
||||
if (tail?.kind === 'user' || tail?.kind === 'steering'
|
||||
|| (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks))) {
|
||||
if (tail?.kind === 'assistant' && tail.turn === turn && hasContentText(tail.blocks)) {
|
||||
result.add(tail.seq)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,14 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
/* The column scrolls on ONE axis. Stating `hidden` rather than leaving the
|
||||
initial `visible` is what removes the horizontal bar: a box that scrolls in
|
||||
one axis computes `visible` to `auto` in the other, so any bleed becomes
|
||||
user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero
|
||||
box), which put a horizontal scrollbar under every center column narrower
|
||||
than the glow. Clipping is unchanged — `overflow-y: auto` already made this
|
||||
a scroll container that clips both axes, so this only takes away the bar. */
|
||||
overflow-x: hidden;
|
||||
/* Reserved unconditionally: the composer seat rides this box's content box in
|
||||
Chat and its padding box under a view's composer overlay, so an `auto`
|
||||
gutter moves the input card sideways by the bar's width whenever the two
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Pure plan derivation for the todo_write row's one-line summary. Several items
|
||||
* may be `in_progress` at once — parallel work runs concurrent tasks, so a
|
||||
* summary built from one active item would silently drop the rest. The plan
|
||||
* strip header derives its own counts inline and shares nothing with this, so
|
||||
* this stays inside the toolviews domain rather than in `contract/` (the
|
||||
* inter-domain face).
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* One list item as the row sees it: unvalidated model JSON parsed from a call's
|
||||
* args, so any field may be missing or mistyped.
|
||||
*/
|
||||
export interface PlanItemLike {
|
||||
content?: unknown
|
||||
status?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts plus the two halves of the summary, deliberately NOT pre-joined: the
|
||||
* row ellipsizes its summary text, and a count concatenated onto the end of the
|
||||
* task name is the first thing a narrow row clips — exactly when it carries
|
||||
* information. The row renders `activeExtra` in its own non-shrinking span
|
||||
* beside the truncatable text.
|
||||
*/
|
||||
export interface PlanSummary {
|
||||
done: number
|
||||
total: number
|
||||
/** First `in_progress` content, or null when that first item is unusable. */
|
||||
activeContent: string | null
|
||||
/** Active items beyond the first; 0 whenever there is no `activeContent` to sit beside. */
|
||||
activeExtra: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the counts and the active summary from a whole-list snapshot. It names
|
||||
* the first `in_progress` item and counts the remaining active ones, so a
|
||||
* parallel plan reports how many tasks are running rather than naming one and
|
||||
* hiding the others. `activeContent` is null when nothing is in progress, or
|
||||
* when the first active item's content is missing, mistyped, or blank once
|
||||
* trimmed — the tool's own rule for usable content, applied here because a
|
||||
* rejected call keeps its args verbatim. The row then renders the counts alone
|
||||
* rather than falling back to the generic tool summary: the counts are already
|
||||
* known to be good, and the active-item clause is the only part an unusable
|
||||
* name costs.
|
||||
* @param todos - the whole list, in model order.
|
||||
* @returns the done/total counts and the two summary halves.
|
||||
*/
|
||||
export function planSummary(todos: readonly PlanItemLike[]): PlanSummary {
|
||||
const active = todos.filter(t => t.status === 'in_progress')
|
||||
const first = active[0]?.content
|
||||
const named = typeof first === 'string' && first.trim() !== ''
|
||||
return {
|
||||
done: todos.filter(t => t.status === 'completed').length,
|
||||
total: todos.length,
|
||||
activeContent: named ? first : null,
|
||||
activeExtra: named ? active.length - 1 : 0,
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
// "Tool call" card, registered into the keyed 'conversation.chat.toolview'
|
||||
// hole like the bash sample (a product registration, not a sample). The row
|
||||
// composes ToolRow (chrome, running sweep, whole-row expand) and swaps in a
|
||||
// summary of the written list (counts + active item) from the call args; the
|
||||
// durable list itself renders in the TodoPanel above the composer, so the
|
||||
// row stays one line until expanded.
|
||||
// summary of the written list (counts + active items) from the call args, with
|
||||
// the parallel-active count riding ToolRow's non-shrinking summary suffix so a
|
||||
// narrow row never clips it; the durable list itself renders in the TodoPanel
|
||||
// above the composer, so the row stays one line until expanded.
|
||||
|
||||
import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -13,18 +14,26 @@ import type { ToolRowProps } from '../contract/slots.ts'
|
||||
import { toolRowModel } from '../contract/tool-call-model.ts'
|
||||
import { ToolRow } from '../chat/ToolRow.tsx'
|
||||
import { NS } from '../locales.ts'
|
||||
import { planSummary, type PlanItemLike } from './plan-summary.ts'
|
||||
|
||||
/** Todo row props: the toolview runtime share plus the standard locale seat. */
|
||||
type TodoRowProps = ToolRowProps & PropsLocale<'conversation'>
|
||||
|
||||
/** One parsed args item, shape-checked (model JSON: any field may be missing or mistyped). */
|
||||
interface TodoWriteItem { content?: unknown; status?: unknown }
|
||||
|
||||
function isItem(value: unknown): value is TodoWriteItem {
|
||||
function isItem(value: unknown): value is PlanItemLike {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
/**
|
||||
* The row's summary split at the ellipsis boundary: `text` truncates, `extra`
|
||||
* is the parallel-active count that must not, so a narrow row never clips the
|
||||
* one part that says several tasks are running.
|
||||
*/
|
||||
interface RowSummary {
|
||||
text: string
|
||||
extra: number
|
||||
}
|
||||
|
||||
function summarize(argsRaw: string, t: TodoRowProps['t']): RowSummary | null {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(argsRaw)
|
||||
@@ -37,12 +46,12 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
if (typeof parsed !== 'object' || parsed === null) return null
|
||||
const todos = (parsed as { todos?: unknown }).todos
|
||||
if (!Array.isArray(todos) || !todos.every(isItem)) return null
|
||||
const done = todos.filter(item => item.status === 'completed').length
|
||||
const active = todos.find(item => item.status === 'in_progress')
|
||||
const head = t('todo.completed', { done, total: todos.length })
|
||||
return typeof active?.content === 'string' && active.content !== ''
|
||||
? `${head} · ${active.content}`
|
||||
: head
|
||||
const { done, total, activeContent, activeExtra } = planSummary(todos)
|
||||
const head = t('todo.completed', { done, total })
|
||||
return {
|
||||
text: activeContent === null ? head : `${head} · ${activeContent}`,
|
||||
extra: activeExtra,
|
||||
}
|
||||
}
|
||||
|
||||
/** One-line plan update row (the whole row toggles the call's Input/Output
|
||||
@@ -52,7 +61,7 @@ function summarize(argsRaw: string, t: TodoRowProps['t']): string | null {
|
||||
export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
const model = toolRowModel(toolName, block)
|
||||
const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? ''
|
||||
const summary = summarize(argsRaw, t) ?? model.summary
|
||||
const summary = summarize(argsRaw, t) ?? { text: model.summary, extra: 0 }
|
||||
return (
|
||||
<ToolRow
|
||||
t={t}
|
||||
@@ -60,7 +69,8 @@ export function TodoRow({ toolName, block, inspect, t }: TodoRowProps) {
|
||||
toolName={toolName}
|
||||
icon={<IconChecklistOutline14 />}
|
||||
title={t('todo.rowTitle')}
|
||||
summary={summary}
|
||||
summary={summary.text}
|
||||
summarySuffix={summary.extra > 0 ? `+${summary.extra}` : null}
|
||||
body={model.body}
|
||||
output={model.output}
|
||||
errorSummary={model.errorSummary}
|
||||
|
||||
@@ -36,7 +36,7 @@ afterEach(() => {
|
||||
const t: MessageItemProps['t'] = makeTranslate(zh, commonZh)
|
||||
|
||||
describe('MessageItem arms', () => {
|
||||
it('user bubbles expose clock / copy / branch and no edit; copy writes the text', () => {
|
||||
it('user bubbles expose clock / copy and neither branch nor edit; copy writes the text', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -45,24 +45,20 @@ describe('MessageItem arms', () => {
|
||||
// Same-day clock: construct "today at 14:24" so the label stays `HH:mm`.
|
||||
const now = new Date()
|
||||
const time = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 14, 24).getTime()
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time,
|
||||
content: [{ type: 'text', text: 'hello bubble' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
onFork={onFork}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('14:24')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '在新对话中分支' })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '编辑' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('hello bubble')
|
||||
fireEvent.click(screen.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(onFork).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('user copy falls back to execCommand when clipboard.writeText is unavailable', () => {
|
||||
@@ -87,30 +83,6 @@ describe('MessageItem arms', () => {
|
||||
expect(exec).toHaveBeenCalledWith('copy')
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'user', seq: 1, time: 1_000,
|
||||
content: [{ type: 'text', text: 'open turn' }] as never,
|
||||
source: null,
|
||||
}}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('user copy never claims success when the host rejects the write', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
@@ -212,19 +184,17 @@ describe('MessageItem arms', () => {
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('consumed steering is captioned as an interjection and keeps copy and branch actions', () => {
|
||||
it('consumed steering is captioned as an interjection and keeps copy without branch', () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
const fork = vi.fn()
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', messageId: 'steer-message', seq: 2, time: 1_000, turn: 1, source: null,
|
||||
content: [{ type: 'text', text: 'steer!' }, { type: 'image', data: 'x' }] as never,
|
||||
} as never}
|
||||
onFork={fork}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
@@ -232,8 +202,7 @@ describe('MessageItem arms', () => {
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
fireEvent.click(view.getByRole('button', { name: '复制' }))
|
||||
expect(writeText).toHaveBeenCalledWith('steer!')
|
||||
fireEvent.click(view.getByRole('button', { name: '在新对话中分支' }))
|
||||
expect(fork).toHaveBeenCalledWith(2)
|
||||
expect(view.queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
})
|
||||
|
||||
it('context uses the Tool calls disclosure chrome and keeps its body collapsed by default', () => {
|
||||
@@ -1002,6 +971,31 @@ describe('small branch tails', () => {
|
||||
expect(streaming.queryByText('14:24')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps an unavailable branch focusable and explains why without sending a fork', () => {
|
||||
const onFork = vi.fn()
|
||||
render(
|
||||
<AssistantMarkdown
|
||||
t={t}
|
||||
blocks={[{ kind: 'text', text: 'answer before a trailing tool row' }]}
|
||||
streaming={false}
|
||||
time={1_000}
|
||||
seq={1}
|
||||
onFork={onFork}
|
||||
forkUnavailable
|
||||
/>,
|
||||
)
|
||||
const branch = screen.getByRole('button', { name: '在新对话中分支' }) as HTMLButtonElement
|
||||
expect(branch.disabled).toBe(false)
|
||||
expect(branch.getAttribute('aria-disabled')).toBe('true')
|
||||
const reasonId = branch.getAttribute('aria-describedby')
|
||||
expect(reasonId).not.toBeNull()
|
||||
expect(document.getElementById(reasonId!)?.textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
fireEvent.click(branch)
|
||||
expect(onFork).not.toHaveBeenCalled()
|
||||
fireEvent.focus(branch)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe('仅可从已完成轮次的最后一条消息分支')
|
||||
})
|
||||
|
||||
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
// Cache hit is null only when all three prompt buckets are zero (pure
|
||||
// output accounting) — any billed input makes it a real 0%.
|
||||
|
||||
@@ -301,6 +301,20 @@ describe('ToolRow', () => {
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders summarySuffix outside the ellipsized summary span, and drops it on a failure line', () => {
|
||||
const view = render(<ToolRow {...rowProps} summarySuffix="+2" />)
|
||||
const summary = view.getByText('List files')
|
||||
const suffix = view.getByText('+2')
|
||||
// Separate spans: .summary truncates, the suffix must not travel inside it.
|
||||
expect(summary.contains(suffix)).toBe(false)
|
||||
view.unmount()
|
||||
// The failure line replaces the summary wholesale, so the suffix goes with it.
|
||||
const failed = render(
|
||||
<ToolRow {...rowProps} state="error" errorSummary="boom" summarySuffix="+2" />,
|
||||
)
|
||||
expect(failed.queryByText('+2')).toBeNull()
|
||||
})
|
||||
|
||||
it('an error file row drops the open-file link (the summary is failure prose, not the path)', () => {
|
||||
const open = vi.fn()
|
||||
const view = render(
|
||||
|
||||
@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
import { ChatView } from '../src/client/chat/ChatView.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { assistantActionsSeqs, assistantBranchSeqs, deriveChatFlow, flowKeys, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
afterEach(() => {
|
||||
@@ -225,12 +225,12 @@ describe('chat-flow derivation', () => {
|
||||
expect(flowKeys(deriveChatFlow([toolResult(3, 'a'), assistant(4, 'found'), toolResult(5, 'b')]))).toBe('g3|n4|g5')
|
||||
})
|
||||
|
||||
it('assistantActionsSeqs keeps only the last content assistant per turn', () => {
|
||||
it('assistantActionsSeqs keeps only the last content assistant per completed turn', () => {
|
||||
const thinkOnly: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 3, time: 3_000, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'planning' }],
|
||||
}
|
||||
const seqs = assistantActionsSeqs([
|
||||
const nodes: ConversationNode[] = [
|
||||
user(1, 'hi'),
|
||||
assistant(2, 'looking', 1),
|
||||
thinkOnly,
|
||||
@@ -238,8 +238,11 @@ describe('chat-flow derivation', () => {
|
||||
assistant(5, 'done', 1),
|
||||
user(6, 'again'),
|
||||
assistant(7, 'second turn', 2),
|
||||
])
|
||||
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
]
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5], [2, 7]]))].sort((a, b) => a - b)).toEqual([5, 7])
|
||||
// Turn 2 is still producing steps: its latest narration owns nothing, and
|
||||
// the settled turn 1 keeps its seat.
|
||||
expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5])
|
||||
})
|
||||
|
||||
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
|
||||
@@ -261,7 +264,7 @@ describe('chat-flow derivation', () => {
|
||||
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
|
||||
})
|
||||
|
||||
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
|
||||
it('assistantBranchSeqs keeps only content-assistant tails; user/steering tails own no branch', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
blocks: [{ kind: 'reasoning', text: 'bad path' }], interrupted: true,
|
||||
@@ -276,8 +279,8 @@ describe('chat-flow derivation', () => {
|
||||
user(10, 'user-only tail'),
|
||||
user(13, 'steering tail'),
|
||||
]
|
||||
const seqs = messageBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7, 10, 13])
|
||||
const seqs = assistantBranchSeqs(nodes, new Map([[1, 5], [2, 8], [3, 11], [4, 14]]))
|
||||
expect([...seqs]).toEqual([7])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,21 +404,24 @@ describe('ChatView', () => {
|
||||
expect(view.getAllByText('interrupt now')).toHaveLength(1)
|
||||
expect(view.container.querySelector('[data-pending-steering]')).toBeNull()
|
||||
expect(view.getAllByText('插话')).toHaveLength(1)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
// Only the durable steering bubble: the turn is still running, so its
|
||||
// assistant narration owns no footer yet, and a steering bubble never
|
||||
// carries a branch action.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(1)
|
||||
const durableBubble = view.getByText('interrupt now').closest('[class*="userRow"]') as HTMLElement
|
||||
const unavailable = within(durableBubble).getByRole('button', { name: '在新对话中分支' })
|
||||
expect(unavailable.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(unavailable)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
expect(within(durableBubble).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
|
||||
|
||||
act(() => {
|
||||
h.set({ running: false, turnEnds: new Map([[1, 3]]) })
|
||||
})
|
||||
// The completed turn's transcript tail is the steering bubble, not the
|
||||
// narration, so the assistant's branch action stays unavailable and the
|
||||
// steering bubble still offers none.
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(2)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
|
||||
fireEvent.click(branchButtons[1]!)
|
||||
expect(h.forkAt).toHaveBeenCalledWith(2)
|
||||
expect(branchButtons).toHaveLength(1)
|
||||
expect(branchButtons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(branchButtons[0]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a later pending occurrence visible when it reuses a durable MessageId', () => {
|
||||
@@ -518,11 +524,35 @@ describe('ChatView', () => {
|
||||
turnEnds: new Map([[1, 4], [2, 6]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// Every message footer keeps branch visible; only completed assistant tails enable it.
|
||||
// Branch renders only under assistant answers; user bubbles keep copy alone.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
const branchButtons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(branchButtons).toHaveLength(4)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
|
||||
expect(branchButtons).toHaveLength(2)
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null])
|
||||
})
|
||||
|
||||
it('withholds assistant IconActions while the turn is still running', () => {
|
||||
const h = makeHarness({
|
||||
running: true,
|
||||
runningCalls: [runningCall('a')],
|
||||
nodes: [
|
||||
user(1, 'first'),
|
||||
assistant(2, 'previous answer', 1),
|
||||
user(4, 'second'),
|
||||
assistant(5, 'mid-turn text', 2),
|
||||
],
|
||||
// Boundary seqs follow the log: a turn/end is strictly after its own nodes.
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// 2 user + the settled turn-1 tail, which keeps its seat while a later
|
||||
// turn runs; turn 2's narration stays chrome-free while its tool runs, so
|
||||
// the footer never appears and then moves.
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(3)
|
||||
expect(view.getByText('mid-turn text')).toBeTruthy()
|
||||
// turn/end lands: the same node becomes the settled answer and takes the seat.
|
||||
act(() => { h.set({ running: false, runningCalls: [], turnEnds: new Map([[1, 3], [2, 6]]) }) })
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('the actions-owning assistant footer shows the turn run time', () => {
|
||||
@@ -606,11 +636,11 @@ describe('ChatView', () => {
|
||||
turnEnds: new Map([[1, 3]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// The user bubble offers no branch; the settled answer's is live.
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null])
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.getAttribute('aria-disabled')).toBeNull()
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt.mock.calls).toEqual([[2]])
|
||||
})
|
||||
|
||||
@@ -626,10 +656,9 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(2)
|
||||
const buttons = view.getAllByRole('button', { name: '在新对话中分支' })
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons.every(button => button.getAttribute('aria-disabled') === 'true')).toBe(true)
|
||||
expect(buttons).toHaveLength(1)
|
||||
expect(buttons[0]!.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
expect(h.forkAt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status
|
||||
* rows, collapse), its TodoDock adapter (selects the plan off the session
|
||||
* snapshot and follows changes), and the todo_write toolview row (progress
|
||||
* summary from args, generic fallback on malformed JSON, shared ToolRow
|
||||
* state dots and leading expansion).
|
||||
* Todo display acceptance: the TodoPanel plan strip (empty-hidden, status rows
|
||||
* including several `in_progress` at once, collapse), its TodoDock adapter
|
||||
* (selects the plan off the session snapshot and follows changes), the row's
|
||||
* plan summary (counts plus the two halves of the active summary — the named
|
||||
* task and the `+N` count that parallel work adds, kept apart so the row never
|
||||
* ellipsizes the count away), and the todo_write toolview row (progress summary
|
||||
* from args, generic fallback on malformed JSON, shared ToolRow state dots and
|
||||
* leading expansion).
|
||||
*/
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -17,6 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { TodoRow, todoToolview } from '../src/client/toolviews/todo-row.tsx'
|
||||
import type { TodoDockProps } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { TodoDock, TodoPanel, todoDockEntry } from '../src/client/skeleton/TodoPanel.tsx'
|
||||
import { planSummary } from '../src/client/toolviews/plan-summary.ts'
|
||||
import { NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
type TodoRowProps = Parameters<typeof TodoRow>[0]
|
||||
@@ -32,6 +36,49 @@ const LIST: TodoItem[] = [
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
/** A parallel plan: three tasks running at once (concurrent subagents). */
|
||||
const PARALLEL: TodoItem[] = [
|
||||
{ content: '搭骨架', status: 'completed' },
|
||||
{ content: '写组件', status: 'in_progress' },
|
||||
{ content: '跑后台构建', status: 'in_progress' },
|
||||
{ content: '读源码', status: 'in_progress' },
|
||||
{ content: '补测试', status: 'pending' },
|
||||
]
|
||||
|
||||
describe('planSummary', () => {
|
||||
it('counts done/total and names the single active item with no extra count', () => {
|
||||
expect(planSummary(LIST)).toEqual({ done: 1, total: 3, activeContent: '写组件', activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('reports the extra active count separately when several items are in progress', () => {
|
||||
// Parallel work marks several: naming one and hiding the rest would lose
|
||||
// them, and the count stays unjoined so the row cannot ellipsize it.
|
||||
expect(planSummary(PARALLEL)).toEqual({ done: 1, total: 5, activeContent: '写组件', activeExtra: 2 })
|
||||
})
|
||||
|
||||
it('has no hint when nothing is in progress', () => {
|
||||
expect(planSummary([{ content: '都完了', status: 'completed' }]))
|
||||
.toEqual({ done: 1, total: 1, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('has no hint when the first active item carries no usable content (model JSON)', () => {
|
||||
// Unvalidated args: a missing, mistyped, empty, or whitespace-only content
|
||||
// yields no hint — and no orphan count, even with a second active item to
|
||||
// count. Whitespace-only is the tool's own rejection rule (trimmed
|
||||
// non-empty), and a rejected call keeps its args verbatim.
|
||||
expect(planSummary([{ status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
expect(planSummary([{ content: 42, status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: '', status: 'in_progress' }]).activeContent).toBeNull()
|
||||
expect(planSummary([{ content: ' ', status: 'in_progress' }, { content: 'x', status: 'in_progress' }]))
|
||||
.toMatchObject({ activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
|
||||
it('is empty-safe', () => {
|
||||
expect(planSummary([])).toEqual({ done: 0, total: 0, activeContent: null, activeExtra: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('TodoPanel', () => {
|
||||
it('renders nothing while the list is empty', () => {
|
||||
const { container } = render(<TodoPanel todos={[]} t={t} />)
|
||||
@@ -80,6 +127,18 @@ describe('TodoPanel', () => {
|
||||
expect(screen.getAllByRole('listitem')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('marks every parallel active item, and counts them all in the header', () => {
|
||||
render(<TodoPanel todos={PARALLEL} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
// The old unconditional cap made this list unreachable: three items carry
|
||||
// the in-progress glyph at once, and the header counts all three.
|
||||
const statuses = screen.getAllByRole('listitem').map(li => li.getAttribute('data-status'))
|
||||
expect(statuses.filter(s => s === 'in_progress')).toHaveLength(3)
|
||||
expect(screen.getByText('跑后台构建')).toBeTruthy()
|
||||
expect(screen.getByText('读源码')).toBeTruthy()
|
||||
expect(screen.getByText('1 已完成 · 3 进行中 · 1 待处理')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('an all-completed list collapses the summary to the done count alone', () => {
|
||||
render(<TodoPanel todos={[{ content: '都完了', status: 'completed' }]} t={t} />)
|
||||
expect(screen.getByRole('button', { expanded: false })).toBeTruthy()
|
||||
@@ -145,12 +204,30 @@ describe('TodoRow', () => {
|
||||
expect(screen.getByText('1/3 已完成 · 写组件')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reports the extra active count outside the ellipsized summary text', () => {
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(JSON.stringify({ todos: PARALLEL })))} />)
|
||||
const text = screen.getByText('1/5 已完成 · 写组件')
|
||||
const extra = screen.getByText('+2')
|
||||
// Separate spans: .summary truncates, the count must not travel inside it.
|
||||
expect(text.contains(extra)).toBe(false)
|
||||
expect(container.textContent).toContain('1/5 已完成 · 写组件+2')
|
||||
})
|
||||
|
||||
it('omits the active clause when no item is in progress and reads running-call args', () => {
|
||||
const args = JSON.stringify({ todos: [{ content: 'x', status: 'completed' }] })
|
||||
render(<TodoRow {...rowProps({ callId: 'c1', name: 'todo_write', argsRaw: args, turn: 1, step: 1, time: 1_000, callView: null })} />)
|
||||
expect(screen.getByText('1/1 已完成')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the counts when an active item has unusable content, instead of the generic summary', () => {
|
||||
// planSummary yields activeContent null here, but the counts are known good,
|
||||
// so the row drops only the active clause — `?? model.summary` never runs.
|
||||
const args = JSON.stringify({ todos: [{ content: 'done', status: 'completed' }, { content: 42, status: 'in_progress' }] })
|
||||
const { container } = render(<TodoRow {...rowProps(resultNode(args))} />)
|
||||
expect(screen.getByText('1/2 已完成')).toBeTruthy()
|
||||
expect(container.textContent).not.toContain('+')
|
||||
})
|
||||
|
||||
it('keeps the non-ok execution states visible through the shared row states', () => {
|
||||
// A running call (no result yet) carries the running state (row sweep).
|
||||
const args = JSON.stringify({ todos: LIST })
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The one-line contract of the ToolRow summary line as CSS text. jsdom has no
|
||||
* layout, so the rendering specs (chat-tool-row.spec.tsx) can pin which spans
|
||||
* exist but not whether a narrow row still fits on one line; these read the
|
||||
* declarations the layout depends on.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/client/chat/ToolRow.module.css', import.meta.url)), 'utf8')
|
||||
/** Declarations only: the sheet's prose names the properties it explains. */
|
||||
const declarationText = css.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
function declarations(selector: string): string[] {
|
||||
// Anchored at a rule boundary: an unanchored match would silently read a
|
||||
// compound rule that merely contains the selector (`.root:hover .summarySuffix`)
|
||||
// if one ever lands above the base rule.
|
||||
const rule = new RegExp(`(?:^|\\})\\s*\\${selector}\\s*\\{([^{}]*)\\}`).exec(declarationText)
|
||||
if (rule === null) throw new Error(`ToolRow.module.css has no \`${selector}\` rule`)
|
||||
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
describe('ToolRow.module.css summary line', () => {
|
||||
it('keeps the summary suffix on one line and unshrunk', () => {
|
||||
// `flex: none` stops the box shrinking, not the text wrapping: without
|
||||
// `nowrap`, a row too narrow for title + separator + suffix wraps the `+n`
|
||||
// onto a second line — the exact case the slot exists to survive.
|
||||
expect(declarations('.summarySuffix')).toEqual(expect.arrayContaining([
|
||||
'flex: none',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
})
|
||||
|
||||
it('leaves the truncation to the summary text alone', () => {
|
||||
// The suffix must never ellipsize: a clipped count reads as a smaller
|
||||
// number rather than as missing information.
|
||||
expect(declarations('.summary')).toEqual(expect.arrayContaining([
|
||||
'overflow: hidden',
|
||||
'text-overflow: ellipsis',
|
||||
'white-space: nowrap',
|
||||
]))
|
||||
expect(declarations('.summarySuffix')).not.toEqual(expect.arrayContaining(['text-overflow: ellipsis']))
|
||||
})
|
||||
})
|
||||
@@ -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-models/README.md
|
||||
README.md: b55914197e472edec8a8b6d4d3e02036d1697728
|
||||
README.zh.md: ca93c3d5a2a85fffb22707f8389f1e979468e2ec
|
||||
README.md: 9d1fbdddd1ad9ec4c073dd1c0ca4ac7124c1b876
|
||||
README.zh.md: ff740bc6d1096901cbcc33772aff09deafeb53a4
|
||||
|
||||
@@ -4,11 +4,11 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and each adapter's model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
@@ -29,7 +29,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred.
|
||||
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
|
||||
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
|
||||
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that listing shape, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
|
||||
- **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows.
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可以询问提供方它服务什么。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及各适配器自己的模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
@@ -29,7 +29,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这一种列表形状,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
- **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。
|
||||
|
||||
@@ -38,6 +38,13 @@
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.savedNotice {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.rows {
|
||||
list-style: none;
|
||||
/* Extra air between the title/intro block and the first provider card. */
|
||||
@@ -65,6 +72,13 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rowIdentity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rowName {
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
@@ -72,6 +86,23 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.credentialDot {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.credentialDotConfigured {
|
||||
background: var(--dsw-alias-state-success-primary);
|
||||
}
|
||||
|
||||
.credentialDotMissing {
|
||||
background: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.rowActions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Models settings section: the provider rows joined from the configurable
|
||||
* directory, settings namespaces, and credential states, with one editor
|
||||
* card at a time. A whole-section provider without a configured key (the
|
||||
* unconfigured DeepSeek posture) renders as its open setup card instead of a
|
||||
* row; the add flow is a card carrying the dormant-provider select. Every
|
||||
* mutation writes through the wire, while a provider removal first requires
|
||||
* card at a time. Rows expose only confirmed API-key state through accessible
|
||||
* solid configured or missing dots. A whole-section provider without a
|
||||
* configured key (the unconfigured DeepSeek posture) renders as its open setup
|
||||
* card instead of a row; the add flow is a card carrying the dormant-provider
|
||||
* select. Every mutation writes through the wire, while a provider removal first requires
|
||||
* confirmation; the page re-renders from pushed invalidations or the
|
||||
* post-apply reload.
|
||||
*/
|
||||
@@ -15,9 +16,9 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { CustomProviderCard } from './CustomProviderCard.tsx'
|
||||
import { messageOf, protocolChoices } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts'
|
||||
import { ProviderEditor } from './ProviderEditor.tsx'
|
||||
import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -39,42 +40,73 @@ export interface ModelsSectionInjected {
|
||||
*/
|
||||
export type ModelsSectionProps = Partial<ModelsSectionInjected>
|
||||
|
||||
/** The editor target: an existing row or a dormant directory entry. */
|
||||
interface EditorTarget {
|
||||
/** Provider identity shared by row actions and confirmation copy. */
|
||||
export interface ProviderIdentity {
|
||||
/** Stable provider route id. */
|
||||
provider: string
|
||||
/** Human-facing provider name. */
|
||||
displayName: string
|
||||
}
|
||||
|
||||
/** One existing row or dormant directory entry addressed by an editor action. */
|
||||
interface EditorTarget extends ProviderIdentity {
|
||||
settingsNs: string
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
interface ProviderEditorRenderProps extends Pick<
|
||||
ProviderEditorProps,
|
||||
'namespace' | 'api' | 't' | 'readOnly' | 'onClose'
|
||||
> {
|
||||
target: EditorTarget
|
||||
}
|
||||
|
||||
/** Render an editor for either the setup posture or an expanded provider row. */
|
||||
function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode {
|
||||
return (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one user-added provider profile by unsetting its path in the stored
|
||||
* user section, then reload. The removal names the profile rather than
|
||||
* rebuilding the section: this page only ever holds the redacted descriptor,
|
||||
* so a rebuilt section would drop every literal secret stored elsewhere in
|
||||
* the namespace along with the profile being removed.
|
||||
* @param api - settings wire face.
|
||||
* Remove one user-added provider and its page-managed credential. Credential
|
||||
* removal comes first so a second-step failure leaves the provider row visible
|
||||
* and the whole operation safely retryable; both unsets are idempotent.
|
||||
* The settings removal names the profile rather than rebuilding its redacted
|
||||
* namespace, which would drop literal secrets stored elsewhere.
|
||||
* @param api - settings and credential wire faces.
|
||||
* @param controller - the page store to refresh.
|
||||
* @param target - the provider's settings address.
|
||||
* @param target - the provider's settings address and optional managed credential.
|
||||
* @returns the failure message, or undefined once the write and reload landed.
|
||||
*/
|
||||
export async function removeProviderProfile(
|
||||
api: Pick<IApiClient, 'settings'>,
|
||||
api: Pick<IApiClient, 'settings' | 'credentials'>,
|
||||
controller: ModelsSettingsStore,
|
||||
target: { settingsNs: string; settingsPath: readonly string[] },
|
||||
target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string },
|
||||
): Promise<string | undefined> {
|
||||
let response
|
||||
try {
|
||||
response = await api.settings.mutate({
|
||||
if (target.credentialRef !== undefined) {
|
||||
const credential = await api.credentials.unset({ ref: target.credentialRef })
|
||||
if (!credential.result.ok) return credential.result.error.message
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
ns: target.settingsNs,
|
||||
ops: [{ op: 'unset', path: [...target.settingsPath] }],
|
||||
})
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
} catch (error) {
|
||||
// The transport rejected rather than answering; the caller must be able
|
||||
// to say so instead of the row silently staying put.
|
||||
// to retry the idempotent operation instead of the row silently staying.
|
||||
return messageOf(error)
|
||||
}
|
||||
if (!response.result.ok) return response.result.error.message
|
||||
await controller.load()
|
||||
return undefined
|
||||
}
|
||||
@@ -93,14 +125,33 @@ export function needsSetup(row: ProviderRow): boolean {
|
||||
}
|
||||
|
||||
function targetOf(row: ProviderRow): EditorTarget {
|
||||
const managedRef = deriveKeyRef(row.entry.provider)
|
||||
const credentialRef = row.apiKeyEnv === managedRef
|
||||
&& row.credential?.configured === true
|
||||
&& row.credential.writable
|
||||
? managedRef
|
||||
: undefined
|
||||
return {
|
||||
provider: row.entry.provider,
|
||||
displayName: row.entry.displayName,
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable visible and accessible identity for one provider target. */
|
||||
export function providerTargetLabel(target: ProviderIdentity): string {
|
||||
return target.provider === target.displayName
|
||||
? target.provider
|
||||
: `${target.displayName} (${target.provider})`
|
||||
}
|
||||
|
||||
/** Replace the one provider placeholder in localized destructive-action copy. */
|
||||
export function providerCopy(template: string, target: ProviderIdentity): string {
|
||||
return template.replace('{provider}', () => providerTargetLabel(target))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Models section content column.
|
||||
* @param props - slot-delivered injected dependencies.
|
||||
@@ -119,28 +170,35 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<EditorTarget | undefined>(undefined)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [deleteFailure, setDeleteFailure] = useState<string | undefined>(undefined)
|
||||
const [savedTarget, setSavedTarget] = useState<ProviderIdentity | undefined>(undefined)
|
||||
const [declaring, setDeclaring] = useState(false)
|
||||
|
||||
const closeEditor = (changed: boolean): void => {
|
||||
const closeEditor = (changed: boolean, target: ProviderIdentity): void => {
|
||||
setEditing(undefined)
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
}
|
||||
}
|
||||
|
||||
const closeDelete = (): void => {
|
||||
if (deleting) return
|
||||
setDeleteTarget(undefined)
|
||||
setDeleteFailure(undefined)
|
||||
}
|
||||
|
||||
const confirmDelete = (): void => {
|
||||
/* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */
|
||||
if (deleteTarget === undefined || deleting) return
|
||||
setDeleting(true)
|
||||
setDeleteFailure(undefined)
|
||||
void removeProviderProfile(api, controller, deleteTarget)
|
||||
.then((failure) => {
|
||||
if (failure !== undefined) {
|
||||
controller.fail(failure)
|
||||
setDeleteFailure(failure)
|
||||
return
|
||||
}
|
||||
setDeleteTarget(undefined)
|
||||
@@ -176,6 +234,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{savedTarget === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['savedNotice']} role="status" aria-live="polite">
|
||||
{providerCopy(t('savedProvider'), savedTarget)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles['rows']}>
|
||||
{configured.map((row) => {
|
||||
const target = targetOf(row)
|
||||
@@ -187,29 +252,54 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
// setup card IS its presence on the page.
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['setupCard']}>
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
{renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
})}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
const open = !adding && editing?.provider === row.entry.provider
|
||||
const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true
|
||||
const credentialMissing = !credentialConfigured
|
||||
&& row.apiKeyEnv !== undefined
|
||||
&& row.credential?.configured === false
|
||||
return (
|
||||
<li key={row.entry.provider} className={styles['rowCard']}>
|
||||
<div className={styles['rowHead']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
<span className={styles['rowIdentity']}>
|
||||
<span className={styles['rowName']}>{row.entry.displayName}</span>
|
||||
{credentialConfigured
|
||||
? (
|
||||
<span
|
||||
className={`${styles['credentialDot']} ${styles['credentialDotConfigured']}`}
|
||||
role="img"
|
||||
aria-label={t('credentialConfigured')}
|
||||
title={t('credentialConfigured')}
|
||||
/>
|
||||
)
|
||||
: credentialMissing
|
||||
? (
|
||||
<span
|
||||
className={`${styles['credentialDot']} ${styles['credentialDotMissing']}`}
|
||||
role="img"
|
||||
aria-label={t('credentialMissing')}
|
||||
title={t('credentialMissing')}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</span>
|
||||
<span className={styles['rowActions']}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles['secondaryButton']}
|
||||
aria-label={providerCopy(t('editProvider'), target)}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
// One card at a time: leaving `declaring` set would show
|
||||
// the create card beside this editor, and closing either
|
||||
// one discards the other's draft.
|
||||
@@ -225,8 +315,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
className={styles['dangerButton']}
|
||||
aria-label={providerCopy(t('removeProvider'), target)}
|
||||
disabled={!state.writable}
|
||||
onClick={() => { setDeleteTarget(target) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setDeleteFailure(undefined)
|
||||
setDeleteTarget(target)
|
||||
}}
|
||||
>
|
||||
{t('remove')}
|
||||
</button>
|
||||
@@ -235,18 +330,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
</span>
|
||||
</div>
|
||||
{open
|
||||
? (
|
||||
<ProviderEditor
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
namespace={namespace}
|
||||
settingsPath={target.settingsPath}
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
/>
|
||||
)
|
||||
? renderProviderEditor({
|
||||
target,
|
||||
namespace,
|
||||
api,
|
||||
t,
|
||||
readOnly: !state.writable,
|
||||
onClose: (changed) => { closeEditor(changed, target) },
|
||||
})
|
||||
: null}
|
||||
</li>
|
||||
)
|
||||
@@ -284,7 +375,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => { closeEditor(changed, addTarget) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -299,7 +390,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
api={api}
|
||||
t={t}
|
||||
readOnly={!state.writable}
|
||||
onClose={closeEditor}
|
||||
onClose={(changed) => {
|
||||
setDeclaring(false)
|
||||
if (changed) void controller.load()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -317,6 +411,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
const first = addable[0]
|
||||
/* v8 ignore next -- the button is disabled while nothing is addable */
|
||||
if (first === undefined) return
|
||||
setSavedTarget(undefined)
|
||||
setDeclaring(false)
|
||||
setAdding(true)
|
||||
setEditing(targetOf(first))
|
||||
@@ -330,7 +425,12 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
type="button"
|
||||
className={styles['addButton']}
|
||||
disabled={protocols.length === 0 || !state.writable}
|
||||
onClick={() => { setAdding(false); setEditing(undefined); setDeclaring(true) }}
|
||||
onClick={() => {
|
||||
setSavedTarget(undefined)
|
||||
setAdding(false)
|
||||
setEditing(undefined)
|
||||
setDeclaring(true)
|
||||
}}
|
||||
>
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('customAdd')}
|
||||
@@ -341,9 +441,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<Modal
|
||||
open={deleteTarget !== undefined}
|
||||
onClose={closeDelete}
|
||||
title={t('deleteTitle')}
|
||||
title={deleteTarget === undefined ? '' : providerCopy(t('deleteTitle'), deleteTarget)}
|
||||
closeLabel={t('close')}
|
||||
description={t('deleteDescription')}
|
||||
description={deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(
|
||||
deleteTarget.credentialRef === undefined
|
||||
? t('deleteDescription')
|
||||
: t('deleteDescriptionWithCredential'),
|
||||
deleteTarget,
|
||||
)}
|
||||
className={styles['deleteDialog'] as string}
|
||||
footer={(
|
||||
<>
|
||||
@@ -356,11 +463,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
disabled={deleting}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
{deleting ? t('deleting') : t('deleteConfirm')}
|
||||
{deleteTarget === undefined
|
||||
? ''
|
||||
: providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
>
|
||||
{deleteFailure === undefined ? null : <p className={styles['error']}>{deleteFailure}</p>}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
* field is a single write-only **API key** input (the page never asks for an
|
||||
* environment-variable name — a typed key stores through `credentials.set`
|
||||
* under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
|
||||
* has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
|
||||
* has none. The pi-ai profile records that derivation as `apiKeyEnv` only when
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
|
||||
* DeepSeek's id/name/context-window model catalog). Everything else stays
|
||||
@@ -133,10 +135,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
// The revision this card opened at. A write carrying it is refused if
|
||||
// anything else — another tab, an external edit of settings.yaml — moved the
|
||||
// namespace meanwhile, instead of silently overwriting that change.
|
||||
const [openedAt] = useState(() => namespace.revision)
|
||||
// A settings success advances both retry baselines immediately. Keeping the
|
||||
// derived fields in the draft prevents a pushed namespace refresh from
|
||||
// turning them into deletions when the following credential write is retried.
|
||||
const [committedOriginal, setCommittedOriginal] = useState<unknown>(
|
||||
() => getPath(namespace.user, settingsPath),
|
||||
)
|
||||
const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision)
|
||||
const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
|
||||
const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
|
||||
const fallback = getPath(namespace.value, settingsPath)
|
||||
@@ -194,11 +199,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
const original = getPath(namespace.user, settingsPath)
|
||||
// The pi-ai profile must name the reference the key stores under, so a
|
||||
// dormant add (or a legacy profile without one) records the derivation.
|
||||
const normalizedKey = keyDraft.trim()
|
||||
// A pi-ai profile names the conventional reference only when this page is
|
||||
// about to store a key. Otherwise the provider keeps its native auth path.
|
||||
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
@@ -217,17 +222,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const sectionError = validateDraft(node, next)
|
||||
if (sectionError !== undefined) return sectionError
|
||||
}
|
||||
const ops = pathOps(settingsPath, original, next)
|
||||
const materializesNativeProfile = layout === 'pi-ai'
|
||||
&& fallback === undefined
|
||||
&& committedOriginal === undefined
|
||||
&& Object.keys(next).length === 0
|
||||
const ops: SettingsPathOpView[] = materializesNativeProfile
|
||||
? [{ op: 'set', path: [...settingsPath], value: {} }]
|
||||
: pathOps(settingsPath, committedOriginal, next)
|
||||
if (ops.length > 0) {
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt })
|
||||
const response = await api.settings.mutate({ ns, ops, expectedRevision })
|
||||
if (!response.result.ok) {
|
||||
return response.result.error.code === 'settings-conflict'
|
||||
? t('conflict')
|
||||
: response.result.error.message
|
||||
}
|
||||
setCommittedOriginal(getPath(response.result.value.user, settingsPath))
|
||||
setExpectedRevision(response.result.value.revision)
|
||||
setDraft(next)
|
||||
}
|
||||
if (keyDraft.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
|
||||
if (normalizedKey.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
@@ -286,6 +300,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
const defaultContextWindow = getPath(fallback, ['defaultContextWindow'])
|
||||
const defaultMaxTokens = getPath(fallback, ['maxTokens'])
|
||||
const keyPlaceholder = keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true
|
||||
? t('keyStored')
|
||||
: family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder')
|
||||
/** What both family editors take: the rows, whose layer owns them, and the two writes. */
|
||||
const catalogProps = {
|
||||
models,
|
||||
@@ -306,9 +325,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={keyDraft}
|
||||
placeholder={keyLocked
|
||||
? t('keyEnvLocked')
|
||||
: keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
|
||||
placeholder={keyPlaceholder}
|
||||
aria-label={t('keyInput')}
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
|
||||
@@ -6,23 +6,30 @@ export const en = {
|
||||
title: 'Models',
|
||||
intro: 'Enter your API keys to use models from the following providers.',
|
||||
edit: 'Edit',
|
||||
editProvider: 'Edit {provider}',
|
||||
remove: 'Delete',
|
||||
deleteTitle: 'Delete model provider?',
|
||||
deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.',
|
||||
deleteConfirm: 'Delete provider',
|
||||
deleting: 'Deleting provider…',
|
||||
removeProvider: 'Delete {provider}',
|
||||
deleteTitle: 'Delete {provider}?',
|
||||
deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.',
|
||||
deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.',
|
||||
deleteConfirm: 'Delete {provider}',
|
||||
deleting: 'Deleting {provider}…',
|
||||
add: 'Add provider',
|
||||
provider: 'Provider',
|
||||
close: 'Close',
|
||||
cancel: 'Cancel',
|
||||
apply: 'Apply',
|
||||
applying: 'Applying…',
|
||||
savedProvider: 'Saved {provider}.',
|
||||
credentialConfigured: 'API key configured',
|
||||
credentialMissing: 'API key missing',
|
||||
readOnly: 'The settings document is read-only in this deployment.',
|
||||
loadFailed: 'Loading the provider directory failed',
|
||||
conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.',
|
||||
retry: 'Retry',
|
||||
keyInput: 'API key',
|
||||
keyPlaceholder: 'Enter your API key',
|
||||
keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication',
|
||||
keyStored: 'Configured — enter a new value to replace',
|
||||
keyEnvLocked: 'Provided by the launch environment (read-only)',
|
||||
customized: 'Customized settings',
|
||||
@@ -90,23 +97,30 @@ export const zh: typeof en = {
|
||||
title: '模型',
|
||||
intro: '填入各提供方的 API 密钥即可使用其模型。',
|
||||
edit: '编辑',
|
||||
editProvider: '编辑 {provider}',
|
||||
remove: '删除',
|
||||
deleteTitle: '删除模型提供方?',
|
||||
deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。',
|
||||
deleteConfirm: '删除提供方',
|
||||
deleting: '正在删除提供方…',
|
||||
removeProvider: '删除 {provider}',
|
||||
deleteTitle: '删除 {provider}?',
|
||||
deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。',
|
||||
deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。',
|
||||
deleteConfirm: '删除 {provider}',
|
||||
deleting: '正在删除 {provider}…',
|
||||
add: '添加提供方',
|
||||
provider: '提供方',
|
||||
close: '关闭',
|
||||
cancel: '取消',
|
||||
apply: '保存',
|
||||
applying: '保存中…',
|
||||
savedProvider: '已保存 {provider}。',
|
||||
credentialConfigured: 'API 密钥已配置',
|
||||
credentialMissing: 'API 密钥缺失',
|
||||
readOnly: '当前部署的设置文档为只读。',
|
||||
loadFailed: '加载提供方目录失败',
|
||||
conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。',
|
||||
retry: '重试',
|
||||
keyInput: 'API 密钥',
|
||||
keyPlaceholder: '输入 API 密钥',
|
||||
keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证',
|
||||
keyStored: '已配置——输入新值可替换',
|
||||
keyEnvLocked: '由启动环境提供(只读)',
|
||||
customized: '自定义设置',
|
||||
|
||||
@@ -125,18 +125,6 @@ export class ModelsSettingsStore {
|
||||
*/
|
||||
constructor(private readonly api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>) {}
|
||||
|
||||
/**
|
||||
* Surface a failure from an operation the page ran outside {@link load} —
|
||||
* a row removal — on the same banner a load failure uses.
|
||||
* @param message - the failure text to show.
|
||||
*/
|
||||
fail(message: string): void {
|
||||
this.store.update((s) => {
|
||||
s.status = 'error'
|
||||
s.error = message
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the whole page snapshot: directory and namespaces in parallel,
|
||||
* then one batched credential describe over every referenced ref. A
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('ui-models apply', () => {
|
||||
expect(resolveSlotLabel(entry.options.label)).toBe('模型')
|
||||
const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)()
|
||||
expect(injected.t('nav')).toBe('模型')
|
||||
expect(injected.t('deleteTitle')).toBe('删除模型提供方?')
|
||||
expect(injected.t('deleteTitle')).toBe('删除 {provider}?')
|
||||
expect(typeof injected.controller.load).toBe('function')
|
||||
expect(typeof injected.useSnapshot).toBe('function')
|
||||
expect(injected.api).toBeDefined()
|
||||
@@ -80,10 +80,10 @@ describe('ui-models apply', () => {
|
||||
b.locale.setLocale('en')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models')
|
||||
const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected
|
||||
expect(injected().t('deleteTitle')).toBe('Delete model provider?')
|
||||
expect(injected().t('deleteTitle')).toBe('Delete {provider}?')
|
||||
b.locale.setLocale('zh')
|
||||
expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型')
|
||||
expect(injected().t('deleteTitle')).toBe('删除模型提供方?')
|
||||
expect(injected().t('deleteTitle')).toBe('删除 {provider}?')
|
||||
})
|
||||
|
||||
it('locale change while the slot is undeclared stays a no-op', async () => {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from 'schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
|
||||
import {
|
||||
ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile,
|
||||
} from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx'
|
||||
import { pathOps } from '../src/client/ProviderEditor.tsx'
|
||||
import {
|
||||
@@ -18,6 +20,8 @@ import { en } from '../src/client/locales.ts'
|
||||
afterEach(cleanup)
|
||||
|
||||
const t: ModelsSectionInjected['t'] = key => en[key]
|
||||
const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' }
|
||||
const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET)
|
||||
|
||||
/** Open one row's capacity disclosure (1-based, as the labels read). */
|
||||
function expandRow(position: number): void {
|
||||
@@ -136,11 +140,13 @@ function scriptedFace(overrides: {
|
||||
replace?: ReturnType<typeof vi.fn>
|
||||
mutate?: ReturnType<typeof vi.fn>
|
||||
set?: ReturnType<typeof vi.fn>
|
||||
unset?: ReturnType<typeof vi.fn>
|
||||
} = {}) {
|
||||
const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
|
||||
const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
const face = {
|
||||
llm: {
|
||||
providers: vi.fn(() => Promise.resolve(ok({
|
||||
@@ -170,16 +176,16 @@ function scriptedFace(overrides: {
|
||||
}])),
|
||||
}))),
|
||||
set,
|
||||
unset: vi.fn(() => Promise.resolve(ok({}))),
|
||||
unset,
|
||||
},
|
||||
}
|
||||
return { face, update, replace, mutate, set }
|
||||
return { face, update, replace, mutate, set, unset }
|
||||
}
|
||||
|
||||
type WireFace = ConstructorParameters<typeof ModelsSettingsStore>[0]
|
||||
|
||||
async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
const { face, update, replace, mutate, set } = scriptedFace(overrides)
|
||||
const { face, update, replace, mutate, set, unset } = scriptedFace(overrides)
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
const injected: ModelsSectionInjected = {
|
||||
@@ -189,7 +195,7 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
|
||||
t,
|
||||
}
|
||||
const view = render(<ModelsSection {...injected} />)
|
||||
return { view, face, update, replace, mutate, set, controller }
|
||||
return { view, face, update, replace, mutate, set, unset, controller }
|
||||
}
|
||||
|
||||
describe('ModelsSection', () => {
|
||||
@@ -207,9 +213,36 @@ describe('ModelsSection', () => {
|
||||
expect(screen.getByText('openai')).toBeTruthy()
|
||||
expect(screen.queryByText('Active')).toBeNull()
|
||||
expect(screen.queryByText('Inactive')).toBeNull()
|
||||
const configured = screen.getByRole('img', { name: en.credentialConfigured })
|
||||
expect(configured.getAttribute('title')).toBe(en.credentialConfigured)
|
||||
expect(configured.className).toContain('credentialDotConfigured')
|
||||
expect(configured.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull()
|
||||
expect(screen.getByText(en.add)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => {
|
||||
const { face } = scriptedFace()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])),
|
||||
})))
|
||||
const controller = new ModelsSettingsStore(face as unknown as WireFace)
|
||||
await controller.load()
|
||||
render(<ModelsSection
|
||||
controller={controller}
|
||||
useSnapshot={bindSnapshotSelector(controller.store)}
|
||||
api={face as never}
|
||||
t={t}
|
||||
/>)
|
||||
|
||||
const missing = screen.getByRole('img', { name: en.credentialMissing })
|
||||
expect(missing.getAttribute('title')).toBe(en.credentialMissing)
|
||||
expect(missing.className).toContain('credentialDotMissing')
|
||||
expect(missing.closest('li')?.textContent).toContain('openai')
|
||||
expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull()
|
||||
expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('turns the setup card into a row once the credential reports configured', async () => {
|
||||
const { face } = await mountSection()
|
||||
face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
|
||||
@@ -254,6 +287,13 @@ describe('ModelsSection', () => {
|
||||
expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
|
||||
})
|
||||
|
||||
it('uses one stable provider identity in action copy', () => {
|
||||
const target = { provider: 'deepseek-official', displayName: 'DeepSeek' }
|
||||
expect(providerTargetLabel(target)).toBe('DeepSeek (deepseek-official)')
|
||||
expect(providerCopy(en.deleteTitle, target)).toBe('Delete DeepSeek (deepseek-official)?')
|
||||
expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai')
|
||||
})
|
||||
|
||||
it('names only the fields the card can see, so an unseen secret survives', () => {
|
||||
// `before` is the REDACTED subtree: a stored literal apiKey is in neither
|
||||
// side, so no op mentions it and the seam leaves it alone.
|
||||
@@ -268,11 +308,16 @@ describe('ModelsSection', () => {
|
||||
it('stores a typed key write-only from the setup card without touching settings', async () => {
|
||||
const { set, update, face } = await mountSection()
|
||||
const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.change(key, { target: { value: ' sk-live ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
|
||||
expect((await screen.findByRole('status')).textContent).toBe(
|
||||
providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }),
|
||||
)
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('applies customized deepseek fields as path ops', async () => {
|
||||
@@ -777,6 +822,7 @@ describe('ModelsSection', () => {
|
||||
expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const addKey = keys[keys.length - 1] as HTMLInputElement
|
||||
expect(addKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(addKey, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
@@ -788,6 +834,59 @@ describe('ModelsSection', () => {
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
|
||||
})
|
||||
|
||||
it('keeps pi-ai provider-native authentication when no key is entered', async () => {
|
||||
const { mutate, set } = await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }],
|
||||
expectedRevision: 0,
|
||||
})
|
||||
expect(set).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retries only the credential after refreshed settings already committed', async () => {
|
||||
const committed = wireNamespaces()[2]!
|
||||
const afterSettings: SettingsNamespaceView = {
|
||||
...committed,
|
||||
value: { providers: {
|
||||
...(committed.value as { providers: object }).providers,
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
} },
|
||||
user: { providers: {
|
||||
...(committed.user as { providers: object }).providers,
|
||||
anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
||||
} },
|
||||
revision: 1,
|
||||
}
|
||||
const mutate = vi.fn(() => Promise.resolve(ok(afterSettings)))
|
||||
const set = vi.fn()
|
||||
.mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected'))
|
||||
.mockResolvedValueOnce(ok({}))
|
||||
const { face, controller } = await mountSection({ mutate, set })
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
await screen.findByLabelText(en.provider)
|
||||
const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await screen.findByText('credential store unavailable')
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
face.settings.describe.mockResolvedValue(ok({
|
||||
writable: true,
|
||||
hasDocument: false,
|
||||
namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace),
|
||||
}))
|
||||
await act(async () => { await controller.load() })
|
||||
expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1)
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) })
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' })
|
||||
})
|
||||
|
||||
it('switches the add card target and degrades unknown or broken targets loudly', async () => {
|
||||
await mountSection()
|
||||
fireEvent.click(screen.getByText(en.add))
|
||||
@@ -876,6 +975,7 @@ describe('ModelsSection', () => {
|
||||
fireEvent.change(key, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
await screen.findByText(/shadowed by the read-only environment/)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
|
||||
it('locks the key input when the launch environment provides the credential', async () => {
|
||||
@@ -898,34 +998,37 @@ describe('ModelsSection', () => {
|
||||
fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
|
||||
const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
|
||||
const editorKey = keys[keys.length - 1] as HTMLInputElement
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholder)
|
||||
expect(editorKey.placeholder).toBe(en.keyPlaceholderNative)
|
||||
fireEvent.change(editorKey, { target: { value: 'sk-live' } })
|
||||
fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
|
||||
await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
|
||||
})
|
||||
|
||||
it('requires confirmation before removing a user-added provider', async () => {
|
||||
const { replace, mutate } = await mountSection()
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
expect(dialog.textContent).toContain(en.deleteDescription)
|
||||
const { replace, mutate, unset } = await mountSection()
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential))
|
||||
expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(unset).not.toHaveBeenCalled()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
|
||||
.getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }))
|
||||
.getByRole('button', { name: openaiCopy(en.deleteConfirm) }))
|
||||
await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull()
|
||||
expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number)
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -939,20 +1042,22 @@ describe('ModelsSection', () => {
|
||||
resolveRemoval = resolve
|
||||
}))
|
||||
await mountSection({ mutate })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
const dialog = screen.getByRole('dialog', { name: en.deleteTitle })
|
||||
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: en.deleteConfirm })
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
const confirm = within(dialog).getByRole<HTMLButtonElement>('button', { name: openaiCopy(en.deleteConfirm) })
|
||||
fireEvent.click(confirm)
|
||||
fireEvent.click(confirm)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(confirm.disabled).toBe(true)
|
||||
expect(within(dialog).getByRole<HTMLButtonElement>('button', { name: en.cancel }).disabled).toBe(true)
|
||||
expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm)
|
||||
expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm)
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
|
||||
expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog)
|
||||
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) })
|
||||
await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the load failure with a retry control', async () => {
|
||||
@@ -1057,15 +1162,58 @@ describe('ModelsSection', () => {
|
||||
expect(controller.store.getSnapshot().rows).toBe(before)
|
||||
})
|
||||
|
||||
it('shows a failed removal on the page banner, including a non-Error rejection', async () => {
|
||||
// The whole click path: the row's Remove button, the transport rejecting
|
||||
// with a non-Error value, and the store surfacing it where a load failure
|
||||
// would appear — rather than the row silently staying put.
|
||||
await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) })
|
||||
fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
|
||||
fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle }))
|
||||
.getByRole('button', { name: en.deleteConfirm }))
|
||||
await screen.findByText(`${en.loadFailed}: the host refused`)
|
||||
it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => {
|
||||
const mutate = vi.fn()
|
||||
.mockResolvedValueOnce(fail('the host refused'))
|
||||
.mockResolvedValueOnce(ok(wireNamespaces()[2]!))
|
||||
const { unset } = await mountSection({ mutate })
|
||||
fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) }))
|
||||
const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })
|
||||
const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) })
|
||||
fireEvent.click(confirm)
|
||||
await within(dialog).findByText('the host refused')
|
||||
expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog)
|
||||
expect(unset).toHaveBeenCalledOnce()
|
||||
expect(mutate).toHaveBeenCalledOnce()
|
||||
|
||||
fireEvent.click(confirm)
|
||||
await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) })
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('retains credentials that are not identified as page-managed', async () => {
|
||||
const { unset, mutate } = await mountSection()
|
||||
const target = { provider: 'zombie', displayName: 'zombie' }
|
||||
fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) }))
|
||||
const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) })
|
||||
expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) }))
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
expect(unset).not.toHaveBeenCalled()
|
||||
expect(mutate.mock.calls[0]?.[0]).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'unset', path: ['providers', 'zombie'] }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not remove provider settings when its managed credential removal is refused', async () => {
|
||||
const { face, controller, mutate } = await mountSection({
|
||||
unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))),
|
||||
})
|
||||
const failure = await removeProviderProfile(
|
||||
face as unknown as Parameters<typeof removeProviderProfile>[0],
|
||||
controller,
|
||||
{
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'openai'],
|
||||
credentialRef: 'OPENAI_API_KEY',
|
||||
},
|
||||
)
|
||||
expect(failure).toBe('credential is read-only')
|
||||
expect(mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a transport rejection instead of failing the removal silently', async () => {
|
||||
|
||||
@@ -142,7 +142,7 @@ async function mountSection(options: Parameters<typeof scriptedFace>[0] = {}) {
|
||||
t,
|
||||
}
|
||||
render(<ModelsSection {...injected} />)
|
||||
return scripted
|
||||
return { ...scripted, controller }
|
||||
}
|
||||
|
||||
/** Open the editor of one configured row and expand its customized fold. */
|
||||
@@ -862,4 +862,20 @@ describe('hand-declared providers', () => {
|
||||
await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() })
|
||||
expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reloads the section after creating a hand-declared provider', async () => {
|
||||
const { controller, mutate } = await mountSection()
|
||||
const load = vi.spyOn(controller, 'load')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: en.customAdd }))
|
||||
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
|
||||
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
|
||||
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
|
||||
fireEvent.click(screen.getByText(en.create))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() })
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledOnce() })
|
||||
expect(screen.queryByText(en.customTitle)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user