Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
ab59020
docs(webui): the engine layer has six files, not five (webui-parity 107)
fengzhi09 Oct 1, 2026
1dc559a
test(webui): M2 capability-declaration snapshot vs the real host (eng…
fengzhi09 Oct 1, 2026
8c085fa
test(webui): point the capability snapshot at the engine layer's real…
fengzhi09 Oct 1, 2026
aa5ab47
fix(webui): stop the shell from carrying one session's state into ano…
fengzhi09 Oct 1, 2026
af01e0e
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
f1842ba
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
726d286
refactor(webui): the plugins and turn-diff routes take the host from …
fengzhi09 Oct 1, 2026
a4fad96
test(webui): make the run-mirror, first-turn-guard and mavis-usage su…
fengzhi09 Oct 1, 2026
e7c0ce9
feat(webui): the five read endpoints ask the engine facade, not the t…
fengzhi09 Oct 1, 2026
e053ae7
feat(webui): the session-tree and export endpoints ask the engine fac…
fengzhi09 Oct 1, 2026
4fb8267
feat(webui): the usage endpoints ask the engine facade, and the deriv…
fengzhi09 Oct 1, 2026
88b9a48
fix(webui): rebase M3-B3 onto M3-B2, register B2's two tmp prefixes, …
fengzhi09 Oct 1, 2026
6bc24bd
feat(webui): the account, model and capability reads ask the engine f…
fengzhi09 Oct 2, 2026
eb2a429
feat(webui): #73 swaps the ACP wire table for the 14-key engine-capab…
fengzhi09 Oct 2, 2026
2baf051
fix(webui): stop two B4 comments describing behaviour the code no lon…
fengzhi09 Oct 2, 2026
edf2b1e
feat(webui): move the session write family behind the engine facade
fengzhi09 Oct 2, 2026
1506cc2
fix(webui): drop whitespace text nodes in markdown tables and dedupe …
fengzhi09 Oct 2, 2026
8cca235
fix(webui): sweep the non-flipping inverted text token off primary su…
fengzhi09 Oct 2, 2026
eecd8c0
feat(webui): move session switch behind the engine facade
fengzhi09 Oct 2, 2026
0cfd51f
Merge main into dev-lhl
fengzhi09 Oct 2, 2026
e4cf052
chore: allowlist the leak-tripwire fixture in model-reads tests
fengzhi09 Oct 2, 2026
3f5b8d2
test(webui): pin session-writes cleanup-orphans test to isolated paths
fengzhi09 Oct 2, 2026
e7df93d
chore: ignore gitleaks fingerprints of deliberate test fixtures
fengzhi09 Oct 2, 2026
62814ff
chore: make the gitleaks fixture allowlists path-only
fengzhi09 Oct 2, 2026
3074010
feat(webui): move interrupt and load endpoints behind the engine facade
fengzhi09 Oct 3, 2026
063a43a
fix(webui): take the plan's 5s abort force-kill bound by product call
fengzhi09 Oct 3, 2026
90cf85e
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
a9af820
docs(webui): add session-switch, interrupt and session-load to the ar…
fengzhi09 Oct 3, 2026
4d904c3
docs(webui): add the missing zh-CN section for the B5 write family
fengzhi09 Oct 3, 2026
fdc3ff2
fix(webui): make webui-only session delete return promptly instead of…
fengzhi09 Oct 3, 2026
dab453d
fix(webui): retire lossy streaming mirrors when the engine transcript…
fengzhi09 Oct 3, 2026
7138b5b
feat(webui): add the streaming-send capability gate and pure stream b…
fengzhi09 Oct 3, 2026
a2223f4
feat(webui): run send on the runtime transport behind the engine facade
fengzhi09 Oct 3, 2026
8b51fdd
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
964c0cf
feat(webui): answer set-mode and set-config-option with structured 50…
fengzhi09 Oct 3, 2026
bdde1eb
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
a112e45
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
245a101
docs(webui): add the streaming-send architecture section, bilingual
fengzhi09 Oct 3, 2026
d9e181d
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
679d0fe
docs(webui): add the streaming-send architecture section, bilingual
fengzhi09 Oct 3, 2026
a078ee6
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
591ccff
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
b529543
fix(local-runtime): make an abandoned migration lease recoverable at …
fengzhi09 Oct 3, 2026
a8e56dc
feat(webui): move model and permission writes behind the engine facade
fengzhi09 Oct 3, 2026
48199c5
Reset dev-lhl to the full local integration line (B9+B10+docs+P13+P14…
fengzhi09 Oct 3, 2026
5661bb9
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
4b5e8d2
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
9fdd8d1
fix(webui): surface truncated acp stderr in failure alerts
fengzhi09 Oct 3, 2026
5427f23
feat(webui): move the provider family behind the engine facade with s…
fengzhi09 Oct 3, 2026
afa995e
fix(webui): acknowledge in-flight messages explicitly instead of echo…
fengzhi09 Oct 3, 2026
6c30484
fix(webui): normalise the expected side of the provider cwd path asse…
fengzhi09 Oct 3, 2026
e68a8df
feat(webui): bridge thinkingEffort as the third config id and gate th…
fengzhi09 Oct 3, 2026
1473183
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
15a3f42
feat(webui): register the acp transport as the first engine capabilit…
fengzhi09 Oct 3, 2026
313286a
fix(webui): keep over-tall code blocks inside their scroll container
fengzhi09 Oct 3, 2026
506c0a9
feat(webui): replace the flat provider form with the desktop-style di…
fengzhi09 Oct 3, 2026
25da27a
docs(webui): document the provider dialog interaction, bilingual
fengzhi09 Oct 3, 2026
38befac
feat(webui): route session deletion through the engine deleteSession …
fengzhi09 Oct 3, 2026
06a0e8e
feat(webui): open the host services window for capability exposure ba…
fengzhi09 Oct 3, 2026
60e5361
feat(webui): register the exec transport in the engine capability reg…
fengzhi09 Oct 3, 2026
64914d7
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
382c10c
fix(webui): escape raw svg tags in markdown output instead of mountin…
fengzhi09 Oct 3, 2026
bc49315
fix(webui): consume the real stream-json events on the exec transport
fengzhi09 Oct 3, 2026
35f1e0c
feat(webui): unlock the session context menu actions backed by the en…
fengzhi09 Oct 3, 2026
2b3a9e4
test(webui): register the PB-1 real-host tmp prefix
fengzhi09 Oct 3, 2026
f9829b3
chore(release-tools): register the mcode-exec-stream- tmp prefix
fengzhi09 Oct 3, 2026
b5bad19
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
33629d7
feat(webui): wire the context-window usage switch to the composer rea…
fengzhi09 Oct 3, 2026
c10736d
feat(webui): unlock the project menu's reveal-in-folder (SB-6)
fengzhi09 Oct 3, 2026
92abe74
feat(webui): make the Shortcuts page state what the browser can do
fengzhi09 Oct 3, 2026
595c06d
feat(webui): plan card reads the account tier, honest cloud placeholders
fengzhi09 Oct 3, 2026
d087f28
feat(webui): wire the usage-and-models model source to the engine (SB-1)
fengzhi09 Oct 3, 2026
43d0b2a
dev-lhl: SB-3/6/2/7/1 + SB-5 + P19 + SB-4 (settings waves, delete-han…
fengzhi09 Oct 3, 2026
c96e7a2
fix(webui): re-read the account after a source switch, and stop the k…
fengzhi09 Oct 3, 2026
c290440
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
2250209
dev-lhl: SB-10 (DOM harness) + PB-3 (worktree page) + docs stale-pare…
fengzhi09 Oct 3, 2026
8fbb8c3
dev-lhl: P21 locale-independent worktree discovery + docs stale fix
fengzhi09 Oct 3, 2026
9806700
fix(webui): stop the built distribution from tripping the credential …
fengzhi09 Oct 3, 2026
0755124
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
5442e4f
dev-lhl: carry P22's model-source.js (the blob the incremental push m…
fengzhi09 Oct 3, 2026
9e3ffbb
Merge main into dev-lhl
fengzhi09 Oct 3, 2026
20fba11
dev-lhl: SB-9 desktop notifications (D-4)
fengzhi09 Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -1348,6 +1348,46 @@ clients:
| `file_line_wrap` | `"true"` | **Yes.** On wraps over-wide lines; off scrolls horizontally. Covers both code-file previews (ticket 48) and markdown codeblocks — chat messages, activity groups and markdown file previews (ticket 52); the language label never wraps. Applies to previews opened / messages mounted after the switch (an already-open one does not reflow); a wrapped file-preview line's gutter number aligns with its first visual row — a known trade-off |
| `webui-context-window-usage` | `"false"` | **Yes.** On draws the context-window readout in the composer's toolbar, immediately left of the model chip; off renders nothing there. The readout's own form is unchanged — the ring, the percentage, the breakdown and the plan rows all come from the session snapshot as before. Flipping the switch takes effect without a reload |
| `webui-follow-up-behavior` | `"queue"` (or `"off"`, `"steer"`) | No. Decides what a send does while a turn runs; `"off"` is webui's own third position (the reference has two) |
| `webui-desktop-notifications` | `"false"` | **Yes.** On raises a browser notification when a turn finishes, when a tool needs a decision, or when a turn fails — and only while this page is in the background. See **Desktop notifications (SB-9)** below |

**Desktop notifications (SB-9)**

The 桌面通知 row on the General page is the one switch in the 应用 block that
is neither an OS integration nor a placeholder. The decision about *whether*
to notify is a pure function over four facts in
`webapp/lib/desktop-notify.ts#shouldShowDesktopNotification` — the stored
switch, the browser's live `Notification.permission`, whether the browser
supports the API at all, and `document.visibilityState`. The browser calls
live at the edges of that module and nowhere else.

| Question | Answer | Why |
| --- | --- | --- |
| Which events notify? | turn settled, a `needs_authorization` request arrived, an `error`-level anomaly landed on the alerts stream | Each already arrives on a stream this client subscribes to, so no new server channel was added. Subagent completion and plan review are deliberately not separate triggers: both arrive as `needs_authorization`, and a fourth trigger would be a fourth thing to get wrong |
| When is a notification suppressed? | whenever `document.visibilityState === "visible"`, for **all three** kinds | A notification that duplicates something already on screen is noise. Each of the three has an on-screen face: the finished conversation is in the tab in front of the user, the decision prompt is a blocking modal, and the failure is already in the alerts badge and the action-error banner. The reference has no such rule because it has no page to be looking at |
| What happens when a turn fails? | one notification, not two | The error and the settle arrive on **two different SSE connections** (alerts vs. events) and nothing orders two HTTP responses, so either order is possible. The error is remembered against its session and a completion on that session inside a 5 s window is swallowed; outside the window the stale entry is ignored, so it cannot suppress an unrelated turn later. The entry is consumed on use |
| What about a tab opened over a busy server? | the alerts opening snapshot is history, not news | `GET /api/alerts` opens with a ring buffer of up to 200 pre-load alerts. `lib/alerts.ts#historySealed` reports whether that frame has arrived, and until it has nothing in the buffer is announced. Told by the frame's own kind, never guessed from whether the list happens to be empty — an empty history and an unwatched first failure are identical in a list |
| What does the click do? | focuses the browser window, then switches to the named session if it is not the one on screen | The reference's click target is a tab in its own tab strip. Here one browser tab is one conversation, so the browser window *is* the tab; `app/page.tsx` registers that behaviour with `registerDesktopNotifyFocusHandler` rather than letting the notifier reach into `lib/api` |
| What does the switch do? | turning it **on** is the permission request; it latches only on `granted` | A switch that reads ON while the browser has refused is a dishonest state, and it would notify nobody. Turning it off never asks anything |
| What is shown after a refusal? | the row's own hint gains a second sentence naming the refusal and the way out, in the status colour | The permission is read live on every mount of the settings page and is never persisted — a stored `granted` would outlive the user revoking it in the browser's site settings. Reopening the settings page is therefore enough to see the refusal |
| Is the permission ever stored? | no | `Notification.permission` is owned by the browser and revocable outside the page |

`Notification.requestPermission()` is only ever called from the switch's
`onChange` — never at load. A permission prompt raised before the user has
expressed any intent is the pattern browsers penalise, and the switch *is*
the request. The default is therefore `"false"`: a profile that never touched
the switch asks for nothing and shows nothing.

**How to tell it works**

1. Settings → General → 桌面通知, turn it on, accept the browser prompt.
2. Switch to another browser tab (or window) while a turn is running.
3. On the settle you get one notification; clicking it brings the window back
and lands on that session.
4. Raise a turn failure (an unknown `/cmd` produces an `[chat.send]`
anomaly): you get the failure notification, and **not** a second
"finished" one.
5. Revoke the permission in the browser's site settings and reopen the
settings page: the switch reads off and the refusal sentence is there.

**The context-window readout**

Expand Down Expand Up @@ -2765,6 +2805,7 @@ Invariants worth keeping when touching either branch:
| `file_line_wrap` | `localStorage` | `webapp/lib/settings-local.ts` | tickets 48 + 52 | bare `"true"\|"false"` string, reference-shared namespace; default `"true"`; read per mount by `components/code-view.tsx` (code-file previews) and `components/markdown-html.tsx` (markdown codeblocks: chat, activity groups, file previews) |
| `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 | bare `"true"\|"false"` string, reference-shared namespace; default `"false"`; read at mount and followed live by `components/context-meter.tsx` through `subscribeContextWindowUsage` |
| `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | ticket 48 / SB-4 | bare `"off"\|"queue"\|"steer"` string (anything else reads as `"queue"`), reference-shared namespace; read by `components/composer.tsx` and republished on every write |
| `webui-desktop-notifications` | `localStorage` | `webapp/lib/settings-local.ts` | SB-9 | bare `"true"\|"false"` string; default `"false"`; written by the General page's 桌面通知 row, published on every write for `subscribeDesktopNotifications`. Stores the user's **intent only** — the browser's `Notification.permission` is read live from `webapp/lib/desktop-notify.ts` and is never persisted |
| `webui-shortcut-bindings` | `localStorage` | `webapp/lib/shortcuts.ts` | ticket 55c (settings Shortcuts page) | `{"global-search":"Ctrl+Shift+P", …}` — rebindings of the **live** shortcut rows only, written when the user records a new combination and removed entirely when the last one is cleared. Re-validated against the registry on read: a stored id that is no longer dispatched, or a chord that no longer parses, is dropped rather than honoured, so a hand-edited entry cannot widen what the page dispatches. Read at every keydown by `app/page.tsx` (through `effectiveBindings`) and once per mount by the settings page |
| `webui:project-custom:v1` | `localStorage` | `webapp/lib/project-custom.ts` | ticket 55c (project context menu) | `{version:1, titles:{<projectKey>:<customName>}, pinned:[<projectKey>]}`. **Deliberately not cid-namespaced**: a rename or a pin describes the project, not a browser session, so every tab of this browser shares it. Best-effort write, silent failure; a project's entries are cleared when its remove completed with every session deleted |

Expand Down
37 changes: 37 additions & 0 deletions docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1117,6 +1117,42 @@ slice 22 增强:
| `file_line_wrap` | `true` | **是**。开启时超宽行自动折行;关闭时横向滚动。覆盖两类表面:代码文件预览(工单 48)与 markdown 代码块——聊天消息、活动组、markdown 文件预览(工单 52);语言标签不随代码行折行。对之后打开的预览/之后挂载的消息生效(已打开的不重排);文件预览折行后行号与第二视觉行不对齐,是已知取舍 |
| `webui-context-window-usage` | `false` | **是**。开启时在输入区工具栏(紧挨模型选择器左侧)绘制上下文用量计量;关闭时该处不渲染任何内容。计量本身的形态不变——圆环、百分比、分类明细、套餐各行仍照旧来自会话快照。拨动开关无需刷新页面即生效 |
| `webui-follow-up-behavior` | `queue`(可选 `off`、`steer`) | 否。决定回合运行中发送的去向;`off` 是 webui 自有的第三态(参照只有两态) |
| `webui-desktop-notifications` | `false` | **是**。开启时在回合结束、工具等待授权确认、回合失败三种情形下弹出浏览器通知,且仅在本页处于后台时弹出。详见下文**桌面通知(SB-9)** |

**桌面通知(SB-9)**

通用页「应用」分区里的「桌面通知」是该分区唯一一个既非操作系统集成、
也非占位的开关。是否弹出的判断是
`webapp/lib/desktop-notify.ts#shouldShowDesktopNotification` 里的纯函数,
输入只有四个事实:存储的开关、浏览器实时的 `Notification.permission`、
浏览器是否支持该 API、以及 `document.visibilityState`。所有浏览器调用
只出现在该模块的边界上,别处一律不碰。

| 问题 | 结论 | 理由 |
| --- | --- | --- |
| 哪些事件会通知 | 回合结束、`needs_authorization` 授权请求到达、告警流上落下一条 `error` 级异常 | 三者本来就各自落在本客户端已订阅的流上,因此没有新增任何服务端通道。子代理完成与计划评审**有意不单列**:它们都以 `needs_authorization` 的形式到达,再加第四个触发点只是多一个可能出错的地方 |
| 什么情况下不弹 | `document.visibilityState === "visible"` 时**三种一律不弹** | 重复屏幕上已有内容的通知就是噪音。三种情形在屏幕上都有对应的落点:结束的会话就在用户正看着的标签页里,等待确认是阻塞式弹窗,失败已经进了告警徽标与操作错误横幅。桌面参照没有这条规则,因为它没有「用户正在看的页面」这回事 |
| 回合失败会弹几条 | 一条,不是两条 | 错误与回合结束走的是**两条不同的 SSE 连接**(告警流与事件流),两个 HTTP 响应之间没有任何顺序保证,所以两种到达顺序都可能。错误按会话记下,落在 5 秒窗口内的结束通知被吞掉;超出窗口的陈旧记录直接忽略,不会压掉一小时后的另一个回合。记录用后即消费 |
| 在繁忙服务端上打开新标签页会怎样 | 告警流的首帧快照是历史,不是新消息 | `GET /api/alerts` 打开时先下发一个最多 200 条的环形缓冲快照。`lib/alerts.ts#historySealed` 报告该帧是否已到,未到之前缓冲里的内容一律不播报。只依据帧自身的种类判断,绝不靠列表是否为空来猜——「空历史」与「第一条未被观看的失败」在一个列表里长得一模一样 |
| 点击通知做什么 | 先聚焦浏览器窗口;若通知指向的会话不是当前会话,再切过去 | 桌面参照的点击目标是它自己标签条里的某个标签页。本客户端一个浏览器标签页就是一个会话,所以浏览器窗口**就是**那个标签页;该行为由 `app/page.tsx` 通过 `registerDesktopNotifyFocusHandler` 注册,通知模块自己不伸手去调 `lib/api` |
| 开关做什么 | **打开开关即发起授权请求**,且只有在浏览器返回 `granted` 时才真正落到 ON | 浏览器已经拒绝而开关显示 ON 是不诚实状态,而且它一条也弹不出来。关闭开关不会发起任何请求 |
| 被拒绝后显示什么 | 该行原有的说明文字下多出第二句,用状态色写明「已拒绝」与解法 | 权限在设置页每次挂载时实时读取,且从不落盘——存下来的 `granted` 会比用户在浏览器站点设置里的撤销活得更久。因此重新打开设置页就能看到拒绝态 |
| 权限会被持久化吗 | 不会 | `Notification.permission` 归浏览器所有,且可在页面之外撤销 |

`Notification.requestPermission()` 只在开关的 `onChange` 里被调用,
绝不会在加载时自动弹出。在用户尚未表达意图前索要权限正是浏览器会惩罚的
模式,而开关本身就是那个请求。因此默认值是 `false`:从未碰过这个开关的
配置既不询问也不弹任何东西。

**如何验证它真的工作**

1. 设置 → 通用 → 桌面通知,打开开关,在浏览器弹窗中同意。
2. 回合运行中把页面切到另一个浏览器标签页(或窗口)。
3. 回合结束时收到一条通知;点击后窗口回到前台,并落在那个会话上。
4. 制造一次回合失败(一条无法识别的 `/cmd` 会产生 `[chat.send]` 告警):
收到失败通知,且**不会**再多出一条「已完成」。
5. 在浏览器站点设置里撤销通知权限后重新打开设置页:开关显示为关,
且拒绝文案就在该行说明里。

**上下文用量计量**

Expand Down Expand Up @@ -2042,6 +2078,7 @@ loading-states 相同:让 SSR 渲染测试可以脱离 `chat.tsx` 的 `@/` 别
| `file_line_wrap` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 + 52 | 纯 `"true"\|"false"` 字符串,参照共享命名;默认 `"true"`;每次挂载读取方为 `components/code-view.tsx`(代码文件预览)与 `components/markdown-html.tsx`(markdown 代码块:聊天、活动组、文件预览) |
| `webui-context-window-usage` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 | 纯 `"true"\|"false"` 字符串,参照共享命名;默认 `"false"`;`components/context-meter.tsx` 挂载时读取一次,并通过 `subscribeContextWindowUsage` 实时跟随 |
| `webui-follow-up-behavior` | `localStorage` | `webapp/lib/settings-local.ts` | 工单 48 / SB-4 | 纯 `"off"\|"queue"\|"steer"` 字符串(其他值读取为 `"queue"`),参照共享命名;由 `components/composer.tsx` 读取,每次写入都会重新发布 |
| `webui-desktop-notifications` | `localStorage` | `webapp/lib/settings-local.ts` | SB-9 | 纯 `"true"\|"false"` 字符串;默认 `"false"`;由通用页「桌面通知」行写入,每次写入都发布给 `subscribeDesktopNotifications`。只存用户的**意图**——浏览器的 `Notification.permission` 由 `webapp/lib/desktop-notify.ts` 实时读取,从不落盘 |
| `webui-shortcut-bindings` | `localStorage` | `webapp/lib/shortcuts.ts` | 工单 55c(设置快捷键页) | `{"global-search":"Ctrl+Shift+P", …}`——**已生效**行的改键记录,用户录入新组合时写入,清掉最后一条时整个键删除。读取时按注册表重新校验:已不再分发的行 id、或已无法解析的组合一律丢弃,手工改过的存储项无法借此扩大页面的分发面。`app/page.tsx` 每次键盘事件经 `effectiveBindings` 读取,设置页每次挂载读取一次 |
| `webui:project-custom:v1` | `localStorage` | `webapp/lib/project-custom.ts` | 工单 55c(项目右键菜单) | `{version:1, titles:{<项目key>:<自定义名>}, pinned:[<项目key>]}`。**不按 cid 命名空间**(有意):重命名与置顶描述的是项目本身而非某个浏览器会话,同一浏览器的所有标签页共享。写入尽力而为,失败静默;项目被完整移除(全部会话删除成功)时同步清除其条目 |

Expand Down
7 changes: 1 addition & 6 deletions packages/webui/server/engine/model-source.js
Original file line number Diff line number Diff line change
Expand Up @@ -310,14 +310,9 @@ export function publicApiKeyStatus(status) {
const record = status && typeof status === "object" ? status : {};
const cached = record.cachedStatus && typeof record.cachedStatus === "object" ? record.cachedStatus : {};
const lastTested = typeof cached.lastTestedAt === "number" ? cached.lastTestedAt : null;
// Destructured, not read as `record.hasApiKey` in the projection: the bundler
// renames `record` to a generated identifier, and a `hasKey: <renamed>.<member>`
// pair reads to the credential scanner as `hasKey = <16+ char secret>` inside the
// bundled distribution, failing the release audit on a boolean comparison.
const { hasApiKey } = record;
return {
available: true,
hasKey: hasApiKey === true,
hasKey: record.hasApiKey === true,
masked: typeof record.maskedApiKey === "string" ? record.maskedApiKey : null,
testState: typeof cached.state === "string" ? cached.state : null,
lastTestedAtMs: lastTested,
Expand Down
30 changes: 29 additions & 1 deletion packages/webui/webapp/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { Composer } from "@/components/composer";
import { TranscriptSkeleton } from "@/components/loading-states";
import { Modals } from "@/components/modals";
import { ActionErrorBanner } from "@/components/action-error-banner";
// SB-9:桌面通知的唯一挂载点(见该组件文件头的分工说明)。
import { DesktopNotifySync } from "@/components/desktop-notify-sync";
import { registerDesktopNotifyFocusHandler } from "@/lib/desktop-notify";
// Settings modal port (webui-parity 58): the reference SettingsModal
// structure; the shim keeps this import shape unchanged.
import { SettingsModal } from "@/components/settings-modal-port";
Expand All @@ -17,7 +20,7 @@ import { WorkspaceColumns } from "@/components/workspace-columns";
import { PreviewColumn, PreviewColumnMounted } from "@/components/workspace-tabs";
import { TreeColumn } from "@/components/workspace-tree-column";
import { runAction } from "@/lib/action-errors";
import { SessionProvider, useSessionContext } from "@/lib/store";
import { SessionProvider, getActiveSessionId, useSessionContext } from "@/lib/store";
import { decodeTranscript } from "@/lib/transcript";
import { useLocale } from "@/lib/use-locale";
import {
Expand Down Expand Up @@ -212,6 +215,30 @@ function App() {
return nextTabs;
});
},
[], );

/**
* SB-9 — what a desktop notification's click does.
*
* The page root is the only place that knows how a session switch is
* performed, so it registers that behaviour with `lib/desktop-notify.ts`
* rather than letting the notifier reach into `lib/api` itself. The contract
* is one sentence: bring the named session into view, and do nothing when it
* is already the one on screen (a notification for the current session is
* still worth clicking — it raises the browser window — but it must not
* re-issue a switch that would reload the conversation).
*
* The desktop reference's click target is a tab in its own tab strip. webui
* has no equivalent: one browser tab is one conversation, so "the tab" is the
* browser window, which `lib/desktop-notify.ts` focuses before calling this.
*/
useEffect(
() =>
registerDesktopNotifyFocusHandler((sessionId) => {
if (!sessionId) return;
if (getActiveSessionId() === sessionId) return;
void api.switchSession(sessionId);
}),
[],
);

Expand Down Expand Up @@ -797,6 +824,7 @@ function App() {
</div>
) : null}
<ActionErrorBanner t={t} />
<DesktopNotifySync />
<Modals t={t} />
</>
);
Expand Down
Loading
Loading