Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into feat/desktop-update

winewill 5 дней назад
Родитель
Сommit
72f5fcc905
82 измененных файлов с 2631 добавлено и 84 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml
  2. 0 1
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
  3. 0 1
      .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md
  4. 6 0
      .agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml
  5. 74 0
      .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
  6. 74 0
      .agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md
  7. 15 0
      apps/web/tests/clickable-links-gallery.e2e.ts
  8. 5 0
      apps/web/tests/expected/clickable-links-gallery/ui.expected.md
  9. 0 2
      apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
  10. 12 11
      apps/web/tests/markdown-inline-code-links.e2e.ts
  11. 119 0
      apps/web/tests/sidebar-browser.e2e.ts
  12. 1 1
      apps/web/tests/sidebar-right.e2e.ts
  13. 1 0
      apps/web/tsconfig.json
  14. 2 2
      docs/config-catalog.i18n.yaml
  15. 1 0
      docs/config-catalog.md
  16. 1 0
      docs/config-catalog.zh.md
  17. 2 2
      docs/module-graph.i18n.yaml
  18. 2 0
      docs/module-graph.md
  19. 2 0
      docs/module-graph.zh.md
  20. 2 2
      docs/subsystems/sidebar-right.i18n.yaml
  21. 4 3
      docs/subsystems/sidebar-right.md
  22. 4 3
      docs/subsystems/sidebar-right.zh.md
  23. 5 1
      packages/bundle/web-app/cordis.patch.yml
  24. 1 0
      packages/bundle/web-app/package.json
  25. 2 2
      packages/client/README.i18n.yaml
  26. 1 0
      packages/client/README.md
  27. 1 0
      packages/client/README.zh.md
  28. 2 2
      packages/client/ui-chat/README.i18n.yaml
  29. 1 1
      packages/client/ui-chat/README.md
  30. 1 1
      packages/client/ui-chat/README.zh.md
  31. 1 0
      packages/client/ui-chat/package.json
  32. 8 0
      packages/client/ui-chat/src/client/apply.ts
  33. 23 21
      packages/client/ui-chat/src/client/chat/ChatView.tsx
  34. 2 0
      packages/client/ui-chat/src/client/contract/slots.ts
  35. 41 2
      packages/client/ui-chat/tests/apply-inject.client.spec.tsx
  36. 1 1
      packages/client/ui-chat/tests/chat-apply.client.spec.tsx
  37. 1 0
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  38. 3 0
      packages/client/ui-chat/tsconfig.json
  39. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  40. 2 2
      packages/client/ui-primitives/README.md
  41. 2 2
      packages/client/ui-primitives/README.zh.md
  42. 2 0
      packages/client/ui-primitives/src/index.ts
  43. 38 0
      packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx
  44. 6 4
      packages/client/ui-primitives/src/markdown/MarkdownText.tsx
  45. 20 5
      packages/client/ui-primitives/src/markdown/render.tsx
  46. 53 2
      packages/client/ui-primitives/tests/markdown.client.spec.tsx
  47. 6 0
      packages/client/ui-sidebar-browser/README.i18n.yaml
  48. 117 0
      packages/client/ui-sidebar-browser/README.md
  49. 117 0
      packages/client/ui-sidebar-browser/README.zh.md
  50. 66 0
      packages/client/ui-sidebar-browser/package.json
  51. 166 0
      packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts
  52. 98 0
      packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts
  53. 174 0
      packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts
  54. 38 0
      packages/client/ui-sidebar-browser/src/client/browser/store.ts
  55. 45 0
      packages/client/ui-sidebar-browser/src/client/browser/url.ts
  56. 26 0
      packages/client/ui-sidebar-browser/src/client/definition.tsx
  57. 46 0
      packages/client/ui-sidebar-browser/src/client/index.ts
  58. 61 0
      packages/client/ui-sidebar-browser/src/client/locales.ts
  59. 167 0
      packages/client/ui-sidebar-browser/src/client/view/Browser.module.css
  60. 155 0
      packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx
  61. 17 0
      packages/client/ui-sidebar-browser/src/client/view/BrowserTitle.tsx
  62. 4 0
      packages/client/ui-sidebar-browser/src/css-modules.d.ts
  63. 4 0
      packages/client/ui-sidebar-browser/src/index.ts
  64. 82 0
      packages/client/ui-sidebar-browser/tests/apply.client.spec.ts
  65. 231 0
      packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx
  66. 142 0
      packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts
  67. 41 0
      packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts
  68. 69 0
      packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts
  69. 32 0
      packages/client/ui-sidebar-browser/tests/browser-title.client.spec.tsx
  70. 21 0
      packages/client/ui-sidebar-browser/tests/store.client.spec.ts
  71. 52 0
      packages/client/ui-sidebar-browser/tests/url.client.spec.ts
  72. 21 0
      packages/client/ui-sidebar-browser/tsconfig.json
  73. 3 0
      packages/client/ui-sidebar-browser/tsdown.config.ts
  74. 1 1
      packages/client/ui-sidebar-right/src/client/contract/params.ts
  75. 7 5
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  76. 51 0
      pnpm-lock.yaml
  77. 1 0
      scripts/verify-package-readme-model-experience.ts
  78. 9 0
      snapshots/web/sidebar-browser/browser.expected.md
  79. 9 0
      snapshots/web/sidebar-browser/snapshot.yml
  80. 2 0
      tsconfig.base.json
  81. 1 0
      tsconfig.client.json
  82. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md
-2026-09-05-sidebar-tab-types-and-navigation.md: 24332c844d13c41e4f966f39aa7d2fe79fd8553f
-2026-09-05-sidebar-tab-types-and-navigation.zh.md: 3aeb38e2ec9c997b9aaf040ae0cb575fa08fa235
+2026-09-05-sidebar-tab-types-and-navigation.md: 834a26e55f87e5db54d57f06fec592e4fdb9f61c
+2026-09-05-sidebar-tab-types-and-navigation.zh.md: 9884db9eef49162e8883c390dd71bdbf9d45489f

+ 0 - 1
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.md

@@ -116,6 +116,5 @@ The conversation's `openFile(path, { line? })` — tool-row path links, produced
 ## Deferred
 
 - A navigation protocol beyond `sidebar://<kind>`: sub-routes within a page, naming an implementation, and the ecosystem-facing rules for other navigation schemes.
-- Parameters for the shipped page types, which today declare none.
 - Opening into a session other than the one on screen from the public face, which acts on the mounted session only; a tab's own actions already act on their tab's session.
 - A localized message when an open fails from the conversation; the failure is currently the thrown error's text.

+ 0 - 1
.agents/notes/implemented/architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md

@@ -116,6 +116,5 @@ interface SidebarRightTabParamsMap {}        // key: kind — a page type declar
 ## Deferred
 
 - `sidebar://<kind>` 之外的导航协议:页内子路由、点名实现、以及面向生态的其它导航 scheme 规则。
-- 随包页类型的参数,今天未声明任何。
 - 从公开面往屏上会话之外的会话里打开;公开面只作用于已挂载的会话,而 tab 自己的动作已作用于其所在会话。
 - 从会话区打开失败时的本地化提示;目前是抛错文本本身。

+ 6 - 0
.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
+2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c
+2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e

+ 74 - 0
.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md

@@ -0,0 +1,74 @@
+# Agent Note: Sandboxed Sidebar browser
+
+Status: implemented
+
+English | [中文](2026-09-16-sidebar-browser.zh.md)
+
+## Problem
+
+The right Sidebar can preview addressed workspace files, but it has no independent surface for visiting a Web page. Opening a page outside the application loses the Sidebar's split, float, and tab lifecycle. Treating an arbitrary page like Document Preview content also obscures a different trust model: a Web page controls a live browsing context, while a document renderer receives bytes selected for one preview.
+
+A parent page cannot inspect or drive a cross-origin iframe's internal history. The supported behavior must not imply that capability.
+
+## Decision
+
+`@deepseek-ai/dsh-client-ui-sidebar-browser` registers the multi-instance `browser` right-Sidebar tab type. `SidebarRightTabParamsMap.browser` accepts an optional initial URL so another Client plugin can open a Browser without importing this package's runtime values.
+
+`MarkdownDelegateProvider` gives nested Markdown anchors an optional owner callback for ordinary HTTP(S) activation while retaining native modified-click behavior. Chat places one provider around its node list and opens a new `browser` tab with the URL as typed navigation parameters when that type is registered, or uses the system browser otherwise; the Markdown renderer does not import the Browser feature.
+
+The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface.
+
+The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy.
+
+Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component.
+
+`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record.
+
+Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities.
+
+## Web navigation states
+
+The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored.
+
+| State | Entry | Address and controls |
+|---|---|---|
+| `empty` | The tab has no controlled target. | The address is empty. Back, Forward, Reload, and external-open are disabled. |
+| `loading` | Address submission, an application-history move, or Reload starts a new revision. | The requested URL remains authoritative. Back and Forward follow application-history bounds; Reload remains available; external-open follows the known target protocol. |
+| `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. |
+| `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. |
+
+Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable.
+
+## Deferred Electron carrier
+
+Electron `<webview>` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`.
+
+The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins.
+
+The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied.
+
+The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `<webview>` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization.
+
+Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target.
+
+## Alternatives considered
+
+**Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning.
+
+**Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support.
+
+**Support `file:` URLs in Browser.** Rejected because local files already belong to Document Preview, while browser navigation has a different trust model. Browser refuses the protocol instead of acquiring filesystem or Workspace Files access.
+
+**Proxy Web pages through the Host.** Rejected because a compatible proxy would have to rewrite URLs, CSP, cookies, modules, streams, forms, and downloads while turning the Host into a general outbound requester.
+
+**Implement the Electron carrier in the initial Browser change.** Deferred so the first implementation does not enable a new Electron guest surface without packaged-app evidence for overlay stacking, target lifetime, cookie isolation, and every permission denial.
+
+## Verification
+
+Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal.
+
+## Consequences
+
+The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior.
+
+Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage.

+ 74 - 0
.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md

@@ -0,0 +1,74 @@
+# Agent Note: 沙箱化 Sidebar 浏览器
+
+Status: implemented
+
+[English](2026-09-16-sidebar-browser.md) | 中文
+
+## Problem
+
+右侧 Sidebar 可以预览已寻址的工作区文件,但没有用于访问网页的独立界面。在应用外打开会丢失 Sidebar 的分栏、浮动与 tab 生命周期。把任意网页当成 Document Preview 内容,也会混淆两种不同的信任模型:网页控制一个活跃浏览上下文,而文档 renderer 只接收为一次预览选定的字节。
+
+父页面不能检查或驱动跨域 iframe 的内部 history。支持的行为不能暗示具备这种能力。
+
+## Decision
+
+`@deepseek-ai/dsh-client-ui-sidebar-browser` 注册可多开的右侧 Sidebar `browser` tab 类型。`SidebarRightTabParamsMap.browser` 接受可选初始 URL,使其他 Client 插件无须导入本包运行时值即可打开 Browser。
+
+`MarkdownDelegateProvider` 为嵌套的 Markdown anchor 提供可选 owner callback,用于委托普通 HTTP(S) 点击,同时保留带修饰键点击的原生行为。Chat 在 node list 外放置一个 Provider;已注册该类型时,它以 URL 作为 typed navigation 参数打开新的 `browser` tab,否则使用系统浏览器;Markdown renderer 不导入 Browser feature。
+
+地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。
+
+当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。
+
+每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。
+
+`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。
+
+Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。
+
+## Web 导航状态
+
+对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。
+
+| 状态 | 进入条件 | 地址与控件 |
+|---|---|---|
+| `empty` | tab 没有受控目标。 | 地址为空;后退、前进、刷新与外部打开均禁用。 |
+| `loading` | 地址提交、应用 history 移动或刷新启动新的 revision。 | 请求 URL 仍是权威地址;后退和前进遵循应用 history 范围;刷新保持可用;外部打开遵循已知目标的协议。 |
+| `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 |
+| `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 |
+
+每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。
+
+## Deferred Electron carrier
+
+Electron `<webview>` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。
+
+Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。
+
+主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。
+
+view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `<webview>` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。
+
+每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。
+
+## Alternatives considered
+
+**增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。
+
+**把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。
+
+**在 Browser 中支持 `file:` URL。** 不采用,因为本地文件已由 Document Preview 负责,而 browser 导航使用不同的信任模型。Browser 直接拒绝该协议,不获取文件系统或 Workspace Files 能力。
+
+**通过 Host 代理网页。** 不采用,因为兼容代理必须重写 URL、CSP、Cookie、module、stream、form 与 download,同时会把 Host 变成通用出站请求器。
+
+**在首个 Browser 变更中实现 Electron 载体。** 延期处理,避免在没有打包应用证据覆盖 overlay stacking、target lifetime、Cookie 隔离与全部 permission 拒绝路径时启用新的 Electron guest surface。
+
+## Verification
+
+单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。
+
+## Consequences
+
+Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。
+
+站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。

+ 15 - 0
apps/web/tests/clickable-links-gallery.e2e.ts

@@ -50,6 +50,7 @@ const DONE = 'LINK_GALLERY_DONE'
 const GALLERY_TIME = Date.UTC(2026, 0, 15, 12)
 
 const GUIDE_URL = 'https://docs.example.test/guide'
+const HTTP_URL = 'http://docs.example.test/plain'
 const API_URL = 'https://docs.example.test/api'
 const RELEASES_URL = 'https://docs.example.test/releases'
 const MAILTO_URL = 'mailto:owner@example.test'
@@ -260,6 +261,8 @@ function galleryFixture(imageUrl: string): string {
         `Docs: [style guide](${GUIDE_URL}) and \`${API_URL}\`; see [the release notes][rel], `
         + `contact [the maintainer](${MAILTO_URL}), and check the fine print[^1].`,
         '',
+        `Preview: [plain HTTP](${HTTP_URL}).`,
+        '',
         `Upstream: [the repository](${REPO_URL}).`,
         '',
         `Inert contrasts: \`curl ${API_URL}\`, \`javascript:alert(1)\`, and \`pnpm run build\`.`,
@@ -315,6 +318,10 @@ describe('web e2e: clickable links gallery', () => {
     await seedSession(scaffold, galleryFixture(imageUrl), SEED_ID, undefined, { createdAt: GALLERY_TIME })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
+    await page.route(/https?:\/\/docs\.example\.test\/.*/u, async route => route.fulfill({
+      contentType: 'text/html',
+      body: `<h1>${new URL(route.request().url()).pathname}</h1>`,
+    }))
     await page.clock.setFixedTime(GALLERY_TIME + 60_000)
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
@@ -342,6 +349,7 @@ describe('web e2e: clickable links gallery', () => {
     const markdown = page.locator('[class*="markdown"]')
     await expect.poll(() => markdown.locator(`a[href="${GUIDE_URL}"]`).count(), { timeout: 10_000 }).toBe(1)
     expect(await markdown.locator(`a[href="${RELEASES_URL}"]`).count()).toBe(1)
+    expect(await markdown.locator(`a[href="${HTTP_URL}"]`).count()).toBe(1)
     expect(await markdown.locator(`a[href="${MAILTO_URL}"]`).count()).toBe(1)
     const inlineCodeLink = markdown.locator(`code a[href="${API_URL}"]`)
     expect(await inlineCodeLink.count()).toBe(1)
@@ -439,5 +447,12 @@ describe('web e2e: clickable links gallery', () => {
     expect(await styleOf(mentions.first(), 'text-decoration-style')).toBe('dotted')
     // The excluded grey affordance: tool-row file links keep their own color.
     expect(await styleOf(page.locator('button[class*="fileLink"]').first(), 'color')).not.toBe(LINK_BLUE)
+
+    // Ordinary message HTTP(S) links delegate to the right Sidebar Browser.
+    await guideLink.click()
+    const browserAddress = page.locator('[data-rightbar-col]').getByRole('textbox', { name: 'Enter an HTTP(S) address' })
+    await expect.poll(() => browserAddress.inputValue()).toBe(GUIDE_URL)
+    await markdown.locator(`a[href="${HTTP_URL}"]`).click()
+    await expect.poll(() => browserAddress.inputValue()).toBe(HTTP_URL)
   }, 90_000)
 })

+ 5 - 0
apps/web/tests/expected/clickable-links-gallery/ui.expected.md

@@ -142,6 +142,11 @@
   - text: ", and check the fine print"
   - superscript: "1"
   - text: .
+- paragraph:
+  - text: "Preview:"
+  - link "plain HTTP":
+    - /url: http://docs.example.test/plain
+  - text: .
 - paragraph:
   - text: "Upstream:"
   - link "the repository":

+ 0 - 2
apps/web/tests/expected/markdown-inline-code-links/ui.expected.md

@@ -3,8 +3,6 @@
     - button "Inline code links" [disabled]
   - button "More actions":
     - img
-  - button "Open right sidebar":
-    - img
   - tablist:
     - tab "Chat" [selected]
     - tab "Trajectory"

+ 12 - 11
apps/web/tests/markdown-inline-code-links.e2e.ts

@@ -22,6 +22,7 @@ const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-inline-code-links
 const MODE = webSnapshotMode()
 const SEED_ID = 'markdown-inline-code-links-web-e2e'
 const DONE = 'INLINE_CODE_LINK_DONE'
+const LINK_URL = 'http://127.0.0.1:3199/?demo=1'
 
 /** Build a settled assistant reply with linkable URL code and inert code controls. */
 function markdownFixture(linkUrl: string): string {
@@ -88,15 +89,17 @@ describe('web e2e: Markdown inline-code links', () => {
   let scaffold: WebScaffold
   let browser: Browser
   let page: Page
-  let linkUrl: string
   let tripwire: ReturnType<typeof watchConsole>
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
-    linkUrl = new URL('/?demo=1', scaffold.baseUrl).toString()
-    await seedSession(scaffold, markdownFixture(linkUrl), SEED_ID)
+    await seedSession(scaffold, markdownFixture(LINK_URL), SEED_ID)
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
+    await page.route('http://127.0.0.1:3199/**', async route => route.fulfill({
+      contentType: 'text/html',
+      body: '<h1>Inline-code preview</h1>',
+    }))
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -119,24 +122,22 @@ describe('web e2e: Markdown inline-code links', () => {
 
     const inlineCodeLink = page.locator('[class*="markdown"] code a')
     await expect.poll(() => inlineCodeLink.count(), { timeout: 10_000 }).toBe(1)
-    expect(await inlineCodeLink.getAttribute('href')).toBe(linkUrl)
+    expect(await inlineCodeLink.getAttribute('href')).toBe(LINK_URL)
     expect(await inlineCodeLink.getAttribute('target')).toBe('_blank')
     expect(await inlineCodeLink.getAttribute('rel')).toBe('noopener noreferrer')
     await inlineCodeLink.focus()
     expect(await inlineCodeLink.evaluate(element => document.activeElement === element)).toBe(true)
 
-    const popupPromise = page.waitForEvent('popup')
     await inlineCodeLink.click()
-    const popup = await popupPromise
-    await popup.waitForURL(linkUrl, { timeout: 15_000 })
-    expect(popup.url()).toBe(linkUrl)
-    await popup.close()
+    const browserAddress = page.locator('[data-rightbar-col]')
+      .getByRole('textbox', { name: 'Enter an HTTP(S) address' })
+    await expect.poll(() => browserAddress.inputValue()).toBe(LINK_URL)
 
-    expect(await page.getByText(`curl ${linkUrl}`, { exact: true }).locator('a').count()).toBe(0)
+    expect(await page.getByText(`curl ${LINK_URL}`, { exact: true }).locator('a').count()).toBe(0)
     expect(await page.getByText('javascript:alert(1)', { exact: true }).locator('a').count()).toBe(0)
     const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
-      .split(linkUrl).join('{{linkUrl}}')
+      .split(LINK_URL).join('{{linkUrl}}')
     await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])

+ 119 - 0
apps/web/tests/sidebar-browser.e2e.ts

@@ -0,0 +1,119 @@
+/** Keyless Browser-tab coverage through the shipped Web composition. */
+import { mkdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import type {} from '@deepseek-ai/dsh-agent'
+import {
+  assertFixtureInventory,
+  compareOrRefreshGolden,
+  launchWebScaffold,
+  watchConsole,
+  webSnapshotMode,
+  type WebScaffold,
+} from './scaffold.ts'
+import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
+
+const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
+const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/sidebar-browser', import.meta.url))
+const EXPECTED = join(SNAPSHOT_DIR, 'browser.expected.md')
+const MODE = webSnapshotMode()
+const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
+
+describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({ replayFixture: FIXTURE, compareReplaySession: false })
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    await page.route('https://browser.test/**', async (route) => {
+      const name = new URL(route.request().url()).pathname.slice(1) || 'one'
+      await route.fulfill({
+        contentType: 'text/html',
+        body: `<h1>${name}</h1><a href="/inside">Inside navigation</a><p>isolated HTTPS fixture</p>`,
+      })
+    })
+    await page.route('http://127.0.0.1:3080/**', async route => route.fulfill({
+      contentType: 'text/html',
+      body: '<h1>loopback</h1>',
+    }))
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await connectFreshWorkspace(page, scaffold.workspaceCwd)
+  })
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('navigates sandboxed HTTP(S) with application-known history', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-browser'))
+    const settled = scaffold.whenTurnSettled()
+    const composer = page.locator('[data-composer-input]').first()
+    await composer.fill(PROMPT)
+    await composer.press('Enter')
+    await settled
+    await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
+    const column = page.locator('[data-rightbar-col]')
+    await page.locator('[data-sidebar-right-expand]').click()
+    await column.locator('[data-sidebar-right-guide-entry="browser"]').click()
+    const input = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' })
+    await input.fill('https://browser.test/one')
+    await input.press('Enter')
+    const frame = column.locator('[data-sidebar-browser-frame]')
+    await frame.waitFor({ state: 'visible' })
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
+    expect(await frame.getAttribute('allow')).toBeNull()
+    await column.getByRole('button', { name: 'Disable sandbox restrictions' }).click()
+    await expect.poll(() => frame.getAttribute('sandbox')).toBeNull()
+    await column.getByText('Sandbox restrictions are disabled; the page can navigate the top-level app and use downloads, modal dialogs, and input locks.', { exact: true }).waitFor()
+    await column.getByRole('button', { name: 'Restore sandbox restrictions' }).click()
+    await expect.poll(() => frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('link', { name: 'Inside navigation' }).click()
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'inside' }).waitFor()
+    await column.getByText('URL changed', { exact: true }).waitFor()
+    expect(await column.getByRole('button', { name: 'Back', exact: true }).isDisabled()).toBe(true)
+    expect(await column.getByRole('button', { name: 'Forward', exact: true }).isDisabled()).toBe(true)
+    expect(await column.getByRole('button', { name: 'Open in system browser', exact: true }).isDisabled()).toBe(true)
+    await column.getByRole('button', { name: 'Reload' }).click()
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
+    await input.fill('https://browser.test/two')
+    await input.press('Enter')
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor()
+    await column.getByRole('button', { name: 'Back', exact: true }).click()
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
+    await column.getByRole('button', { name: 'Forward', exact: true }).click()
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor()
+
+    await input.fill('http://127.0.0.1:3080/preview')
+    await input.press('Enter')
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'loopback' }).waitFor()
+    expect(await frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
+
+    await input.fill('file:///work/index.html')
+    await input.press('Enter')
+    const blocked = await column.getByRole('alert').innerText()
+    expect(blocked).toBe('Only HTTP and HTTPS addresses are supported; use Document Preview for local files.')
+    expect(tripwire.pageErrors).toEqual([])
+    expect(tripwire.warnings).toEqual([])
+    await mkdir(SNAPSHOT_DIR, { recursive: true })
+    await compareOrRefreshGolden(EXPECTED, [
+      '# Sidebar Browser', '',
+      '- HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox',
+      '- Permissions Policy: browser defaults',
+      '- Sandbox toggle: per-tab and temporary',
+      '- Unknown navigation: marker shown; Back, Forward, and external-open disabled',
+      '- HTTPS history: one -> two -> one -> two',
+      '- Loopback HTTP: loaded under the default sandbox',
+      `- Invalid protocol: ${blocked}`,
+    ].join('\n'), MODE)
+    await assertFixtureInventory(SNAPSHOT_DIR, ['browser.expected.md'])
+  })
+})

+ 1 - 1
apps/web/tests/sidebar-right.e2e.ts

@@ -407,7 +407,7 @@ describe('web e2e: shipped right Sidebar', () => {
       }
 
       await expect.poll(async () => await tabTitles(column)).toEqual(['Start'])
-      await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(2)
+      await expect.poll(async () => await column.locator('[data-sidebar-right-guide-entry]').count()).toBe(3)
       await column.locator('[data-sidebar-right-guide-entry="files"]').click()
 
       // A manual guide is closable beside Files and suppresses another add

+ 1 - 0
apps/web/tsconfig.json

@@ -46,6 +46,7 @@
     "tests/details-session-lifecycle.e2e.ts",
     "tests/document-preview.e2e.ts",
     "tests/office-fixture.ts",
+    "tests/sidebar-browser.e2e.ts",
     "tests/plugin-config.e2e.ts",
     "tests/plugin-manager.e2e.ts",
     "tests/plugin-install-cancel.e2e.ts",

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 3adaaf047e7b3b439045d5bb4edae3ebb46ab391
-config-catalog.zh.md: c86cad7986e6c0041f675650e517f5d821be88a1
+config-catalog.md: 631ce6a34b120f44534a7935ff976662287fa879
+config-catalog.zh.md: 8d72786a79ceff85e35aff72fa6622af7373a3b4

+ 1 - 0
docs/config-catalog.md

@@ -3812,6 +3812,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-ui-settings-plugins` ([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions` ([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar` ([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-browser` ([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files` ([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right` ([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-terminal` ([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))

+ 1 - 0
docs/config-catalog.zh.md

@@ -3814,6 +3814,7 @@ export interface Config {
 - `@deepseek-ai/dsh-client-ui-settings-plugins`([`packages/client/ui-settings-plugins/src/index.ts`](../packages/client/ui-settings-plugins/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-settings-unarchive-sessions`([`packages/client/ui-settings-unarchive-sessions/src/index.ts`](../packages/client/ui-settings-unarchive-sessions/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar`([`packages/client/ui-sidebar/src/index.ts`](../packages/client/ui-sidebar/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-sidebar-browser`([`packages/client/ui-sidebar-browser/src/index.ts`](../packages/client/ui-sidebar-browser/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-files`([`packages/client/ui-sidebar-files/src/index.ts`](../packages/client/ui-sidebar-files/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-right`([`packages/client/ui-sidebar-right/src/index.ts`](../packages/client/ui-sidebar-right/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-sidebar-terminal`([`packages/client/ui-sidebar-terminal/src/index.ts`](../packages/client/ui-sidebar-terminal/src/index.ts))

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: b89bb220fa37e70b83fe879adee71f8d042b0c9a
-module-graph.zh.md: 7eff2d9faeea8e49d675ad1894b481cd0e1d0ade
+module-graph.md: 8ec3673173c299fdd69134af1002ba157d6db870
+module-graph.zh.md: 1dd43514acff155c92c139dca7ad519e137e2a03

+ 2 - 0
docs/module-graph.md

@@ -182,6 +182,7 @@ flowchart TD
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
     pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
+    pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
@@ -1363,6 +1364,7 @@ flowchart TD
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
+| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |

+ 2 - 0
docs/module-graph.zh.md

@@ -184,6 +184,7 @@ flowchart TD
     pkg_client_ui_settings_plugins["client-ui-settings-plugins"]
     pkg_client_ui_settings_unarchive_sessions["client-ui-settings-unarchive-sessions"]
     pkg_client_ui_sidebar["client-ui-sidebar"]
+    pkg_client_ui_sidebar_browser["client-ui-sidebar-browser"]
     pkg_client_ui_sidebar_documentpreview["client-ui-sidebar-documentpreview"]
     pkg_client_ui_sidebar_files["client-ui-sidebar-files"]
     pkg_client_ui_sidebar_right["client-ui-sidebar-right"]
@@ -1365,6 +1366,7 @@ flowchart TD
 | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | — |
 | [`client-ui-settings-unarchive-sessions`](../packages/client/ui-settings-unarchive-sessions) | `client` | — |
 | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | — |
+| [`client-ui-sidebar-browser`](../packages/client/ui-sidebar-browser) | `client` | — |
 | [`client-ui-sidebar-documentpreview`](../packages/client/ui-sidebar-documentpreview) | `client` | — |
 | [`client-ui-sidebar-files`](../packages/client/ui-sidebar-files) | `client` | — |
 | [`client-ui-sidebar-right`](../packages/client/ui-sidebar-right) | `client` | — |

+ 2 - 2
docs/subsystems/sidebar-right.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/sidebar-right.md
-sidebar-right.md: 6b7cc4a29535e5f69dcc42e3ab82f445d830c806
-sidebar-right.zh.md: ef41287ec6465af469467e45b48e9a3e9ffc5c47
+sidebar-right.md: 470b72118ee680f84489d13668e74c0eede652b0
+sidebar-right.zh.md: 62481c4024e6647a7e620a9c8e72ed6d0c969a60

+ 4 - 3
docs/subsystems/sidebar-right.md

@@ -19,7 +19,7 @@ A tab type is two registrations that share the definition's `id`: a static defin
 | [`client/resources`](../../packages/client/resources/README.md) | `ctx.resources`, `useResource`, the protocol → value roster `ResourceProtocolMap` |
 | [`api/workspace-files`](../../packages/api/workspace-files/README.md) | Host `ctx.workspaceFiles`, the `workspaceFiles` Remote namespace, and the Client `file` resource provider |
 | [`util/workspace-path`](../../packages/util/workspace-path/README.md) | The file address grammar: `fileAddressFor`, `parseFileAddress` |
-| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md) | The shipped `text` and `files` types |
+| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.md), [`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.md), [`client/ui-sidebar-browser`](../../packages/client/ui-sidebar-browser/README.md) | The shipped `text`, `files`, and `browser` types |
 
 ## Addresses
 
@@ -72,7 +72,7 @@ export function apply(ctx: Context): void {
 
 ## Navigation: `ctx.sidebarRight`
 
-Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
+Two opens are the navigation controller, and every way into the column calls one of them: `openResource(address, options?)` for a `dsh-resource://` address — the conversation's file links, a tool row's line reference, a file tree's rows — and `openTab(kind, options?)` for a page — the strip's add control, a guide entry box, or an HTTP(S) link in Assistant Markdown. Both run four steps as one history entry — claim (the registry ranks the resource's types, or the named `kind`'s implementation in force answers); focus a tab already showing the same `(kind, address)`; otherwise seat a new tab; expand the column — and then record the navigation in the Tab domain ([service](../../packages/client/ui-sidebar-right/README.md#ctxsidebarright)). Content the user cannot see is not opened, so a collapsed column expands in the same step. `openResource` throws for an address outside `dsh-resource://` or one no type claims; `openTab` throws for a kind nothing registered: both are wiring mistakes, not user errors.
 
 | Option | Meaning |
 |---|---|
@@ -135,6 +135,7 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name
 - **`guide`** — `builtin`, opened as `openTab('guide')`. A muted compass sits above one capsule per contributed `guide` entry, in `order`; short lists show registered descriptions, and every missing icon uses the shipped placeholder. Picking a capsule opens the contributing type as a page in the guide tab's place. A pane holds at most one guide tab, and the strip's add control appears only while its pane has none. A new pane receives the registered default page: the sole guide entry directly, or the guide when the entry count is not one ([guide](../../packages/client/ui-sidebar-right/README.md#the-guide)).
 - **`text`** — `fallback`, `dsh-resource://file/**`, claiming Session addresses only. Document Preview observes metadata through `useResource<'file'>`, loads content through Remote callbacks, and owns renderer selection, the toolbar, per-tab refresh, scroll, and source navigation; unknown extensions render as plain text ([README](../../packages/client/ui-sidebar-documentpreview/README.md)).
 - **`files`** — `builtin`, opened as `openTab('files')`. The workspace directory tree, listed lazily through `list`, opening a file with `tab.actions.openResource(fileAddressFor(sessionId, root, path))` into its own pane ([README](../../packages/client/ui-sidebar-files/README.md)).
+- **`browser`** — multi-instance `builtin`, opened as `openTab('browser', { params: { url? } })`. Assistant Markdown delegates HTTP(S) links to this page type. It accepts public and loopback HTTP(S) targets under the default sandbox, rejects local files in favor of Document Preview, and uses application-known iframe history ([README](../../packages/client/ui-sidebar-browser/README.md)).
 
 <a id="not-built"></a>
 ## Not built
@@ -147,4 +148,4 @@ The Host `ctx.workspaceFiles` service and generated `workspaceFiles` Remote name
 - Naming a tab implementation when opening: `openResource` names a kind at most; document-renderer selection belongs to the file tab's toolbar.
 - An address lookup on the service (`find`): a caller opens with `revealIfOpened` and lets the surface de-duplicate.
 - Navigation addresses beyond the Sidebar's own `sidebar://<kind>` bookkeeping; their grammar waits for the navigation controller as a whole.
-- A user-facing undo, a content navigation stack, and tab icons ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)).
+- A user-facing undo and general cross-type content navigation stack ([deferred](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md#deferred)); Browser owns only its own page history.

+ 4 - 3
docs/subsystems/sidebar-right.zh.md

@@ -19,7 +19,7 @@
 | [`client/resources`](../../packages/client/resources/README.zh.md) | `ctx.resources`、`useResource`、协议 → 值类型的花名册 `ResourceProtocolMap` |
 | [`api/workspace-files`](../../packages/api/workspace-files/README.zh.md) | Host `ctx.workspaceFiles`、`workspaceFiles` Remote 命名空间与 Client `file` 资源提供者 |
 | [`util/workspace-path`](../../packages/util/workspace-path/README.zh.md) | 文件地址语法:`fileAddressFor`、`parseFileAddress` |
-| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md) | 内置的 `text` 与 `files` 类型 |
+| [`client/ui-sidebar-documentpreview`](../../packages/client/ui-sidebar-documentpreview/README.zh.md)、[`client/ui-sidebar-files`](../../packages/client/ui-sidebar-files/README.zh.md)、[`client/ui-sidebar-browser`](../../packages/client/ui-sidebar-browser/README.zh.md) | 内置的 `text`、`files` 与 `browser` 类型 |
 
 ## 地址
 
@@ -72,7 +72,7 @@ export function apply(ctx: Context): void {
 
 ## 导航:`ctx.sidebarRight`
 
-两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
+两种打开构成导航控制器,进入这一列的每条路都调用其一:`openResource(address, options?)` 打开 `dsh-resource://` 地址——会话区的文件链接、工具行的行号引用、文件树的行;`openTab(kind, options?)` 打开页面——tab 条的新增控件、引导页入口框或 Assistant Markdown 中的 HTTP(S) 链接。两者都以一条历史记录走完四步——认领(注册表为资源排候选,或点名 `kind` 的生效实现应答);聚焦已显示同一 `(kind, address)` 的 tab;否则落一个新 tab;展开这一列——然后把导航记入 Tab 域([服务](../../packages/client/ui-sidebar-right/README.zh.md#ctxsidebarright))。用户看不见的内容不算打开,所以折叠的列会在同一步展开。`openResource` 对 `dsh-resource://` 之外的地址或无人认领的地址抛错;`openTab` 对无人注册的 kind 抛错:二者都是接线错误,不是用户错误。
 
 | 选项 | 含义 |
 |---|---|
@@ -135,6 +135,7 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
 - **`guide`**——`builtin`,以 `openTab('guide')` 打开。一枚弱化的罗盘位于各类型按 `order` 贡献的入口胶囊上方;入口较少时显示已注册的描述,未提供图标的入口统一使用内置占位符。点选胶囊即在引导 tab 的位置把贡献它的类型作为页面打开。每个 pane 最多一个引导 tab,tab 条的新增控件只在本 pane 没有引导时出现。新 pane 使用已注册的默认页:只有一个引导入口时直接使用该入口,否则使用引导页([引导](../../packages/client/ui-sidebar-right/README.zh.md#the-guide))。
 - **`text`**——`fallback`,`dsh-resource://file/**`,只认领 Session 地址。Document Preview 通过 `useResource<'file'>` 观察元数据,经 Remote 回调加载内容,并拥有渲染器选择、工具栏、逐 tab 刷新、滚动与源码定位;未知扩展名按纯文本渲染([README](../../packages/client/ui-sidebar-documentpreview/README.zh.md))。
 - **`files`**——`builtin`,以 `openTab('files')` 打开。工作区目录树,经 `list` 懒加载,用 `tab.actions.openResource(fileAddressFor(sessionId, root, path))` 在自己所在 pane 打开文件([README](../../packages/client/ui-sidebar-files/README.zh.md))。
+- **`browser`**——可多开的 `builtin`,以 `openTab('browser', { params: { url? } })` 打开。Assistant Markdown 会把 HTTP(S) 链接委托给该页面类型。它在默认 sandbox 下接受公共与 loopback HTTP(S) 目标,本地文件改用 Document Preview,并使用应用已知的 iframe history([README](../../packages/client/ui-sidebar-browser/README.zh.md))。
 
 <a id="not-built"></a>
 ## 不做
@@ -147,4 +148,4 @@ Host 的 `ctx.workspaceFiles` 服务与生成的 `workspaceFiles` Remote 命名
 - 打开时点名某个 tab 实现:`openResource` 最多点名一个 kind;文档渲染器由文件 tab 的工具栏选择。
 - 服务上的地址查找(`find`):调用方用 `revealIfOpened` 打开,由停靠面去重。
 - Sidebar 自身 `sidebar://<kind>` 记账之外的导航地址;其语法等导航控制器整体做时再定。
-- 面向用户的撤销、内容导航栈与 tab 图标([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred))。
+- 面向用户的撤销与跨类型通用内容导航栈([暂缓](../../.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md#deferred));Browser 只拥有自身页面历史

+ 5 - 1
packages/bundle/web-app/cordis.patch.yml

@@ -233,9 +233,13 @@
     - id: ui-sidebar-documentpreview
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 
-    # The right Sidebar's workspace file tree tab type.
+    # Sandboxed HTTP(S) Browser tabs, including loopback targets.
+    - id: ui-sidebar-browser
+      name: '@deepseek-ai/dsh-client-ui-sidebar-browser'
+
     - id: ui-sidebar-terminal
       name: '@deepseek-ai/dsh-client-ui-sidebar-terminal'
+    # The right Sidebar's workspace file tree tab type.
     - id: ui-sidebar-files
       name: '@deepseek-ai/dsh-client-ui-sidebar-files'
 

+ 1 - 0
packages/bundle/web-app/package.json

@@ -85,6 +85,7 @@
     "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings-unarchive-sessions": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-files": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^",

+ 2 - 2
packages/client/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/README.md
-README.md: f8146ceafa179508fcc77718c1ecc91bd459d3d0
-README.zh.md: f8db7644a2164c892c4fa2d92aef836ecae9e7e5
+README.md: 3a1a54f801090456a1038d0e3d59fe5223374f3c
+README.zh.md: 231449ab574224a3e4fc5436c57e9ece5b0183b5

+ 1 - 0
packages/client/README.md

@@ -45,6 +45,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
 | [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation | — |
 | [`ui-sidebar-right/`](ui-sidebar-right/README.md) | Owns the right Sidebar and its tab types | `ctx.sidebarRight`, `ctx.sidebarRightTabs` |
 | [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.md) | Displays documents in right Sidebar tabs | `ctx.documentPreviews` |
+| [`ui-sidebar-browser/`](ui-sidebar-browser/README.md) | Browses sandboxed HTTP(S) pages, including loopback services, in right-Sidebar tabs | — |
 | [`resources/`](resources/README.md) | Unified resource model: protocol providers behind the `useResource` session standard hook | `ctx.resources` |
 | [`ui-sidebar-files/`](ui-sidebar-files/README.md) | Right-Sidebar workspace file tree tab type | — |
 | [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks | — |

+ 1 - 0
packages/client/README.zh.md

@@ -45,6 +45,7 @@ kind: "package-group"
 | [`ui-sidebar/`](ui-sidebar/README.zh.md) | 展示工作区与会话导航 | — |
 | [`ui-sidebar-right/`](ui-sidebar-right/README.zh.md) | 管理右侧 Sidebar 及其 tab 类型 | `ctx.sidebarRight`, `ctx.sidebarRightTabs` |
 | [`ui-sidebar-documentpreview/`](ui-sidebar-documentpreview/README.zh.md) | 在右侧 Sidebar 的 tab 中显示文档 | `ctx.documentPreviews` |
+| [`ui-sidebar-browser/`](ui-sidebar-browser/README.zh.md) | 在右侧 Sidebar tab 中浏览 sandboxed HTTP(S) 页面,包括 loopback 服务 | — |
 | [`resources/`](resources/README.zh.md) | 统一资源模型:`useResource` 会话标准钩子背后的协议提供方 | `ctx.resources` |
 | [`ui-sidebar-files/`](ui-sidebar-files/README.zh.md) | 右侧 Sidebar 的工作区文件树 tab 类型 | — |
 | [`ui-brand-official/`](ui-brand-official/README.zh.md) | 用官方名称与标记填充通用浏览器品牌 slot | — |

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 9217ff678690f6eb659bb6b7c3a808d97a26171b
-README.zh.md: 1ac5159a466c61ddada5b55e62354d6bfb1e6652
+README.md: b5314413df8195834152c354df89c25600afc63b
+README.zh.md: 23288907f6f0dd4dec457ac56e403b050b6ef4e5

+ 1 - 1
packages/client/ui-chat/README.md

@@ -29,7 +29,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
 <a id="reference-previews"></a>
 ## Reference previews
 
-Sent file references and skills confirmed by the message’s logged invocation open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
+HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordinary clicks when that type is registered, or the system browser otherwise; modified clicks retain the native external-link behavior. Sent file references and skills confirmed by the message’s logged invocation also open in the right Sidebar. File paths use the viewed Session; skill names resolve through its current input-trigger source. Both use the prose file-link dotted underline on hover or focus. Sessions, directories, and command labels remain non-navigating references.
 
 <a id="system-prompt-row"></a>
 ## System prompt row

+ 1 - 1
packages/client/ui-chat/README.zh.md

@@ -29,7 +29,7 @@ kind: "package-reference"
 <a id="reference-previews"></a>
 ## 引用预览
 
-已发送的文件引用及消息日志确认调用的 skill 可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
+Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注册时打开新的右侧 Sidebar Browser tab,否则改用系统浏览器;带修饰键的点击保留原生外部链接行为。已发送的文件引用及消息日志确认调用的 skill 可在右侧栏打开预览。文件路径使用当前查看的 Session;skill 名称由该 Session 当前的输入触发源解析。两者悬停或聚焦时均使用正文文件链接的虚线下划线。会话、目录和命令标签仍只作为引用展示。
 
 <a id="system-prompt-row"></a>
 ## 系统提示词行

+ 1 - 0
packages/client/ui-chat/package.json

@@ -69,6 +69,7 @@
     "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
     "@deepseek-ai/dsh-client-ui-session": "workspace:^",
     "@deepseek-ai/dsh-client-ui-settings": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": "workspace:^",
     "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",

+ 8 - 0
packages/client/ui-chat/src/client/apply.ts

@@ -6,6 +6,7 @@ import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/cli
 import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client'
 import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client'
 // The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below.
 import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client'
@@ -142,6 +143,13 @@ export function apply(ctx: Context): void {
             if (scope === undefined) return
             ctx.get('inputTriggers')?.sessionOf(scope).openReference('skill', { ref: `/${name}` })
           },
+          openExternalLink: (url) => {
+            if (ctx.get('sidebarRightTabs')?.get('browser') !== undefined) {
+              ctx.sidebarRight.openTab('browser', { params: { url } })
+            } else {
+              window.open(url, '_blank', 'noopener,noreferrer')
+            }
+          },
           loadOlder: () => { void session.loadOlder() },
           loadThrough: seq => session.loadThrough(seq),
           loadImage: Object.assign(

+ 23 - 21
packages/client/ui-chat/src/client/chat/ChatView.tsx

@@ -7,7 +7,7 @@ import type {
 } from '@deepseek-ai/dsh-client-ui-conversation/client'
 import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
 import type { InboxState } from '@deepseek-ai/dsh-agent/types'
-import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
+import { Button, IconChevronDownOutline14, MarkdownDelegateProvider, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ChatViewSlotProps, OpenFileOptions } from '../contract/slots.ts'
 import type { ChatSnapshot } from '../contract/snapshot.ts'
 import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx'
@@ -217,7 +217,7 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod
  */
 export function ChatView({
   useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot,
-  sessionId, openFile, openSkill, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
+  sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions,
   useTranscriptView, useProjection, t,
 }: ChatViewSlotProps) {
   const order = useChat(s => s.order)
@@ -782,25 +782,27 @@ export function ChatView({
               </button>
             </div>
           )}
-          <ChatNodeList
-            order={order}
-            useChatNode={useChatNode}
-            useChatNodeProcess={useChatNodeProcess}
-            historyIncomplete={hasMore}
-            compactTranscript={compactTranscript}
-            useStore={useStore}
-            actions={actions}
-            cwd={cwd}
-            openFile={requestOpenFile}
-            openSkill={openSkill}
-            inspectCall={inspectCall}
-            forkAt={forkAt}
-            loadImage={loadImage}
-            renderMessageImages={renderMessageImages}
-            fileMentions={fileMentions}
-            renderSlot={renderSlot}
-            t={t}
-          />
+          <MarkdownDelegateProvider openExternalLink={openExternalLink}>
+            <ChatNodeList
+              order={order}
+              useChatNode={useChatNode}
+              useChatNodeProcess={useChatNodeProcess}
+              historyIncomplete={hasMore}
+              compactTranscript={compactTranscript}
+              useStore={useStore}
+              actions={actions}
+              cwd={cwd}
+              openFile={requestOpenFile}
+              openSkill={openSkill}
+              inspectCall={inspectCall}
+              forkAt={forkAt}
+              loadImage={loadImage}
+              renderMessageImages={renderMessageImages}
+              fileMentions={fileMentions}
+              renderSlot={renderSlot}
+              t={t}
+            />
+          </MarkdownDelegateProvider>
           {/* No pending placeholders: questions (ui-user-questions) and approvals
               (ApprovalPanel) both take over the composer, so a flow card would
               double-render the same wait. */}

+ 2 - 0
packages/client/ui-chat/src/client/contract/slots.ts

@@ -142,6 +142,8 @@ export interface ChatViewInjected {
   }
   /** Open the current source file of a skill referenced by a sent message. */
   openSkill: (name: string) => void
+  /** Open one HTTP(S) message link in a Sidebar Browser tab. */
+  openExternalLink: (url: string) => void
   openFile: (path: string, options?: OpenFileOptions) => Promise<void>
   loadOlder: () => void
   /** Jump loader: page history back through seq; resolves when the window covers it. */

+ 41 - 2
packages/client/ui-chat/tests/apply-inject.client.spec.tsx

@@ -51,8 +51,15 @@ async function bench() {
   runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
   const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() }
   runtime.ctx.provide('layout', layout as never)
-  const sidebarRight = { openResource: vi.fn<(address: string) => void>() }
+  const sidebarRight = {
+    openResource: vi.fn<(address: string) => void>(),
+    openTab: vi.fn<(kind: string, options?: unknown) => void>(),
+  }
   runtime.ctx.provide('sidebarRight', sidebarRight as never)
+  const sidebarRightTabs = {
+    get: vi.fn<(kind: string) => object | undefined>(() => ({})),
+  }
+  runtime.ctx.provide('sidebarRightTabs', sidebarRightTabs as never)
   const openWorkspacePath = vi.fn<ClientRemote['session']['openWorkspacePath']>(
     () => Promise.resolve({ ok: true, value: { opened: true } }),
   )
@@ -93,7 +100,9 @@ async function bench() {
     ) => ChatViewInjected)(id, instance.actions)
     return { instance, injected }
   }
-  return { runtime, layout, openWorkspacePath, sidebarRight, session, chatViewApi, rootReference, openSession }
+  return {
+    runtime, layout, openWorkspacePath, sidebarRight, sidebarRightTabs, session, chatViewApi, rootReference, openSession,
+  }
 }
 
 describe('Chat inject API', () => {
@@ -145,6 +154,33 @@ describe('Chat inject API', () => {
     await b.runtime.dispose()
   })
 
+  it('opens message HTTP(S) links in Sidebar Browser tabs', async () => {
+    const b = await bench()
+    const { injected } = b.chatViewApi(b.rootReference)
+    injected.openExternalLink('http://example.test/path')
+    injected.openExternalLink('https://example.test/path')
+    expect(b.sidebarRight.openTab.mock.calls).toEqual([
+      ['browser', { params: { url: 'http://example.test/path' } }],
+      ['browser', { params: { url: 'https://example.test/path' } }],
+    ])
+    await b.runtime.dispose()
+  })
+
+  it('opens message HTTP(S) links in the system browser when no Sidebar Browser is registered', async () => {
+    const b = await bench()
+    const open = vi.spyOn(window, 'open').mockImplementation(() => null)
+    try {
+      b.sidebarRightTabs.get.mockReturnValue(undefined)
+      const { injected } = b.chatViewApi(b.rootReference)
+      injected.openExternalLink('https://example.test/path')
+      expect(b.sidebarRight.openTab).not.toHaveBeenCalled()
+      expect(open).toHaveBeenCalledWith('https://example.test/path', '_blank', 'noopener,noreferrer')
+    } finally {
+      open.mockRestore()
+      await b.runtime.dispose()
+    }
+  })
+
   it('routes sent skill previews through the viewed Session source and tolerates an absent provider', async () => {
     const b = await bench()
     const { injected } = b.chatViewApi(b.rootReference)
@@ -198,6 +234,9 @@ describe('Chat inject API', () => {
     const { injected } = b.chatViewApi(b.rootReference)
     const owner = {} as never
 
+    expect(injected.keyedHooks.chatNode('missing')).toBeDefined()
+    expect(injected.keyedHooks.chatNodeProcess('missing')).toBeDefined()
+
     expect(injected.fileMentions(owner)).toBeUndefined()
     const mentions = { resolve: vi.fn() } as never
     const forClosing = vi.fn(() => mentions)

+ 1 - 1
packages/client/ui-chat/tests/chat-apply.client.spec.tsx

@@ -43,7 +43,7 @@ async function bench() {
       : stubSettingsScope().scope,
   } as never)
   runtime.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn() } as never)
-  runtime.ctx.provide('sidebarRight', { openResource: vi.fn() } as never)
+  runtime.ctx.provide('sidebarRight', { openResource: vi.fn(), openTab: vi.fn() } as never)
   const openSession = vi.fn<(id: SessionId) => void>()
   runtime.ctx.provide('uiWorkspace', {
     openWorkspace: vi.fn(async (_workspaceId: WorkspaceId, beforeOpen: (id: SessionId) => void) => {

+ 1 - 0
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -408,6 +408,7 @@ function makeHarness(
     completeViewRequest: () => {},
     openFile,
     openSkill,
+    openExternalLink: vi.fn(),
     loadOlder,
     loadThrough,
     loadImage: vi.fn(() => Promise.reject(new Error('not used'))),

+ 3 - 0
packages/client/ui-chat/tsconfig.json

@@ -86,6 +86,9 @@
     {
       "path": "../ui-session"
     },
+    {
+      "path": "../ui-sidebar-browser"
+    },
     {
       "path": "../ui-sidebar-right"
     },

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 2abb32906841f0d112896acb242d0e4ab0557856
-README.zh.md: 340d239cac3a72f9769a29f3f772794f4ca9fa7f
+README.md: d466de8e4a26a6476e373e19c45c72ae22197f1b
+README.zh.md: eb6f4ce836eb8a219aad38f0941e666f3391ebc6

+ 2 - 2
packages/client/ui-primitives/README.md

@@ -53,7 +53,7 @@ Check this table before writing a control in a feature package. A plugin cannot
 | `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
 | `Toast` | Transient top-center banner held for the owner's `holdMs`. |
 | `JsonTree`, `JsonBlock` | Read-only JSON inspection. |
-| `MarkdownText`, `CodeBlock` | Untrusted GFM with TeX math, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. |
+| `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. |
 | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. |
 | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIcon`, `LinkIcon` | Glyphs and brand marks. Use `LinkIcon` for 14px clickable-link categories and known-site marks. |
 | `FileTypeIcon`, `classifyFileType`, `fileExtension` | A category-colored 28px file or folder glyph and the shared case-insensitive filename mapping behind it. Code and configuration files use detailed full-color technology glyphs; use `LinkIcon` for link-leading glyphs and image previews for image content. |
@@ -72,7 +72,7 @@ The catalog above lists what each export is for; this section covers the behavio
 
 ### Rendering agent output
 
-`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
+`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
 
 `MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
 

+ 2 - 2
packages/client/ui-primitives/README.zh.md

@@ -53,7 +53,7 @@ kind: "package-library"
 | `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
 | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
 | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 |
-| `MarkdownText`、`CodeBlock` | 不可信 GFM 与 TeX 数学,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 |
+| `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 |
 | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 |
 | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIcon`、`LinkIcon` | 字形与品牌标识。`LinkIcon` 用于 14px 的可点击链接分类及已知站点标记。 |
 | `FileTypeIcon`、`classifyFileType`、`fileExtension` | 按类别着色的 28px 文件或文件夹图形,以及它背后共享的不区分大小写文件名映射。代码与配置文件使用细分的全彩技术图形;链接前置图形使用 `LinkIcon`,图片内容使用图片预览。 |
@@ -72,7 +72,7 @@ kind: "package-library"
 
 ### 渲染 agent 输出
 
-`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
+`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
 
 `MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
 

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -68,6 +68,8 @@ export type {
 export { CodeBlock } from './markdown/CodeBlock.tsx'
 export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
+export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx'
+export type { MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages } from './markdown/MarkdownText.tsx'
 export { extractMarkdownPlainText } from './markdown/plain-text.ts'

+ 38 - 0
packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx

@@ -0,0 +1,38 @@
+/** Consumer-owned navigation for ordinary Markdown HTTP(S) link activation. */
+import { createContext, useContext } from 'react'
+import type { ReactNode } from 'react'
+
+/**
+ * Handle one sanitized absolute HTTP(S) URL selected from Markdown.
+ * @param href - destination URL.
+ */
+export type MarkdownExternalLinkHandler = (href: string) => void
+
+const MarkdownDelegateContext = createContext<MarkdownExternalLinkHandler | undefined>(undefined)
+
+/** Props for one Markdown navigation scope. */
+export interface MarkdownDelegateProviderProps {
+  readonly children: ReactNode
+  readonly openExternalLink: MarkdownExternalLinkHandler
+}
+
+/**
+ * Delegate ordinary Markdown HTTP(S) activation without threading callbacks through renderers.
+ * @param props - child tree and its link handler.
+ * @returns the scoped child tree.
+ */
+export function MarkdownDelegateProvider({
+  children,
+  openExternalLink,
+}: MarkdownDelegateProviderProps): ReactNode {
+  return (
+    <MarkdownDelegateContext.Provider value={openExternalLink}>
+      {children}
+    </MarkdownDelegateContext.Provider>
+  )
+}
+
+/** Read the nearest optional Markdown HTTP(S) navigation delegate. */
+export function useMarkdownExternalLinkDelegate(): MarkdownExternalLinkHandler | undefined {
+  return useContext(MarkdownDelegateContext)
+}

+ 6 - 4
packages/client/ui-primitives/src/markdown/MarkdownText.tsx

@@ -157,12 +157,14 @@ class StreamingRenderer {
  * identity discards the streaming render cache mid-message. `fileMentions`
  * links inline-code tokens its resolver recognizes as real files, and
  * `pathImages` rewrites image destinations that are local file paths into
- * displayable URLs its resolver vouches for; both vocabularies are the
+ * displayable URLs its resolver vouches for. Those two vocabularies are the
  * single streaming gate — they apply to settled renders only, because a
  * streaming message's vocabulary is not final and frozen cached elements
- * must not bake in handlers that could go stale. `variant="compact"` uses
- * secondary text sizing, uniform bold headings, and tight block spacing;
- * the default `body` variant uses the full document typography.
+ * must not bake in handlers that could go stale. A surrounding
+ * `MarkdownDelegateProvider` can delegate ordinary HTTP(S) activation while
+ * modified clicks retain native behavior. `variant="compact"` uses secondary
+ * text sizing, uniform bold headings, and tight block spacing; the default
+ * `body` variant uses the full document typography.
  * @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
  * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
  * images render directly.

+ 20 - 5
packages/client/ui-primitives/src/markdown/render.tsx

@@ -25,6 +25,7 @@ import { normalizeUri } from 'micromark-util-sanitize-uri'
 import { CodeBlock } from './CodeBlock.tsx'
 import { renderTexToReact } from './katex.tsx'
 import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
+import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx'
 import type { PositionedBlock } from './incremental.ts'
 import css from './MarkdownText.module.css'
 
@@ -525,18 +526,32 @@ function anchorWrapsOnlyImages(children: Md.PhrasingContent[]): boolean {
   return children.length > 0 && children.every(child => child.type === 'image' || child.type === 'imageReference')
 }
 
-/** Anchor over an already-authored href: allowlisted or unwrapped, external links get the safe attributes. */
+/** Anchor over an already-authored href: allowlisted or unwrapped, with optional owner navigation for HTTP(S). */
 function renderSafeLink(href: string, children: ReactNode[], key: Key, glyph = true): ReactNode {
   const safeHref = sanitizeUrl(href)
   if (safeHref === '') return <Fragment key={key}>{children}</Fragment>
-  const external = ['http:', 'https:'].includes(new URL(safeHref).protocol)
+  return <MarkdownAnchor key={key} href={safeHref} glyph={glyph}>{children}</MarkdownAnchor>
+}
+
+function MarkdownAnchor({ href, glyph, children }: {
+  readonly href: string
+  readonly glyph: boolean
+  readonly children: ReactNode[]
+}): ReactNode {
+  const openExternalLink = useMarkdownExternalLinkDelegate()
+  const external = ['http:', 'https:'].includes(new URL(href).protocol)
+  const open = external ? openExternalLink : undefined
   return (
     <a
-      key={key}
-      href={safeHref}
+      href={href}
       {...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
+      onClick={open === undefined ? undefined : (event) => {
+        if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
+        event.preventDefault()
+        open(href)
+      }}
     >
-      {glyph && <LinkIcon kind="url" href={safeHref} className={css.linkIcon} />}
+      {glyph && <LinkIcon kind="url" href={href} className={css.linkIcon} />}
       {children}
     </a>
   )

+ 53 - 2
packages/client/ui-primitives/tests/markdown.client.spec.tsx

@@ -1,8 +1,8 @@
 // @vitest-environment jsdom
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { JsonBlock, MarkdownText } from './markdown-test-components.tsx'
-import { LinkIcon } from '../src/index.ts'
+import { LinkIcon, MarkdownDelegateProvider } from '../src/index.ts'
 import { cjkFriendlyStrong } from '../src/markdown/cjkFriendlyStrong.ts'
 import { mathCompatibility } from '../src/markdown/mathCompatibility.ts'
 
@@ -141,6 +141,57 @@ describe('MarkdownText', () => {
     expect(container.querySelector('pre code a')).toBeNull()
   })
 
+  it('delegates ordinary HTTP(S) clicks while preserving modified-click behavior', () => {
+    const openExternalLink = vi.fn<(href: string) => void>()
+    render(
+      <MarkdownDelegateProvider openExternalLink={openExternalLink}>
+        <MarkdownText text={'[secure](https://example.com/a) [plain](http://example.com/b) `https://example.com/code` [mail](mailto:dev@example.com)'} />
+      </MarkdownDelegateProvider>,
+    )
+
+    const secure = screen.getByRole('link', { name: 'secure' })
+    const plain = screen.getByRole('link', { name: 'plain' })
+    const code = screen.getByRole('link', { name: 'https://example.com/code' })
+    expect(fireEvent.click(secure)).toBe(false)
+    expect(fireEvent.click(plain)).toBe(false)
+    expect(fireEvent.click(code)).toBe(false)
+    expect(openExternalLink.mock.calls).toEqual([
+      ['https://example.com/a'],
+      ['http://example.com/b'],
+      ['https://example.com/code'],
+    ])
+
+    for (const modified of [
+      { button: 1 },
+      { metaKey: true },
+      { ctrlKey: true },
+      { shiftKey: true },
+      { altKey: true },
+    ]) expect(fireEvent.click(secure, modified)).toBe(true)
+    expect(openExternalLink).toHaveBeenCalledTimes(3)
+    expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
+  })
+
+  it('updates the delegated link handler while Markdown is streaming', () => {
+    const first = vi.fn<(href: string) => void>()
+    const second = vi.fn<(href: string) => void>()
+    const source = '[web](https://example.com/)'
+    const view = render(
+      <MarkdownDelegateProvider openExternalLink={first}>
+        <MarkdownText text={source} streaming />
+      </MarkdownDelegateProvider>,
+    )
+    fireEvent.click(screen.getByRole('link', { name: 'web' }))
+    view.rerender(
+      <MarkdownDelegateProvider openExternalLink={second}>
+        <MarkdownText text={source} streaming />
+      </MarkdownDelegateProvider>,
+    )
+    fireEvent.click(screen.getByRole('link', { name: 'web' }))
+    expect(first).toHaveBeenCalledOnce()
+    expect(second).toHaveBeenCalledOnce()
+  })
+
   it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => {
     const opened: string[] = []
     const fileMentions = {

+ 6 - 0
packages/client/ui-sidebar-browser/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-browser/README.md
+README.md: 61de40ef98c72501c20c56cffb1075322bcb4886
+README.zh.md: 8bdc88a85f2473e62c640e509db53577c1f2e6d4

+ 117 - 0
packages/client/ui-sidebar-browser/README.md

@@ -0,0 +1,117 @@
+---
+description: "Right-Sidebar browser tabs for sandboxed HTTP(S) pages, including loopback services."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-browser
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Browse HTTP(S) pages, including loopback services, inside independent right-Sidebar tabs. The current carrier is an iframe with application-managed history in both Web and Desktop. The package never injects Electron or Node access into visited content.
+
+## Table of Contents
+
+- [Use this package](#use-this-package)
+- [Understand the implementation](#understand-the-implementation)
+- [Further Exploration](#further-exploration)
+- [Model Experience](#model-experience)
+- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## Use this package
+
+The shipped Web and Desktop compositions already mount this package. Open **Browser** from the right-Sidebar guide, enter an HTTP(S) URL, or select an HTTP(S) link in Assistant Markdown. A host name without a scheme becomes HTTPS. Public and loopback targets use the same default sandbox. Each guide action or message-link activation creates another Browser tab.
+
+### When to choose it
+
+Choose Browser for a Web page that should remain beside the current Session. Choose [Document Preview](../ui-sidebar-documentpreview/README.md) for local files, and use the explicit external-browser action when a site refuses iframe embedding or needs browser capabilities this package withholds.
+
+### Minimal configuration
+
+The package has no configuration. A custom Web composition mounts its Host companion; the Client loader then discovers the browser entry declared by the package manifest:
+
+```yaml
+- id: ui-sidebar-browser
+  name: '@deepseek-ai/dsh-client-ui-sidebar-browser'
+```
+
+Client plugins can open a tab through `ctx.sidebarRight.openTab('browser', { params: { url } })`. The optional URL passes the same validation as address-bar input before navigation.
+
+The toolbar provides Back, Forward, Reload, Go, Open in system browser, and a rightmost per-tab sandbox toggle. Disabling the sandbox is temporary and displays a warning. The external action accepts a known HTTP(S) target. The tab title is the Web host.
+
+-----
+
+<a id="understand-the-implementation"></a>
+## Understand the implementation
+
+<details>
+<summary>Implementation internals — click to expand</summary>
+
+### Protocol policy
+
+The address parser accepts HTTP and HTTPS, including loopback targets. It rejects `file:` URLs, script/data/blob input, embedded credentials, the DSH application origin, and malformed addresses. Document Preview owns local-file rendering.
+
+### Iframe carrier
+
+Web and Desktop use `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"` by default. The frame has no direct download or top-navigation flag. Popups leave the sandbox; in Web, an escaped popup retains its opener and can navigate the top-level application. The visited origin can use its own cookies and Web storage but a cross-origin target cannot read DSH DOM, storage, or API responses. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. The toolbar can remove the sandbox for the current tab occurrence; the choice is not persisted. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package does not proxy or probe remote pages.
+
+Web records toolbar submissions and typed tab opens. A navigation state machine treats the first iframe load for each controlled revision as known and a later load as proof that the page changed to an unreadable URL. In that unknown state the address is marked, Back, Forward, and external-open are disabled, and Reload returns to the last controlled URL. A remounted body reloads the latest application-known URL and uses its optional initial URL only before the first controlled target. History API and fragment changes that emit no iframe load remain invisible. An iframe `error` event displays a transient load-failure notice until the next controlled load without changing URL history.
+
+### Controller
+
+Each tab receives one `BrowserController` class. Its public commands are `loadUrl`, `goBack`, `goForward`, and `reload`; address validation and history mutation stay behind that object. Its `BrowserNavigation` class owns the serializable URL state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements that interface for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it keeps only the editable draft and iframe DOM.
+
+The controller interface does not depend on iframe APIs. A future `ElectronWebViewImpl` can implement `BrowserFrame` while owning `<webview>` attachment and target identity. That deferred carrier is documented in the same Sidebar Browser decision, but is not registered or tested today.
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## Further Exploration
+
+- [Right Sidebar](../../../docs/subsystems/sidebar-right.md) — tab composition, navigation, and lifecycle.
+- [Document Preview](../ui-sidebar-documentpreview/README.md) — local source, Markdown, images, HTML, and PDF rendering.
+- [Sidebar Browser decision](../../../.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md) — current iframe behavior, controller ownership, and deferred Electron carrier.
+
+-----
+
+<a id="model-experience"></a>
+## Model Experience
+
+None, as Browser tabs are user-facing presentation state and register no tool, prompt section, or Session event.
+
+#### KV Cache effect
+
+None; browsing does not enter a model request.
+
+## Known Limitations and Deferred Work
+
+<a id="known-limitations-and-deferred-work"></a>
+
+The isolation policy deliberately gives up some browser compatibility:
+
+- Many sites refuse iframe embedding or need downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can also block public HTTP pages as mixed content. Disabling the sandbox trades its restrictions for compatibility but does not bypass mixed-content or private-network policy. The unsandboxed frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. It does not isolate the visited origin's cookies per Browser tab or prevent an in-frame page from choosing its own next URL.
+- In Web, a popup that escapes the sandbox retains its opener and can use that chain to navigate the top-level application. Desktop handles popup creation separately.
+- A later iframe load reveals that navigation occurred but not the new cross-origin URL. History API and fragment changes may remain invisible; Web Back and Forward are unavailable after the state becomes unknown.
+- Browsers conceal many iframe failures for security: DNS, TLS, mixed-content, CSP, and `X-Frame-Options` failures may emit `load` or no actionable event instead of `error`. The load-failure notice is best-effort.
+- Browser history survives body remounts and ordinary page reloads, but closing the tab or unloading `ui-sidebar-right` aborts its occurrence and removes the stored history bucket.
+- Local files are rejected and remain owned by Document Preview.
+- The proposed Electron `<webview>` carrier, per-tab cookie partitions, native history, and target-specific CDP connection are not implemented.
+
+<a id="dev-note"></a>
+### Dev Note
+
+<details>
+<summary>Working context for maintainers — click to expand</summary>
+
+None.
+
+</details>
+
+**Runtime invariant:** No companion is published. `BrowserNavigation` is the sole URL-state writer; the store receives its immutable snapshots, and focused controller and component tests exercise publication and cleanup directly.

+ 117 - 0
packages/client/ui-sidebar-browser/README.zh.md

@@ -0,0 +1,117 @@
+---
+description: "右侧 Sidebar 浏览器 tab:在 sandbox 中访问 HTTP(S) 页面,包括 loopback 服务。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-browser
+
+[English](README.md) | 中文
+
+## 概述
+
+在独立的右侧 Sidebar tab 中浏览 HTTP(S) 页面,包括 loopback 服务。当前 Web 与 Desktop 都使用 iframe 和应用维护的 history。本包不会向被访问内容注入 Electron 或 Node 能力。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [了解实现](#understand-the-implementation)
+- [延伸阅读](#further-exploration)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="use-this-package"></a>
+## 使用本包
+
+随附的 Web 与 Desktop composition 已挂载本包。可以从右侧 Sidebar guide 打开 **浏览器**、输入 HTTP(S) URL,或点击 Assistant Markdown 中的 HTTP(S) 链接。不带 scheme 的主机名会补全为 HTTPS。公共目标与 loopback 目标使用相同的默认 sandbox。每次 guide 操作或消息链接操作都会创建一个新的 Browser tab。
+
+### 何时选择
+
+当 Web 页面需要保留在当前 Session 旁时,选择 Browser。本地文件使用 [Document Preview](../ui-sidebar-documentpreview/README.zh.md);站点拒绝 iframe 嵌入或需要本包不授予的浏览器 capability 时,使用明确的外部浏览器操作。
+
+### 最小配置
+
+本包没有配置字段。自定义 Web composition 挂载 Host companion;随后 Client loader 会发现 package manifest 声明的浏览器入口:
+
+```yaml
+- id: ui-sidebar-browser
+  name: '@deepseek-ai/dsh-client-ui-sidebar-browser'
+```
+
+Client 插件可以调用 `ctx.sidebarRight.openTab('browser', { params: { url } })` 打开 tab。可选 URL 会在导航前接受与地址栏输入相同的校验。
+
+工具栏提供后退、前进、刷新、前往、在系统浏览器中打开,以及最右侧的逐 tab sandbox 开关。关闭 sandbox 是临时选择,并会显示警告。外部打开接受已知的 HTTP(S) 目标。tab 标题显示 Web 主机名。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 了解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+### 协议策略
+
+地址解析器接受 HTTP 与 HTTPS,包括 loopback 目标。`file:` URL、脚本/data/blob 输入、内嵌凭据、DSH 应用自身 origin 和畸形地址会被拒绝。本地文件由 Document Preview 负责渲染。
+
+### Iframe 载体
+
+Web 与 Desktop 默认使用 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`。frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox;在 Web 中,逃逸的 popup 会保留 opener,并可以导航顶层应用。被访问的 origin 可以使用自身 Cookie 与 Web storage,但跨域目标无法读取 DSH DOM、storage 或 API 响应。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。toolbar 可以为当前 tab occurrence 移除 sandbox;该选择不持久化。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不代理或探测远程页面。
+
+Web 记录 toolbar 提交和 typed tab 打开。导航状态机把每个受控 revision 的第一次 iframe load 视为已知,把后续 load 视为页面已经变化到不可读取 URL 的证据。进入 unknown 状态后,地址会显示标记,后退、前进和外部打开会禁用,刷新则返回最后一个受控 URL。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。不产生 iframe load 的 History API 与 fragment 变化仍不可见。iframe `error` event 会显示临时加载失败 notice,直到下一个受控加载,但不会改变 URL history。
+
+### Controller
+
+每个 tab 获得一个 `BrowserController` class。它的公开命令只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;地址校验与 history 变更均由该对象封装。它的 `BrowserNavigation` class 拥有可序列化的 URL 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只保留可编辑草稿与 iframe DOM。
+
+Controller 接口不依赖 iframe API。未来的 `ElectronWebViewImpl` 可以实现 `BrowserFrame`,并持有 `<webview>` attachment 与 target identity。该延期载体记录在同一份 Sidebar Browser 决策中,当前不注册也不测试。
+
+</details>
+
+-----
+
+<a id="further-exploration"></a>
+## 延伸阅读
+
+- [右侧 Sidebar](../../../docs/subsystems/sidebar-right.zh.md)——tab composition、导航与生命周期。
+- [Document Preview](../ui-sidebar-documentpreview/README.zh.md)——本地源码、Markdown、图片、HTML 与 PDF 渲染。
+- [Sidebar Browser 决策](../../../.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md)——当前 iframe 行为、controller 所有权与延期 Electron 载体。
+
+-----
+
+<a id="model-experience"></a>
+## 模型体验
+
+无。Browser tab 是用户侧呈现状态,不注册工具、prompt section 或 Session event。
+
+#### KV Cache 影响
+
+无;浏览内容不进入模型请求。
+
+## 已知限制与延期工作
+
+<a id="known-limitations-and-deferred-work"></a>
+
+隔离策略有意放弃部分浏览器兼容性:
+
+- 很多站点拒绝 iframe 嵌入,或需要默认 sandbox 不向 frame 授予的下载与顶层导航。HTTPS 应用还可能按 mixed-content 策略阻止公共 HTTP 页面。关闭 sandbox 会用自身限制换取兼容性,但不会绕过 mixed-content 或 private-network 策略。未受 sandbox 约束的 frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。该模式不会按 Browser tab 隔离被访问 origin 的 Cookie,也无法阻止 iframe 内页面自行选择后续 URL。
+- 在 Web 中,逃逸出 sandbox 的 popup 会保留 opener,并可以通过该链导航顶层应用。Desktop 会单独处理 popup 创建。
+- 后续 iframe load 能表明发生了导航,但无法给出新的跨域 URL。History API 与 fragment 变化可能仍不可见;状态变成 unknown 后,Web 的后退与前进不可用。
+- 出于安全原因,浏览器会隐藏很多 iframe 失败:DNS、TLS、mixed-content、CSP 与 `X-Frame-Options` 失败可能触发 `load`,也可能不提供可操作 event,而不是触发 `error`。加载失败 notice 只能作为 best-effort 提示。
+- Browser history 会跨 body 重挂载与普通页面刷新保留,但关闭 tab 或卸载 `ui-sidebar-right` 会中止其 occurrence 并删除已存储的 history bucket。
+- 本地文件会被拒绝,并继续由 Document Preview 负责。
+- 拟议的 Electron `<webview>` 载体、per-tab Cookie partition、原生 history 和 target-specific CDP 连接尚未实现。
+
+<a id="dev-note"></a>
+### 开发备注
+
+<details>
+<summary>维护者工作上下文——点击展开</summary>
+
+无。
+
+</details>
+
+**运行时不变量:** 不发布 companion。`BrowserNavigation` 是唯一的 URL 状态写入方;store 接收它的 immutable snapshot,controller 与组件的聚焦测试直接覆盖发布与清理。

+ 66 - 0
packages/client/ui-sidebar-browser/package.json

@@ -0,0 +1,66 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-sidebar-browser",
+  "description": "Sandboxed Web browser tabs for the right Sidebar",
+  "version": "0.1.6-alpha.1",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/ui-sidebar-browser"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dsh": {
+    "client": {
+      "inject": [
+        "@deepseek-ai/dsh-client-ui-sidebar-right",
+        "@deepseek-ai/dsh-client-ui-session"
+      ],
+      "platform": "web"
+    }
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "MIT",
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-store": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-dockkit": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-session": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@testing-library/react": "^16.1.0",
+    "@types/react": "~18.3.1",
+    "@types/react-dom": "~18.3.0",
+    "react": "^18.2.0",
+    "react-dom": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 166 - 0
packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts

@@ -0,0 +1,166 @@
+/** Per-tab Browser controller. */
+import type { BoundActions } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
+import { IframeImpl } from './BrowserFrame.ts'
+import type { BrowserFrame, BrowserFrameState } from './BrowserFrame.ts'
+import { BrowserNavigation } from './BrowserNavigation.ts'
+import type { BrowserTabState } from './BrowserNavigation.ts'
+import type { BrowserStore } from './store.ts'
+import { parseBrowserAddress } from './url.ts'
+
+/** Construction dependencies for one tab-scoped Browser controller. */
+export interface BrowserControllerOptions {
+  readonly tabId: TabId
+  readonly signal: AbortSignal
+  readonly applicationOrigin: string
+  readonly initial?: BrowserTabState
+  readonly actions: BoundActions<BrowserStore>
+}
+
+/** Owns one Browser tab's URL state, loading lifecycle, and four navigation commands. */
+export class BrowserController {
+  /** Renderer-facing iframe and sandbox state. */
+  readonly frame: BrowserFrame
+  private readonly navigation: BrowserNavigation
+  private disposed = false
+
+  /**
+   * @param options - tab identity, persistence writer, URL origin, and lifetime.
+   */
+  constructor(private readonly options: BrowserControllerOptions) {
+    this.navigation = new BrowserNavigation(options.initial)
+    this.frame = new IframeImpl(
+      () => { this.reload() },
+      (revision) => { this.frameLoaded(revision) },
+    )
+    options.signal.addEventListener('abort', () => { this.dispose() }, { once: true })
+  }
+
+  /**
+   * Validate and load one address, replacing a same-address load with Reload.
+   * @param value - address-bar or typed-tab value.
+   */
+  loadUrl(value: string): void {
+    if (this.disposed) return
+    const parsed = parseBrowserAddress(value, this.options.applicationOrigin)
+    if (!parsed.ok) {
+      this.navigation.addressFailed(parsed.reason)
+      this.publish()
+      return
+    }
+    const current = BrowserNavigation.current(this.navigation.snapshot)
+    if (current?.url === parsed.target.url) {
+      this.reload()
+      return
+    }
+    this.start(this.navigation.navigate(parsed.target))
+  }
+
+  /** Move to the preceding application-known address when Web history remains usable. */
+  goBack(): void {
+    if (this.disposed) return
+    const request = this.navigation.back()
+    if (request !== undefined) this.start(request)
+  }
+
+  /** Move to the following application-known address when Web history remains usable. */
+  goForward(): void {
+    if (this.disposed) return
+    const request = this.navigation.forward()
+    if (request !== undefined) this.start(request)
+  }
+
+  /** Reload the last application-known address without adding history. */
+  reload(): void {
+    if (this.disposed) return
+    const request = this.navigation.reload()
+    if (request !== undefined) this.start(request)
+  }
+
+  private start(request: NonNullable<BrowserTabState['request']>): void {
+    this.publish()
+    this.frame.clearDocument()
+    this.frame.setDocument({ target: request.target, src: request.target.url, revision: request.revision })
+  }
+
+  private frameLoaded(revision: number): void {
+    if (this.disposed) return
+    const previous = this.navigation.snapshot
+    this.navigation.frameLoaded(revision)
+    if (this.navigation.snapshot !== previous) this.publish()
+  }
+
+  private publish(): void {
+    this.options.actions.replace(this.options.tabId, this.navigation.snapshot)
+  }
+
+  private dispose(): void {
+    this.disposed = true
+    this.frame.clearDocument()
+  }
+}
+
+/** Browser commands and keyed frame state injected into the tab body. */
+export interface BrowserInjected {
+  readonly keyedHooks: {
+    readonly browserFrame: (key: string) => HostObservable<BrowserFrameState> | undefined
+  }
+  /**
+   * @param tabId - tab occurrence.
+   * @param signal - occurrence lifetime.
+   * @param applicationOrigin - current application origin.
+   * @param initial - persisted tab state.
+   */
+  mount(tabId: TabId, signal: AbortSignal, applicationOrigin: string, initial?: BrowserTabState): void
+  /** @param tabId - tab occurrence. @param value - address-bar or typed-open value. */
+  loadUrl(tabId: TabId, value: string): void
+  /** @param tabId - tab occurrence. */
+  goBack(tabId: TabId): void
+  /** @param tabId - tab occurrence. */
+  goForward(tabId: TabId): void
+  /** @param tabId - tab occurrence. */
+  reload(tabId: TabId): void
+  /** @param tabId - tab occurrence. */
+  toggleSandbox(tabId: TabId): void
+  /** @param tabId - tab occurrence. @param revision - rendered document revision. */
+  reportLoaded(tabId: TabId, revision: number): void
+  /** @param tabId - tab occurrence. @param revision - rendered document revision that emitted `error`. */
+  reportLoadFailed(tabId: TabId, revision: number): void
+}
+
+/**
+ * Bind Browser controllers to one Session and its persistence writer.
+ * @param actions - Browser store mutation face.
+ * @returns a per-tab controller registry exposed as plain Slot callbacks.
+ */
+export function createBrowserControllers(
+  actions: BoundActions<BrowserStore>,
+): BrowserInjected {
+  const controllers = new Map<TabId, { readonly signal: AbortSignal; readonly controller: BrowserController }>()
+  const controller = (tabId: TabId): BrowserController | undefined => controllers.get(tabId)?.controller
+  return {
+    keyedHooks: { browserFrame: key => controller(key as TabId)?.frame },
+    mount(tabId, signal, applicationOrigin, initial) {
+      const held = controllers.get(tabId)
+      if (held?.signal === signal) return
+      const created = new BrowserController({
+        tabId, signal, applicationOrigin, actions,
+        ...(initial === undefined ? {} : { initial }),
+      })
+      controllers.set(tabId, { signal, controller: created })
+      signal.addEventListener('abort', () => {
+        if (controllers.get(tabId)?.controller !== created) return
+        controllers.delete(tabId)
+        actions.forget(tabId)
+      }, { once: true })
+    },
+    loadUrl: (tabId, value) => { controller(tabId)?.loadUrl(value) },
+    goBack: (tabId) => { controller(tabId)?.goBack() },
+    goForward: (tabId) => { controller(tabId)?.goForward() },
+    reload: (tabId) => { controller(tabId)?.reload() },
+    toggleSandbox: (tabId) => { controller(tabId)?.frame.toggleSandbox() },
+    reportLoaded: (tabId, revision) => { controller(tabId)?.frame.reportLoaded(revision) },
+    reportLoadFailed: (tabId, revision) => { controller(tabId)?.frame.reportLoadFailed(revision) },
+  }
+}

+ 98 - 0
packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts

@@ -0,0 +1,98 @@
+/** BrowserFrame interface and the current iframe implementation. */
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
+import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
+import type { BrowserTarget } from './url.ts'
+
+/** One prepared document rendered by a BrowserFrame implementation. */
+export interface BrowserDocument {
+  readonly target: BrowserTarget
+  readonly src: string
+  readonly revision: number
+}
+
+/** Current renderer-facing state for one Browser tab. */
+export interface BrowserFrameState {
+  readonly document: BrowserDocument | undefined
+  readonly sandboxed: boolean
+  readonly loadFailed: boolean
+}
+
+/** Browser rendering operations shared by Web and future Electron implementations. */
+export interface BrowserFrame extends HostObservable<BrowserFrameState> {
+  /** Toggle sandbox enforcement for this tab occurrence. */
+  toggleSandbox(): void
+  /** @param document - current prepared document. */
+  setDocument(document: BrowserDocument): void
+  /** Remove the current prepared document. */
+  clearDocument(): void
+  /** @param revision - rendered document revision reported by the carrier. */
+  reportLoaded(revision: number): void
+  /** @param revision - rendered document revision whose carrier reported an error. */
+  reportLoadFailed(revision: number): void
+}
+
+/** Owns transient iframe and sandbox-toggle state independently from URL navigation. */
+export class IframeImpl implements BrowserFrame {
+  private readonly store: SnapshotStore<BrowserFrameState> = createSnapshotStore({
+    document: undefined,
+    sandboxed: true,
+    loadFailed: false,
+  })
+
+  /**
+   * @param sandboxChanged - applies the new policy to the current controlled target.
+   * @param documentLoaded - reports an iframe load to the navigation state machine.
+   */
+  constructor(
+    private readonly sandboxChanged: (sandboxed: boolean) => void,
+    private readonly documentLoaded: (revision: number) => void,
+  ) {}
+
+  /** @returns the immutable renderer snapshot. */
+  getSnapshot = (): BrowserFrameState => this.store.getSnapshot()
+
+  /**
+   * Subscribe to iframe or sandbox-mode changes.
+   * @param listener - invalidation callback.
+   * @returns the unsubscribe function.
+   */
+  subscribe = (listener: () => void): (() => void) => this.store.subscribe(listener)
+
+  /** Toggle sandbox enforcement for this tab occurrence. */
+  toggleSandbox(): void {
+    const current = this.store.getSnapshot()
+    const sandboxed = !current.sandboxed
+    this.store.set({ ...current, sandboxed })
+    this.sandboxChanged(sandboxed)
+  }
+
+  /**
+   * Publish a prepared frame from the owning controller.
+   * @param document - current prepared document.
+   */
+  setDocument(document: BrowserDocument): void {
+    this.store.set({ ...this.store.getSnapshot(), document, loadFailed: false })
+  }
+
+  /** Remove the current prepared frame and transient load failure. */
+  clearDocument(): void {
+    const current = this.store.getSnapshot()
+    const { document } = current
+    if (document !== undefined) {
+      this.store.set({ ...current, document: undefined, loadFailed: false })
+    }
+  }
+
+  /** @param revision - rendered document revision reported by the iframe. */
+  reportLoaded(revision: number): void {
+    this.documentLoaded(revision)
+  }
+
+  /** @param revision - rendered document revision whose iframe emitted `error`. */
+  reportLoadFailed(revision: number): void {
+    const current = this.store.getSnapshot()
+    if (current.document?.revision !== revision || current.loadFailed) return
+    this.store.set({ ...current, loadFailed: true })
+  }
+}

+ 174 - 0
packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts

@@ -0,0 +1,174 @@
+/** URL, history, and observability state for one Browser tab. */
+import type { BrowserAddressFailure, BrowserTarget } from './url.ts'
+
+/** Maximum retained application-known navigation entries per tab. */
+export const MAX_BROWSER_HISTORY = 100
+
+/** One canonical address in the application-managed Web history. */
+export type BrowserHistoryEntry = BrowserTarget
+
+/** Whether the current carrier document still corresponds to an application-known URL. */
+export type BrowserNavigationStatus =
+  | { readonly status: 'empty' }
+  | { readonly status: 'loading'; readonly revision: number }
+  | { readonly status: 'known'; readonly revision: number }
+  | { readonly status: 'unknown'; readonly revision: number }
+
+/** Address-policy or loading failure shown below the toolbar. */
+export type BrowserFailure =
+  { readonly kind: 'address'; readonly reason: BrowserAddressFailure }
+
+/** One Browser tab's serializable URL state. */
+export interface BrowserTabState {
+  readonly entries: readonly BrowserHistoryEntry[]
+  readonly index: number
+  /** Last application-directed load; carrier observations do not rewrite it. */
+  readonly request: { readonly revision: number; readonly target: BrowserTarget } | undefined
+  readonly navigation: BrowserNavigationStatus
+  readonly failure: BrowserFailure | undefined
+}
+
+/**
+ * Owns the application-known URL history and the iframe observation state machine.
+ * The first load for a request keeps its URL authoritative; another load marks it unknown.
+ */
+export class BrowserNavigation {
+  private value: BrowserTabState
+
+  /**
+   * @param initial - persisted state restored for this tab, or a fresh empty state.
+   */
+  constructor(initial: BrowserTabState = BrowserNavigation.empty()) {
+    this.value = initial
+  }
+
+  /**
+   * Create state before a tab has a controlled navigation target.
+   * @returns empty serializable state.
+   */
+  static empty(): BrowserTabState {
+    return { entries: [], index: -1, request: undefined, navigation: { status: 'empty' }, failure: undefined }
+  }
+
+  /**
+   * Read the selected application-history entry.
+   * @param state - serializable tab state.
+   * @returns the current target, if any.
+   */
+  static current(state: BrowserTabState | undefined): BrowserHistoryEntry | undefined {
+    return state === undefined || state.index < 0 ? undefined : state.entries[state.index]
+  }
+
+  /**
+   * Test whether the Web carrier can use the preceding application-history entry.
+   * @param state - serializable tab state.
+   * @returns whether Back is available.
+   */
+  static canGoBack(state: BrowserTabState): boolean {
+    return state.navigation.status !== 'unknown' && state.index > 0
+  }
+
+  /**
+   * Test whether the Web carrier can use the following application-history entry.
+   * @param state - serializable tab state.
+   * @returns whether Forward is available.
+   */
+  static canGoForward(state: BrowserTabState): boolean {
+    return state.navigation.status !== 'unknown'
+      && state.index >= 0
+      && state.index < state.entries.length - 1
+  }
+
+  /** Current immutable serializable state. */
+  get snapshot(): BrowserTabState {
+    return this.value
+  }
+
+  /** Whether the Web iframe can safely use the application-owned Back entry. */
+  get canGoBack(): boolean {
+    return BrowserNavigation.canGoBack(this.value)
+  }
+
+  /** Whether the Web iframe can safely use the application-owned Forward entry. */
+  get canGoForward(): boolean {
+    return BrowserNavigation.canGoForward(this.value)
+  }
+
+  /**
+   * Add a controlled target and discard its stale forward branch.
+   * @param target - validated canonical target.
+   * @returns the new load request.
+   */
+  navigate(target: BrowserTarget): NonNullable<BrowserTabState['request']> {
+    const entries = [...this.value.entries.slice(0, this.value.index + 1), target]
+    if (entries.length > MAX_BROWSER_HISTORY) entries.splice(0, entries.length - MAX_BROWSER_HISTORY)
+    return this.request(target, { ...this.value, entries, index: entries.length - 1 })
+  }
+
+  /**
+   * Select the preceding application-known target.
+   * @returns a new load request, or undefined when unavailable.
+   */
+  back(): BrowserTabState['request'] {
+    if (!this.canGoBack) return undefined
+    const index = this.value.index - 1
+    const target = this.value.entries[index] as BrowserHistoryEntry
+    return this.request(target, { ...this.value, index })
+  }
+
+  /**
+   * Select the following application-known target.
+   * @returns a new load request, or undefined when unavailable.
+   */
+  forward(): BrowserTabState['request'] {
+    if (!this.canGoForward) return undefined
+    const index = this.value.index + 1
+    const target = this.value.entries[index] as BrowserHistoryEntry
+    return this.request(target, { ...this.value, index })
+  }
+
+  /**
+   * Start another load of the last application-known target.
+   * @returns a new load request, or undefined before the first target.
+   */
+  reload(): BrowserTabState['request'] {
+    const target = BrowserNavigation.current(this.value)
+    return target === undefined ? undefined : this.request(target, this.value)
+  }
+
+  /**
+   * Record an invalid address without changing the active document state.
+   * @param reason - parser refusal.
+   */
+  addressFailed(reason: BrowserAddressFailure): void {
+    this.value = { ...this.value, failure: { kind: 'address', reason } }
+  }
+
+  /**
+   * Record a frame load for its captured revision.
+   * @param revision - revision bound to the rendered frame.
+   */
+  frameLoaded(revision: number): void {
+    const navigation = this.value.navigation
+    if (navigation.status === 'empty' || navigation.revision !== revision) return
+    if (navigation.status === 'loading') {
+      this.value = { ...this.value, navigation: { status: 'known', revision } }
+    } else if (navigation.status === 'known') {
+      this.value = { ...this.value, navigation: { status: 'unknown', revision } }
+    }
+  }
+
+  private request(
+    target: BrowserTarget,
+    basis: BrowserTabState,
+  ): NonNullable<BrowserTabState['request']> {
+    const request = { revision: (this.value.request?.revision ?? 0) + 1, target }
+    this.value = {
+      ...basis,
+      request,
+      navigation: { status: 'loading', revision: request.revision },
+      failure: undefined,
+    }
+    return request
+  }
+}

+ 38 - 0
packages/client/ui-sidebar-browser/src/client/browser/store.ts

@@ -0,0 +1,38 @@
+/** Persisted Browser tab snapshots shared by the body and title slots. */
+import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import type { BrowserTabState } from './BrowserNavigation.ts'
+
+/** All Browser tabs in one Session-scoped store. */
+export interface BrowserState {
+  byTab: Record<TabId, BrowserTabState>
+}
+
+type BrowserActions = {
+  replace: (draft: BrowserState, tabId: TabId, state: BrowserTabState) => void
+  forget: (draft: BrowserState, tabId: TabId) => void
+}
+
+/**
+ * Declare the Session-scoped Browser persistence store.
+ * @returns a fresh store handle for Slot registration.
+ */
+export function createBrowserStore(): EngineStoreHandle<BrowserState, BrowserActions> {
+  return defineStore({
+    init: (): BrowserState => ({ byTab: {} }),
+    persist: 'dsh.sidebar-browser.v1',
+    actions: {
+      replace: (draft, tabId: TabId, state: BrowserTabState) => { draft.byTab[tabId] = state },
+      forget: (draft, tabId: TabId) => {
+        const byTab: BrowserState['byTab'] = {}
+        for (const [id, state] of Object.entries(draft.byTab) as [TabId, BrowserTabState][]) {
+          if (id !== tabId) byTab[id] = state
+        }
+        draft.byTab = byTab
+      },
+    },
+  })
+}
+
+/** Browser store handle shared by body and title registrations. */
+export type BrowserStore = ReturnType<typeof createBrowserStore>

+ 45 - 0
packages/client/ui-sidebar-browser/src/client/browser/url.ts

@@ -0,0 +1,45 @@
+/** Address parsing for the Sidebar browser's HTTP(S) allowlist. */
+
+/** Maximum accepted address length; this bounds persisted navigation state. */
+export const MAX_BROWSER_URL_LENGTH = 16 * 1024
+
+/** A normalized Browser navigation target. */
+export type BrowserTarget =
+  | { readonly kind: 'https'; readonly url: string; readonly title: string }
+  | { readonly kind: 'http'; readonly url: string; readonly title: string }
+
+/** Why an address was refused before navigation. */
+export type BrowserAddressFailure = 'empty' | 'invalid' | 'protocol' | 'credentials' | 'application-origin'
+
+/** Result of parsing an address-bar value. */
+export type BrowserAddressResult =
+  | { readonly ok: true; readonly target: BrowserTarget }
+  | { readonly ok: false; readonly reason: BrowserAddressFailure }
+
+/**
+ * Parse one address-bar value into the fixed protocol allowlist.
+ * @param input - user or typed-open input.
+ * @param applicationOrigin - current DSH document origin, blocked for HTTPS.
+ * @returns a canonical target or the refusal reason.
+ */
+export function parseBrowserAddress(input: string, applicationOrigin?: string): BrowserAddressResult {
+  const trimmed = input.trim()
+  if (trimmed === '') return { ok: false, reason: 'empty' }
+  if (trimmed.length > MAX_BROWSER_URL_LENGTH) return { ok: false, reason: 'invalid' }
+  const explicitScheme = /^[A-Za-z][A-Za-z\d+.-]*:(?!\d+(?:[/?#]|$))/u.test(trimmed)
+  const candidate = explicitScheme ? trimmed : `https://${trimmed}`
+  let url: URL
+  try { url = new URL(candidate) } catch { return { ok: false, reason: 'invalid' } }
+  if (url.username !== '' || url.password !== '') return { ok: false, reason: 'credentials' }
+  if (url.protocol === 'https:' || url.protocol === 'http:') {
+    if (applicationOrigin !== undefined && applicationOrigin !== 'null') {
+      try {
+        if (url.origin === new URL(applicationOrigin).origin) return { ok: false, reason: 'application-origin' }
+      } catch {
+        // An unavailable application origin cannot grant access to a target.
+      }
+    }
+    return { ok: true, target: { kind: url.protocol === 'https:' ? 'https' : 'http', url: url.href, title: url.hostname } }
+  }
+  return { ok: false, reason: 'protocol' }
+}

+ 26 - 0
packages/client/ui-sidebar-browser/src/client/definition.tsx

@@ -0,0 +1,26 @@
+/** Static Browser tab type and guide declaration. */
+import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
+import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import type {} from './locales.ts'
+
+/** Browser tab kind. */
+export const BROWSER_KIND = 'browser'
+
+/** Browser implementation identity and keyed Slot dispatch key. */
+export const BROWSER_ID = '@deepseek-ai/dsh-client-ui-sidebar-browser'
+
+/** Build the Browser type with locale-live copy. */
+export function browserDefinition(t: TranslateNS<'sidebarBrowser'>): SidebarRightTabDefinition {
+  return {
+    id: BROWSER_ID,
+    kind: BROWSER_KIND,
+    multiple: true,
+    priority: 'builtin',
+    title: () => t('type.label'),
+    guide: [{
+      id: 'new', order: 30, title: () => t('guide.title'),
+      description: () => t('guide.description'), icon: IconGlobeOutline14,
+    }],
+  }
+}

+ 46 - 0
packages/client/ui-sidebar-browser/src/client/index.ts

@@ -0,0 +1,46 @@
+/** Register the HTTP(S) Browser tab type in the right Sidebar. */
+import type { Context } from '@deepseek-ai/cordis'
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {} from '@deepseek-ai/dsh-client-ui-session/client'
+import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
+import { BrowserBody } from './view/BrowserBody.tsx'
+import { BrowserTitle } from './view/BrowserTitle.tsx'
+import { createBrowserControllers } from './browser/BrowserController.ts'
+import { BROWSER_ID, browserDefinition } from './definition.tsx'
+import { en, zh } from './locales.ts'
+import { createBrowserStore } from './browser/store.ts'
+
+export type { BrowserBodyProps } from './view/BrowserBody.tsx'
+export type { BrowserInjected } from './browser/BrowserController.ts'
+export type { BrowserDocument, BrowserFrame, BrowserFrameState } from './browser/BrowserFrame.ts'
+export type { BrowserFailure, BrowserHistoryEntry, BrowserNavigationStatus, BrowserTabState } from './browser/BrowserNavigation.ts'
+export type { SidebarBrowserKey } from './locales.ts'
+export type { BrowserState } from './browser/store.ts'
+export type { BrowserAddressFailure, BrowserAddressResult, BrowserTarget } from './browser/url.ts'
+
+declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
+  interface SidebarRightTabParamsMap {
+    /** Optional initial Browser URL. */
+    browser: { readonly url?: string }
+  }
+}
+
+/** Required Browser services. */
+export const inject = ['slots', 'locale', 'sidebarRightTabs']
+
+/** Register the Browser type, localized guide entry, body, and title. */
+export function apply(ctx: Context): void {
+  const namespace = 'sidebarBrowser'
+  const t = ctx.locale.bind(namespace)
+  const store = createBrowserStore()
+  ctx.effect(() => ctx.locale.register(namespace, { zh, en }), 'ui-sidebar-browser.copy')
+  ctx.effect(() => ctx.sidebarRightTabs.register(browserDefinition(t)), 'ui-sidebar-browser.type')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
+    name: 'sidebar.right.pane.tab', key: BROWSER_ID, locale: namespace, store,
+    inject: (_sessionId, actions) => createBrowserControllers(actions),
+  }, BrowserBody)), 'ui-sidebar-browser.body')
+  ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
+    name: 'sidebar.right.pane.tab.title', key: BROWSER_ID, store,
+  }, BrowserTitle)), 'ui-sidebar-browser.title')
+}

+ 61 - 0
packages/client/ui-sidebar-browser/src/client/locales.ts

@@ -0,0 +1,61 @@
+/** Locale-owned Browser tab copy. */
+export const zh = {
+  'type.label': '浏览器',
+  'guide.title': '浏览器',
+  'guide.description': '浏览 HTTP(S) 网页',
+  'address.placeholder': '输入 HTTP(S) 地址',
+  'address.changed': 'URL 已变化',
+  back: '后退',
+  forward: '前进',
+  reload: '刷新',
+  go: '前往',
+  external: '在系统浏览器中打开',
+  'sandbox.disable': '关闭沙箱限制',
+  'sandbox.enable': '恢复沙箱限制',
+  'sandbox.warning': '沙箱限制已关闭;页面可以导航顶层应用,并使用下载、模态对话框与输入锁定。',
+  start: '输入 HTTP(S) 地址开始浏览',
+  loading: '正在打开…',
+  'error.empty': '请输入地址。',
+  'error.invalid': '这个地址无效或过长。',
+  'error.protocol': '只支持 HTTP 和 HTTPS 地址;本地文件请使用文档预览。',
+  'error.credentials': '地址不能包含用户名或密码。',
+  'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。',
+  'web.loadFailed': '页面报告加载失败或可能禁止嵌入;可尝试在系统浏览器中打开。',
+  'web.unknown': '页面已在 iframe 内跳转;Web 模式无法读取当前 URL。',
+} satisfies Record<string, string>
+
+/** Browser dictionary key union. */
+export type SidebarBrowserKey = keyof typeof zh
+
+/** English dictionary with the same keys. */
+export const en = {
+  'type.label': 'Browser',
+  'guide.title': 'Browser',
+  'guide.description': 'Browse HTTP(S) pages',
+  'address.placeholder': 'Enter an HTTP(S) address',
+  'address.changed': 'URL changed',
+  back: 'Back',
+  forward: 'Forward',
+  reload: 'Reload',
+  go: 'Go',
+  external: 'Open in system browser',
+  'sandbox.disable': 'Disable sandbox restrictions',
+  'sandbox.enable': 'Restore sandbox restrictions',
+  'sandbox.warning': 'Sandbox restrictions are disabled; the page can navigate the top-level app and use downloads, modal dialogs, and input locks.',
+  start: 'Enter an HTTP(S) address to start browsing',
+  loading: 'Opening…',
+  'error.empty': 'Enter an address.',
+  'error.invalid': 'That address is invalid or too long.',
+  'error.protocol': 'Only HTTP and HTTPS addresses are supported; use Document Preview for local files.',
+  'error.credentials': 'Addresses cannot contain a username or password.',
+  'error.application-origin': 'The embedded browser cannot open the DSH application itself.',
+  'web.loadFailed': 'The page reported a load failure or may block embedding; try opening it in the system browser.',
+  'web.unknown': 'The page navigated inside the iframe; Web mode cannot read its current URL.',
+} satisfies Record<SidebarBrowserKey, string>
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Sidebar Browser labels, navigation controls, and failures. */
+    sidebarBrowser: SidebarBrowserKey
+  }
+}

+ 167 - 0
packages/client/ui-sidebar-browser/src/client/view/Browser.module.css

@@ -0,0 +1,167 @@
+.root {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-bg-base);
+}
+
+.toolbar {
+  display: flex;
+  flex: 0 0 auto;
+  gap: 4px;
+  align-items: center;
+  box-sizing: border-box;
+  height: 38px;
+  padding: 5px 6px;
+  border-bottom: 0.5px solid var(--dsw-alias-border-l3);
+}
+
+.tool {
+  display: inline-flex;
+  flex: 0 0 auto;
+  align-items: center;
+  justify-content: center;
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  color: var(--dsw-alias-label-secondary);
+  background: transparent;
+  border: 0;
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.tool:hover:not(:disabled) {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.tool:disabled {
+  color: var(--dsw-alias-label-quaternary);
+  cursor: default;
+}
+
+.sandboxOff {
+  color: var(--dsw-alias-state-error-primary);
+  background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);
+}
+
+.addressBox {
+  position: relative;
+  flex: 1 1 auto;
+  min-width: 0;
+}
+
+.address {
+  width: 100%;
+  height: 28px;
+  box-sizing: border-box;
+  padding: 0 34px 0 9px;
+  color: var(--dsw-alias-label-primary);
+  font: var(--dsw-font-xxs-12);
+  background: var(--dsw-alias-bg-layer-1);
+  border: 0.5px solid var(--dsw-alias-border-l2);
+  border-radius: 6px;
+}
+
+.addressGo {
+  position: absolute;
+  top: 0;
+  right: 0;
+  visibility: hidden;
+  opacity: 0;
+}
+
+.addressBox:focus-within .addressGo {
+  visibility: visible;
+  opacity: 1;
+}
+
+.addressUnknown {
+  color: var(--dsw-alias-label-tertiary);
+  background: var(--dsw-alias-bg-layer-2);
+}
+
+.addressUnknown:focus {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-bg-layer-1);
+}
+
+.addressChanged {
+  position: absolute;
+  top: 6px;
+  right: 8px;
+  padding-left: 8px;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xxxs-11);
+  line-height: 16px;
+  pointer-events: none;
+  background: var(--dsw-alias-bg-layer-2);
+}
+
+.addressBox:focus-within .addressChanged {
+  visibility: hidden;
+  opacity: 0;
+}
+
+.address:focus {
+  outline: 1px solid var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+  outline-offset: -1px;
+}
+
+.frame {
+  display: flex;
+  flex: 1 1 auto;
+  width: 100%;
+  min-height: 0;
+  border: 0;
+  background: var(--dsw-alias-bg-base);
+}
+
+.start {
+  display: flex;
+  flex: 1 1 auto;
+  align-items: center;
+  justify-content: center;
+  min-height: 0;
+  padding: 24px;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xs-13);
+  text-align: center;
+}
+
+.failure {
+  flex: 0 0 auto;
+  padding: 6px 12px;
+  color: var(--dsw-alias-state-error-primary);
+  font: var(--dsw-font-xxxs-11);
+  background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent);
+}
+
+.sandboxWarning {
+  flex: 0 0 auto;
+  padding: 6px 12px;
+  color: var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary));
+  font: var(--dsw-font-xxxs-11);
+  background: color-mix(in srgb, var(--dsw-alias-state-warning-primary, var(--dsw-alias-state-business-primary)) 8%, transparent);
+}
+
+.limit {
+  flex: 0 0 auto;
+  margin: 0;
+  padding: 4px 10px;
+  overflow: hidden;
+  color: var(--dsw-alias-label-tertiary);
+  font: var(--dsw-font-xxxs-11);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  border-top: 0.5px solid var(--dsw-alias-border-l3);
+}
+
+.titleIcon {
+  flex: 0 0 auto;
+  margin-right: 4px;
+}

+ 155 - 0
packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx

@@ -0,0 +1,155 @@
+/** Browser toolbar and Web iframe renderer. */
+import { useEffect, useRef, useState } from 'react'
+import type { FormEvent, ReactNode } from 'react'
+import {
+  IconChevronLeftOutline14,
+  IconChevronRightOutline14,
+  IconLinkOutline14,
+  IconRefreshOutline14,
+  IconRightUpOutline16,
+  SHIELD_OUTLINE_PATH,
+  SHIELD_OUTLINE_STROKE,
+} from '@deepseek-ai/dsh-client-ui-primitives'
+import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import type { BrowserInjected } from '../browser/BrowserController.ts'
+import type { BrowserFrameState } from '../browser/BrowserFrame.ts'
+import { BrowserNavigation } from '../browser/BrowserNavigation.ts'
+import type { BrowserAddressFailure } from '../browser/url.ts'
+import type { BrowserStore } from '../browser/store.ts'
+import css from './Browser.module.css'
+
+/** Fixed Web iframe sandbox; popups escape the sandbox while top navigation remains absent. */
+export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
+
+const INITIAL_BROWSER_FRAME: BrowserFrameState = { document: undefined, sandboxed: true, loadFailed: false }
+
+function SandboxPolicyIcon({ sandboxed }: { readonly sandboxed: boolean }): ReactNode {
+  return (
+    <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
+      <path d={SHIELD_OUTLINE_PATH} stroke="currentColor" strokeWidth={SHIELD_OUTLINE_STROKE} strokeLinejoin="round" />
+      {sandboxed
+        ? <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
+        : <path d="M10.6074 4.40278L8.00975 6.99973L10.6074 9.59739L9.59736 10.6074L6.9997 8.00978L4.40274 10.6074L3.3927 9.59739L5.98966 6.99973L3.3927 4.40278L4.40274 3.39273L6.9997 5.98969L9.59736 3.39273L10.6074 4.40278Z" fill="currentColor" transform="translate(1.2 0.8)" />}
+    </svg>
+  )
+}
+
+/** Browser body props assembled by the tab seat. */
+export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'>
+  & PropsStore<BrowserStore>
+  & PropsLocale<'sidebarBrowser'>
+  & InjectFace<BrowserInjected>
+
+/** Translate one parser refusal without matching display strings in logic. */
+function failureText(reason: BrowserAddressFailure, t: BrowserBodyProps['t']): string {
+  return t(`error.${reason}`)
+}
+
+function useBrowserDraft(
+  controlledUrl: string | undefined,
+  requestId: number | undefined,
+): readonly [string, (value: string) => void] {
+  const [edit, setEdit] = useState<{ readonly requestId: number | undefined; readonly value: string }>()
+  const value = edit !== undefined && edit.requestId === requestId ? edit.value : controlledUrl ?? ''
+  return [value, (draft) => { setEdit({ requestId, value: draft }) }]
+}
+
+/** Browser tab renderer for a controller-owned URL state and Web iframe carrier. */
+export function BrowserBody(props: BrowserBodyProps): ReactNode {
+  const {
+    goBack, goForward, loadUrl, mount, reload, reportLoaded, reportLoadFailed, toggleSandbox,
+    useBrowserFrame, useStore, useTabInfo, t,
+  } = props
+  const { tab } = useTabInfo()
+  const state = useStore(snapshot => snapshot.byTab[tab.id]) ?? BrowserNavigation.empty()
+  const initialState = useRef(state)
+  const initialUrl = useRef(tab.navigation.params?.url)
+  const current = BrowserNavigation.current(state)
+  const [draft, setDraft] = useBrowserDraft(current?.url ?? initialUrl.current, state.request?.revision)
+  const [mountCount, setMountCount] = useState(0)
+
+  useEffect(() => {
+    mount(tab.id, tab.signal, window.location.origin, initialState.current)
+    setMountCount(count => count + 1)
+  }, [mount, tab.id, tab.signal])
+
+  useEffect(() => {
+    if (mountCount === 0) return
+    const resumed = BrowserNavigation.current(initialState.current)
+    if (resumed !== undefined) {
+      reload(tab.id)
+      return
+    }
+    const url = initialUrl.current
+    if (url !== undefined) loadUrl(tab.id, url)
+  }, [loadUrl, mountCount, reload, tab.id])
+
+  const frameState = useBrowserFrame(tab.id) ?? INITIAL_BROWSER_FRAME
+  const { document, sandboxed, loadFailed } = frameState
+
+  const navigationUnknown = state.navigation.status === 'unknown'
+  const externalUrl = navigationUnknown ? undefined : current?.url
+  const submit = (event: FormEvent): void => { event.preventDefault(); loadUrl(tab.id, draft) }
+  const failure = state.failure === undefined ? undefined : failureText(state.failure.reason, t)
+  const placeholder = current === undefined ? t('start') : t('loading')
+
+  return (
+    <div className={css.root}>
+      <form className={css.toolbar} onSubmit={submit}>
+        <button type="button" className={css.tool} aria-label={t('back')} title={t('back')} disabled={!BrowserNavigation.canGoBack(state)} onClick={() => { goBack(tab.id) }}><IconChevronLeftOutline14 /></button>
+        <button type="button" className={css.tool} aria-label={t('forward')} title={t('forward')} disabled={!BrowserNavigation.canGoForward(state)} onClick={() => { goForward(tab.id) }}><IconChevronRightOutline14 /></button>
+        <button type="button" className={css.tool} aria-label={t('reload')} title={t('reload')} disabled={current === undefined} onClick={() => { reload(tab.id) }}><IconRefreshOutline14 /></button>
+        <div className={css.addressBox}>
+          <input
+            className={`${css.address} ${navigationUnknown ? css.addressUnknown : ''}`}
+            value={draft}
+            aria-label={t('address.placeholder')}
+            placeholder={t('address.placeholder')}
+            spellCheck={false}
+            onChange={(event) => { setDraft(event.currentTarget.value) }}
+          />
+          {navigationUnknown && <span className={css.addressChanged}>{t('address.changed')}</span>}
+          <button type="submit" className={`${css.tool} ${css.addressGo}`} aria-label={t('go')} title={t('go')}><IconLinkOutline14 /></button>
+        </div>
+        <button
+          type="button"
+          className={css.tool}
+          aria-label={t('external')}
+          title={t('external')}
+          disabled={externalUrl === undefined}
+          onClick={() => {
+            /* v8 ignore next -- React does not dispatch clicks from this disabled button. */
+            if (externalUrl !== undefined) window.open(externalUrl, '_blank', 'noopener,noreferrer')
+          }}
+        ><IconRightUpOutline16 size={14} /></button>
+        <button
+          type="button"
+          className={`${css.tool} ${sandboxed ? '' : css.sandboxOff}`}
+          aria-label={t(sandboxed ? 'sandbox.disable' : 'sandbox.enable')}
+          title={t(sandboxed ? 'sandbox.disable' : 'sandbox.enable')}
+          aria-pressed={!sandboxed}
+          disabled={mountCount === 0}
+          onClick={() => { toggleSandbox(tab.id) }}
+        ><SandboxPolicyIcon sandboxed={sandboxed} /></button>
+      </form>
+      {!sandboxed && <div className={css.sandboxWarning} role="status">{t('sandbox.warning')}</div>}
+      {loadFailed && <div className={css.failure} role="status">{t('web.loadFailed')}</div>}
+      {failure !== undefined && <div className={css.failure} role="alert">{failure}</div>}
+      {document === undefined
+        ? <div className={css.start}>{placeholder}</div>
+        : <iframe
+          key={`${document.target.url}:${String(document.revision)}`}
+          className={css.frame}
+          src={document.src}
+          sandbox={sandboxed ? WEB_BROWSER_SANDBOX : undefined}
+          referrerPolicy="no-referrer"
+          title={document.target.title}
+          onLoad={() => { reportLoaded(tab.id, document.revision) }}
+          /* v8 ignore next -- jsdom does not dispatch React iframe error events; BrowserFrame owns the tested behavior. */
+          onError={() => { reportLoadFailed(tab.id, document.revision) }}
+          data-sidebar-browser-frame
+        />}
+      {navigationUnknown && <p className={css.limit}>{t('web.unknown')}</p>}
+    </div>
+  )
+}

+ 17 - 0
packages/client/ui-sidebar-browser/src/client/view/BrowserTitle.tsx

@@ -0,0 +1,17 @@
+/** Live Browser tab title from the Browser store. */
+import type { ReactNode } from 'react'
+import { IconGlobeOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
+import { BrowserNavigation } from '../browser/BrowserNavigation.ts'
+import type { BrowserStore } from '../browser/store.ts'
+import css from './Browser.module.css'
+
+/** Browser title props assembled by the Sidebar title seat. */
+export type BrowserTitleProps = PropsRuntime<'sidebar.right.pane.tab.title'> & PropsStore<BrowserStore>
+
+/** Browser icon and current host name. */
+export function BrowserTitle({ useTabInfo, useStore }: BrowserTitleProps): ReactNode {
+  const { tab } = useTabInfo()
+  const entry = useStore(state => BrowserNavigation.current(state.byTab[tab.id]))
+  return <><IconGlobeOutline14 className={css.titleIcon} />{entry?.title ?? tab.title}</>
+}

+ 4 - 0
packages/client/ui-sidebar-browser/src/css-modules.d.ts

@@ -0,0 +1,4 @@
+declare module '*.module.css' {
+  const classes: Readonly<Record<string, string>>
+  export default classes
+}

+ 4 - 0
packages/client/ui-sidebar-browser/src/index.ts

@@ -0,0 +1,4 @@
+/** Host companion for the Sidebar Browser Client plugin. */
+
+/** Mount the browser-only plugin through the Client loader. */
+export function apply(): void {}

+ 82 - 0
packages/client/ui-sidebar-browser/tests/apply.client.spec.ts

@@ -0,0 +1,82 @@
+/** Browser type, Slot, locale, and HMR disposal through the real registries. */
+import { describe, expect, it, vi } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
+import { BrowserBody } from '../src/client/view/BrowserBody.tsx'
+import { BrowserTitle } from '../src/client/view/BrowserTitle.tsx'
+import type { BrowserInjected } from '../src/client/browser/BrowserController.ts'
+import { BROWSER_ID, BROWSER_KIND } from '../src/client/definition.tsx'
+import { apply, inject } from '../src/client/index.ts'
+import { en, zh } from '../src/client/locales.ts'
+import { apply as hostApply } from '../src/index.ts'
+
+interface Recorded {
+  name: string
+  key: string
+  locale?: string
+  store?: unknown
+  inject?: unknown
+  component: unknown
+}
+
+async function boot() {
+  const ctx = new Context()
+  const tabs = new SidebarRightTabRegistry(ctx)
+  const registered: Recorded[] = []
+  const slots = {
+    inject: vi.fn((_name: string, register: () => () => void) => register()),
+    register: vi.fn((options: Omit<Recorded, 'component'>, component: unknown) => {
+      const entry: Recorded = { ...options, component }
+      registered.push(entry)
+      return () => { registered.splice(registered.indexOf(entry), 1) }
+    }),
+  }
+  const dictionaries = new Map<string, unknown>()
+  const locale = {
+    bind: vi.fn(() => (key: string) => key),
+    register: vi.fn((namespace: string, value: unknown) => {
+      dictionaries.set(namespace, value)
+      return () => { dictionaries.delete(namespace) }
+    }),
+  }
+  ctx.provide('sidebarRightTabs', tabs as never)
+  ctx.provide('slots', slots as never)
+  ctx.provide('locale', locale as never)
+  const fiber = ctx.plugin({ inject: [...inject], apply })
+  await fiber.await()
+  return { tabs, registered, dictionaries, fiber }
+}
+
+describe('ui-sidebar-browser apply', () => {
+  it('keeps the Host Loader entry inert', () => {
+    expect(hostApply).not.toThrow()
+  })
+
+  it('registers a multi-instance builtin and its body and title', async () => {
+    const { tabs, registered, dictionaries } = await boot()
+    const definition = tabs.get(BROWSER_KIND)
+    expect(definition).toMatchObject({ id: BROWSER_ID, kind: BROWSER_KIND, multiple: true, priority: 'builtin' })
+    expect(definition?.title('sidebar://browser')).toBe('type.label')
+    expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description?.()]))
+      .toEqual([[30, 'guide.title', 'guide.description']])
+    expect(dictionaries.get('sidebarBrowser')).toEqual({ zh, en })
+    expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
+      ['sidebar.right.pane.tab', BROWSER_ID, 'sidebarBrowser', BrowserBody],
+      ['sidebar.right.pane.tab.title', BROWSER_ID, undefined, BrowserTitle],
+    ])
+    expect(registered[0]?.store).toBeDefined()
+    expect(registered[0]?.inject).toBeTypeOf('function')
+    const injectFace = registered[0]?.inject as ((sessionId: string, actions: unknown) => unknown)
+    const browser = injectFace('session', { replace: vi.fn(), forget: vi.fn() }) as BrowserInjected
+    expect(browser.keyedHooks.browserFrame('missing')).toBeUndefined()
+    expect(typeof browser.mount).toBe('function')
+  })
+
+  it('removes every registration when the plugin is disposed', async () => {
+    const { tabs, registered, dictionaries, fiber } = await boot()
+    await fiber.dispose()
+    expect(tabs.get(BROWSER_KIND)).toBeUndefined()
+    expect(registered).toEqual([])
+    expect(dictionaries.size).toBe(0)
+  })
+})

+ 231 - 0
packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx

@@ -0,0 +1,231 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { useSyncExternalStore } from 'react'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { createBrowserControllers } from '../src/client/browser/BrowserController.ts'
+import type { BrowserFrameState } from '../src/client/browser/BrowserFrame.ts'
+import { createBrowserStore } from '../src/client/browser/store.ts'
+import type { BrowserBodyProps } from '../src/client/view/BrowserBody.tsx'
+import { BrowserBody, WEB_BROWSER_SANDBOX } from '../src/client/view/BrowserBody.tsx'
+import { zh } from '../src/client/locales.ts'
+
+const SESSION = 'session' as SessionId
+const TAB = 'tab' as TabId
+const lifetimes = new Set<AbortController>()
+let mountSequence = 0
+
+function hookOf<T>(store: { subscribe(listener: () => void): () => void; getSnapshot(): T }) {
+  return function useSelector<S>(select: (state: T) => S): S {
+    return select(useSyncExternalStore(
+      listener => store.subscribe(listener),
+      () => store.getSnapshot(),
+    ))
+  }
+}
+
+const absentFrame = {
+  subscribe: (_listener: () => void): (() => void) => () => {},
+  getSnapshot: (): BrowserFrameState | undefined => undefined,
+}
+
+function mountBrowser(navigation?: { readonly url?: string }) {
+  const store = createBrowserStore().create(`browser-body-test-${String(++mountSequence)}`)
+  const lifetime = new AbortController()
+  lifetimes.add(lifetime)
+  const injected = createBrowserControllers(store.actions)
+  const { keyedHooks, ...commands } = injected
+  const props = {
+    sessionId: SESSION,
+    useSessions: vi.fn(),
+    useResource: vi.fn(),
+    useWorkspaces: vi.fn(),
+    usePanelInfo: vi.fn(),
+    useSessionPendingInteraction: vi.fn(),
+    useTabInfo: () => ({
+      sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane' },
+      tab: {
+        id: TAB, kind: 'browser', title: 'Browser', contentId: 'sidebar://browser/1', visible: true,
+        navigation: { address: 'sidebar://browser/1', params: navigation, revision: 0 },
+        signal: lifetime.signal,
+        actions: { openResource: vi.fn(), openTab: vi.fn(), close: vi.fn() },
+      },
+    }),
+    useStore: hookOf(store),
+    actions: store.actions,
+    t: (key: keyof typeof zh, params?: Record<string, unknown>) => params === undefined
+      ? zh[key] : zh[key].replace('{message}', String(params.message)),
+    ...commands,
+    useBrowserFrame: (key: string) => {
+      const frame = keyedHooks.browserFrame(key) ?? absentFrame
+      return useSyncExternalStore(frame.subscribe, frame.getSnapshot)
+    },
+  } as unknown as BrowserBodyProps
+  const renderBody = () => render(<BrowserBody {...props} />)
+  return {
+    view: renderBody(), remount: renderBody, store, lifetime, injected,
+  }
+}
+
+afterEach(() => {
+  cleanup()
+  for (const lifetime of lifetimes) lifetime.abort()
+  lifetimes.clear()
+  localStorage.clear()
+  vi.restoreAllMocks()
+})
+
+describe('BrowserBody', () => {
+  it('routes address input to the controller and renders parser failures', async () => {
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.change(input, { target: { value: 'javascript:alert(1)' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.getByRole('alert').textContent).toBe(zh['error.protocol']) })
+    expect(input).toHaveProperty('value', 'javascript:alert(1)')
+    expect(mounted.view.container.querySelector('iframe')).toBeNull()
+    fireEvent.change(input, { target: { value: 'file:///work/index.html' } })
+    fireEvent.submit(input.closest('form')!)
+    expect(mounted.view.getByRole('alert').textContent).toBe(zh['error.protocol'])
+  })
+
+  it('renders HTTPS in the fixed sandbox and follows controller history', async () => {
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.change(input, { target: { value: 'example.com/one' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')).not.toBeNull() })
+    expect(input).toHaveProperty('value', 'https://example.com/one')
+    let frame = mounted.view.container.querySelector('iframe')!
+    expect(frame.getAttribute('src')).toBe('https://example.com/one')
+    expect(frame.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX)
+    expect(frame.getAttribute('allow')).toBeNull()
+    expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer')
+    const disableSandbox = mounted.view.getByRole('button', { name: zh['sandbox.disable'] })
+    const protectedMark = disableSandbox.querySelector('svg path:last-child')?.getAttribute('d')
+    fireEvent.click(disableSandbox)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
+    expect(mounted.view.getByRole('status').textContent).toBe(zh['sandbox.warning'])
+    const enableSandbox = mounted.view.getByRole('button', { name: zh['sandbox.enable'] })
+    expect(enableSandbox.querySelector('svg path:last-child')?.getAttribute('d')).not.toBe(protectedMark)
+    fireEvent.click(enableSandbox)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
+    expect(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }).querySelector('svg path:last-child')?.getAttribute('d')).toBe(protectedMark)
+
+    fireEvent.change(input, { target: { value: 'https://example.com/two' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('https://example.com/two') })
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.back }))
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('https://example.com/one') })
+    expect(input).toHaveProperty('value', 'https://example.com/one')
+    frame = mounted.view.container.querySelector('iframe')!
+    expect(frame.getAttribute('src')).toBe('https://example.com/one')
+    expect(mounted.store.getSnapshot().byTab[TAB]?.index).toBe(0)
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.forward }))
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('https://example.com/two') })
+    expect(input).toHaveProperty('value', 'https://example.com/two')
+
+    const beforeReload = mounted.store.getSnapshot().byTab[TAB]!.request!.revision
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.reload }))
+    expect(mounted.store.getSnapshot().byTab[TAB]!.request?.revision).toBe(beforeReload + 1)
+    fireEvent.submit(input.closest('form')!)
+    expect(mounted.store.getSnapshot().byTab[TAB]!.request?.revision).toBe(beforeReload + 2)
+  })
+
+  it('marks later frame loads unknown and limits unsafe toolbar actions', async () => {
+    const open = vi.spyOn(window, 'open').mockImplementation(() => null)
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.change(input, { target: { value: 'https://example.com/one' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')).not.toBeNull() })
+    fireEvent.change(input, { target: { value: 'https://example.com/two' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('https://example.com/two') })
+    const frame = mounted.view.container.querySelector('iframe')!
+    const revision = mounted.store.getSnapshot().byTab[TAB]!.request!.revision
+
+    fireEvent.load(frame)
+    expect(mounted.store.getSnapshot().byTab[TAB]?.navigation).toEqual({ status: 'known', revision })
+    expect(mounted.view.getByRole('button', { name: zh.back })).toHaveProperty('disabled', false)
+    fireEvent.load(frame)
+    expect(mounted.store.getSnapshot().byTab[TAB]?.navigation).toEqual({ status: 'unknown', revision })
+    expect(mounted.view.getByText(zh['address.changed'])).toBeDefined()
+    expect(mounted.view.getByRole('button', { name: zh.back })).toHaveProperty('disabled', true)
+    expect(mounted.view.getByRole('button', { name: zh.forward })).toHaveProperty('disabled', true)
+    expect(mounted.view.getByRole('button', { name: zh.external })).toHaveProperty('disabled', true)
+    expect(mounted.view.getByRole('button', { name: zh.reload })).toHaveProperty('disabled', false)
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.external }))
+    expect(open).not.toHaveBeenCalled()
+
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.reload }))
+    expect(mounted.store.getSnapshot().byTab[TAB]?.navigation).toEqual({ status: 'loading', revision: revision + 1 })
+  })
+
+  it('shows a best-effort iframe error notice until the next controlled load', async () => {
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.change(input, { target: { value: 'https://example.com/one' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')).not.toBeNull() })
+    const failedRevision = mounted.store.getSnapshot().byTab[TAB]!.request!.revision
+
+    act(() => { mounted.injected.reportLoadFailed(TAB, failedRevision) })
+    expect(mounted.injected.keyedHooks.browserFrame(TAB)?.getSnapshot().loadFailed).toBe(true)
+    await waitFor(() => { expect(mounted.view.getByText(zh['web.loadFailed'])).toBeDefined() })
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.reload }))
+    await waitFor(() => { expect(mounted.view.queryByText(zh['web.loadFailed'])).toBeNull() })
+    act(() => { mounted.injected.reportLoadFailed(TAB, failedRevision) })
+    expect(mounted.view.queryByText(zh['web.loadFailed'])).toBeNull()
+  })
+
+  it('loads loopback under the default sandbox and keeps it across sandbox changes', async () => {
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.change(input, { target: { value: 'http://localhost:5173/' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('http://localhost:5173/') })
+    expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX)
+
+    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }))
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
+    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
+    expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('http://localhost:5173/')
+  })
+
+  it('opens known Web targets externally and consumes an initial typed navigation', async () => {
+    const open = vi.spyOn(window, 'open').mockImplementation(() => null)
+    const mounted = mountBrowser({ url: 'https://initial.example/path' })
+    const input = mounted.view.getByRole('textbox') as HTMLInputElement
+    await waitFor(() => { expect(input.value).toBe('https://initial.example/path') })
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')).not.toBeNull() })
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.external }))
+    expect(open).toHaveBeenCalledWith('https://initial.example/path', '_blank', 'noopener,noreferrer')
+  })
+
+  it('keeps a rejected initial URL in the address input for editing', async () => {
+    const mounted = mountBrowser({ url: 'file:/work/index.html' })
+    const input = mounted.view.getByRole('textbox')
+    await waitFor(() => { expect(mounted.view.getByRole('alert').textContent).toBe(zh['error.protocol']) })
+    expect(input).toHaveProperty('value', 'file:/work/index.html')
+    expect(mounted.view.container.querySelector('iframe')).toBeNull()
+  })
+
+  it('reloads the latest controlled URL instead of replaying the initial URL after remount', async () => {
+    const mounted = mountBrowser({ url: 'https://initial.example/path' })
+    const input = mounted.view.getByRole('textbox')
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('https://initial.example/path') })
+    fireEvent.change(input, { target: { value: 'https://latest.example/path' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('https://latest.example/path') })
+
+    mounted.view.unmount()
+    const remounted = mounted.remount()
+    await waitFor(() => { expect(remounted.container.querySelector('iframe')?.getAttribute('src')).toBe('https://latest.example/path') })
+    expect(remounted.getByRole('textbox')).toHaveProperty('value', 'https://latest.example/path')
+    expect(mounted.store.getSnapshot().byTab[TAB]?.entries.at(-1)?.url).toBe('https://latest.example/path')
+  })
+
+})

+ 142 - 0
packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts

@@ -0,0 +1,142 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { BrowserController, createBrowserControllers } from '../src/client/browser/BrowserController.ts'
+import { createBrowserStore } from '../src/client/browser/store.ts'
+
+const TAB = 'tab' as TabId
+const APP = 'https://dsh.example'
+const lifetimes = new Set<AbortController>()
+
+function lifetime(): AbortController {
+  const controller = new AbortController()
+  lifetimes.add(controller)
+  return controller
+}
+
+afterEach(() => {
+  for (const controller of lifetimes) controller.abort()
+  lifetimes.clear()
+  localStorage.clear()
+  vi.restoreAllMocks()
+})
+
+describe('BrowserController', () => {
+  it('ignores navigation commands after its tab occurrence ends', () => {
+    const store = createBrowserStore().create('browser-controller-ended-test')
+    const tabLifetime = lifetime()
+    const controller = new BrowserController({
+      tabId: TAB, signal: tabLifetime.signal, applicationOrigin: APP, actions: store.actions,
+    })
+    tabLifetime.abort()
+
+    controller.loadUrl('https://ignored.example/')
+    controller.goBack()
+    controller.goForward()
+    controller.reload()
+    controller.frame.reportLoaded(1)
+    expect(store.getSnapshot().byTab[TAB]).toBeUndefined()
+  })
+
+  it('owns the four navigation commands and frame load state', () => {
+    const store = createBrowserStore().create('browser-controller-test')
+    const face = createBrowserControllers(store.actions)
+    const tabLifetime = lifetime()
+    face.mount(TAB, tabLifetime.signal, APP)
+    const frame = face.keyedHooks.browserFrame(TAB)!
+    face.mount(TAB, tabLifetime.signal, APP)
+    expect(face.keyedHooks.browserFrame(TAB)).toBe(frame)
+    face.goBack(TAB)
+    face.goForward(TAB)
+    face.reload(TAB)
+
+    face.loadUrl(TAB, 'https://example.test/one')
+    const first = frame.getSnapshot().document!
+    expect(first.src).toBe('https://example.test/one')
+    face.reportLoaded(TAB, first.revision - 1)
+    face.reportLoaded(TAB, first.revision)
+    expect(store.getSnapshot().byTab[TAB]?.navigation.status).toBe('known')
+    face.reportLoaded(TAB, first.revision)
+    expect(store.getSnapshot().byTab[TAB]?.navigation.status).toBe('unknown')
+    face.goBack(TAB)
+    face.goForward(TAB)
+    expect(store.getSnapshot().byTab[TAB]?.navigation.status).toBe('unknown')
+
+    face.loadUrl(TAB, 'https://example.test/two')
+    face.goBack(TAB)
+    expect(frame.getSnapshot().document?.target.url).toBe('https://example.test/one')
+    face.goForward(TAB)
+    expect(frame.getSnapshot().document?.target.url).toBe('https://example.test/two')
+    const beforeReload = store.getSnapshot().byTab[TAB]!.request!.revision
+    face.reload(TAB)
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeReload + 1)
+    face.loadUrl(TAB, 'https://example.test/two')
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeReload + 2)
+
+    tabLifetime.abort()
+    expect(store.getSnapshot().byTab[TAB]).toBeUndefined()
+    face.reportLoaded(TAB, first.revision)
+    face.loadUrl(TAB, 'https://ignored.example/')
+    face.goBack(TAB)
+    face.goForward(TAB)
+    face.reload(TAB)
+  })
+
+  it('does not let an old occurrence delete a replacement controller', () => {
+    const store = createBrowserStore().create('browser-controller-replacement-test')
+    const face = createBrowserControllers(store.actions)
+    const first = lifetime()
+    const second = lifetime()
+    face.mount(TAB, first.signal, APP)
+    const original = face.keyedHooks.browserFrame(TAB)
+    face.mount(TAB, second.signal, APP)
+    const replacement = face.keyedHooks.browserFrame(TAB)
+    expect(replacement).not.toBe(original)
+    first.abort()
+    expect(store.getSnapshot().byTab[TAB]).toBeUndefined()
+    face.loadUrl(TAB, 'https://example.test/')
+    expect(store.getSnapshot().byTab[TAB]).toBeDefined()
+    face.mount(TAB, second.signal, APP)
+    expect(face.keyedHooks.browserFrame(TAB)).toBe(replacement)
+  })
+
+  it('loads loopback under sandbox and reloads it across sandbox changes', () => {
+    const store = createBrowserStore().create('browser-controller-loopback-test')
+    const face = createBrowserControllers(store.actions)
+    const tabLifetime = lifetime()
+    face.mount(TAB, tabLifetime.signal, APP)
+    const frame = face.keyedHooks.browserFrame(TAB)!
+
+    face.loadUrl(TAB, 'http://localhost:5173/app')
+    expect(frame.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://localhost:5173/app' },
+    })
+    const beforeToggle = store.getSnapshot().byTab[TAB]!.request!.revision
+
+    face.toggleSandbox(TAB)
+    expect(frame.getSnapshot().sandboxed).toBe(false)
+    expect(frame.getSnapshot().document?.src).toBe('http://localhost:5173/app')
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeToggle + 1)
+
+    face.toggleSandbox(TAB)
+    expect(frame.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://localhost:5173/app' },
+    })
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeToggle + 2)
+  })
+
+  it('loads public HTTP without changing the sandbox policy', () => {
+    const store = createBrowserStore().create('browser-controller-http-test')
+    const face = createBrowserControllers(store.actions)
+    const tabLifetime = lifetime()
+    face.mount(TAB, tabLifetime.signal, APP)
+
+    face.loadUrl(TAB, 'http://example.test/path')
+    expect(face.keyedHooks.browserFrame(TAB)?.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://example.test/path' },
+    })
+  })
+})

+ 41 - 0
packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts

@@ -0,0 +1,41 @@
+import { describe, expect, it, vi } from 'vitest'
+import { IframeImpl } from '../src/client/browser/BrowserFrame.ts'
+
+describe('IframeImpl', () => {
+  it('owns transient sandbox mode and prepared document state', () => {
+    const changed = vi.fn<(sandboxed: boolean) => void>()
+    const loaded = vi.fn<(revision: number) => void>()
+    const frame = new IframeImpl(changed, loaded)
+    const listener = vi.fn()
+    const unsubscribe = frame.subscribe(listener)
+
+    frame.toggleSandbox()
+    expect(frame.getSnapshot().sandboxed).toBe(false)
+    expect(changed).toHaveBeenCalledWith(false)
+
+    const document = {
+      target: { kind: 'https' as const, url: 'https://example.test/', title: 'example.test' },
+      src: 'https://example.test/',
+      revision: 4,
+    }
+    frame.reportLoadFailed(4)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    frame.setDocument(document)
+    frame.reportLoadFailed(3)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    frame.reportLoadFailed(4)
+    frame.reportLoadFailed(4)
+    expect(frame.getSnapshot().loadFailed).toBe(true)
+    frame.reportLoaded(4)
+    expect(loaded).toHaveBeenCalledWith(4)
+    frame.setDocument({ ...document, revision: 5 })
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    frame.clearDocument()
+    expect(frame.getSnapshot().document).toBeUndefined()
+    frame.reportLoadFailed(5)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    frame.clearDocument()
+    expect(listener).toHaveBeenCalled()
+    unsubscribe()
+  })
+})

+ 69 - 0
packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts

@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest'
+import type { BrowserTarget } from '../src/client/browser/url.ts'
+import { BrowserNavigation, MAX_BROWSER_HISTORY } from '../src/client/browser/BrowserNavigation.ts'
+
+const httpsTarget = (index: number): BrowserTarget => ({
+  kind: 'https', url: `https://example.test/${index}`, title: `page ${index}`,
+})
+
+describe('BrowserNavigation', () => {
+  it('owns bounded history, branch replacement, and reload revisions', () => {
+    const navigation = new BrowserNavigation()
+    expect(BrowserNavigation.current(navigation.snapshot)).toBeUndefined()
+    expect(navigation.back()).toBeUndefined()
+    expect(navigation.forward()).toBeUndefined()
+    expect(navigation.reload()).toBeUndefined()
+
+    for (let index = 0; index <= MAX_BROWSER_HISTORY; index++) navigation.navigate(httpsTarget(index))
+    expect(navigation.snapshot.entries).toHaveLength(MAX_BROWSER_HISTORY)
+    expect(navigation.snapshot.entries[0]?.url).toBe('https://example.test/1')
+    expect(navigation.canGoBack).toBe(true)
+    expect(navigation.canGoForward).toBe(false)
+
+    const back = navigation.back()
+    expect(back?.target.url).toBe('https://example.test/99')
+    expect(navigation.canGoForward).toBe(true)
+    navigation.navigate(httpsTarget(200))
+    expect(navigation.snapshot.entries.at(-1)?.url).toBe('https://example.test/200')
+    expect(navigation.snapshot.entries.some(entry => entry.url === 'https://example.test/100')).toBe(false)
+
+    const beforeReload = navigation.snapshot.request!.revision
+    navigation.reload()
+    expect(navigation.snapshot.request?.revision).toBe(beforeReload + 1)
+  })
+
+  it('classifies only the first current frame load as known', () => {
+    const navigation = new BrowserNavigation()
+    navigation.navigate(httpsTarget(1))
+    const revision = navigation.snapshot.request!.revision
+    navigation.frameLoaded(revision - 1)
+    expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision })
+    navigation.frameLoaded(revision)
+    expect(navigation.snapshot.navigation).toEqual({ status: 'known', revision })
+    navigation.frameLoaded(revision)
+    expect(navigation.snapshot.navigation).toEqual({ status: 'unknown', revision })
+    navigation.frameLoaded(revision)
+    expect(navigation.snapshot.navigation).toEqual({ status: 'unknown', revision })
+    expect(navigation.back()).toBeUndefined()
+    expect(navigation.forward()).toBeUndefined()
+
+    const reload = navigation.reload()!
+    expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision: reload.revision })
+  })
+
+  it('leaves address failures outside navigation', () => {
+    const navigation = new BrowserNavigation()
+    navigation.frameLoaded(1)
+    navigation.navigate(httpsTarget(1))
+    const revision = navigation.snapshot.request!.revision
+    expect(navigation.snapshot.failure).toBeUndefined()
+    navigation.frameLoaded(revision)
+    expect(navigation.snapshot.navigation).toEqual({ status: 'known', revision })
+
+    navigation.addressFailed('invalid')
+    expect(navigation.snapshot).toMatchObject({
+      navigation: { status: 'known', revision },
+      failure: { kind: 'address', reason: 'invalid' },
+    })
+  })
+})

+ 32 - 0
packages/client/ui-sidebar-browser/tests/browser-title.client.spec.tsx

@@ -0,0 +1,32 @@
+// @vitest-environment jsdom
+import { describe, expect, it } from 'vitest'
+import { render } from '@testing-library/react'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { BrowserNavigation } from '../src/client/browser/BrowserNavigation.ts'
+import type { BrowserTitleProps } from '../src/client/view/BrowserTitle.tsx'
+import { BrowserTitle } from '../src/client/view/BrowserTitle.tsx'
+
+const TAB = 'tab' as TabId
+
+function props(entry = false): BrowserTitleProps {
+  const state = entry
+    ? {
+      ...BrowserNavigation.empty(),
+      entries: [{ kind: 'https' as const, url: 'https://example.test/', title: 'Loaded page' }],
+      index: 0,
+    }
+    : BrowserNavigation.empty()
+  return {
+    useTabInfo: () => ({ tab: { id: TAB, title: 'Browser' } }),
+    useStore: selector => selector({ byTab: { [TAB]: state } }),
+  } as BrowserTitleProps
+}
+
+describe('BrowserTitle', () => {
+  it('uses the current page title and falls back to the tab title', () => {
+    const view = render(<BrowserTitle {...props(true)} />)
+    expect(view.getByText('Loaded page')).toBeDefined()
+    view.rerender(<BrowserTitle {...props()} />)
+    expect(view.getByText('Browser')).toBeDefined()
+  })
+})

+ 21 - 0
packages/client/ui-sidebar-browser/tests/store.client.spec.ts

@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
+import { BrowserNavigation } from '../src/client/browser/BrowserNavigation.ts'
+import { createBrowserStore } from '../src/client/browser/store.ts'
+
+const TAB = 'browser-tab' as TabId
+
+describe('Browser persistence store', () => {
+  it('replaces snapshots and forgets only the closed tab', () => {
+    const store = createBrowserStore().create()
+    const first = new BrowserNavigation()
+    first.navigate({ kind: 'https', url: 'https://example.test/', title: 'example.test' })
+    store.actions.replace(TAB, first.snapshot)
+    const other = 'other-tab' as TabId
+    store.actions.replace(other, BrowserNavigation.empty())
+    expect(BrowserNavigation.current(store.getSnapshot().byTab[TAB])?.url).toBe('https://example.test/')
+    store.actions.forget(TAB)
+    expect(store.getSnapshot().byTab[TAB]).toBeUndefined()
+    expect(store.getSnapshot().byTab[other]).toBeDefined()
+  })
+})

+ 52 - 0
packages/client/ui-sidebar-browser/tests/url.client.spec.ts

@@ -0,0 +1,52 @@
+import { describe, expect, it } from 'vitest'
+import { parseBrowserAddress } from '../src/client/browser/url.ts'
+
+const APP = 'https://dsh.example'
+
+describe('Browser address policy', () => {
+  it('normalizes host names and HTTPS addresses', () => {
+    expect(parseBrowserAddress('example.com/path', APP)).toEqual({
+      ok: true, target: { kind: 'https', url: 'https://example.com/path', title: 'example.com' },
+    })
+    expect(parseBrowserAddress('https://docs.example/a?q=1#x', APP)).toEqual({
+      ok: true, target: { kind: 'https', url: 'https://docs.example/a?q=1#x', title: 'docs.example' },
+    })
+    expect(parseBrowserAddress('example.com:8443/path', APP)).toEqual({
+      ok: true, target: { kind: 'https', url: 'https://example.com:8443/path', title: 'example.com' },
+    })
+  })
+
+  it('accepts HTTP including loopback hosts', () => {
+    expect(parseBrowserAddress('http://localhost:5173/app', APP)).toEqual({
+      ok: true, target: { kind: 'http', url: 'http://localhost:5173/app', title: 'localhost' },
+    })
+    expect(parseBrowserAddress('http://127.42.0.9/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
+    expect(parseBrowserAddress('http://[::1]:8080/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
+    expect(parseBrowserAddress('http://example.com/', APP)).toEqual({
+      ok: true, target: { kind: 'http', url: 'http://example.com/', title: 'example.com' },
+    })
+    expect(parseBrowserAddress('http://128.0.0.1/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
+    expect(parseBrowserAddress('http:/example.com/path', APP)).toEqual({
+      ok: true, target: { kind: 'http', url: 'http://example.com/path', title: 'example.com' },
+    })
+    expect(parseBrowserAddress('https:/example.com/path', APP)).toEqual({
+      ok: true, target: { kind: 'https', url: 'https://example.com/path', title: 'example.com' },
+    })
+  })
+
+  it('rejects every undeclared or privileged form', () => {
+    expect(parseBrowserAddress('', APP)).toEqual({ ok: false, reason: 'empty' })
+    expect(parseBrowserAddress('javascript:alert(1)', APP)).toEqual({ ok: false, reason: 'protocol' })
+    expect(parseBrowserAddress('https://user:secret@example.com', APP)).toEqual({ ok: false, reason: 'credentials' })
+    expect(parseBrowserAddress(`${APP}/session`, APP)).toEqual({ ok: false, reason: 'application-origin' })
+    expect(parseBrowserAddress(`https://${'a'.repeat(17_000)}.example`, APP)).toEqual({ ok: false, reason: 'invalid' })
+    expect(parseBrowserAddress('file:///work/index.html', APP)).toEqual({ ok: false, reason: 'protocol' })
+    expect(parseBrowserAddress('file:////server/share/index.html', APP)).toEqual({ ok: false, reason: 'protocol' })
+    expect(parseBrowserAddress('file:/work/index.html', APP)).toEqual({ ok: false, reason: 'protocol' })
+    expect(parseBrowserAddress('ftp:/example.com/file', APP)).toEqual({ ok: false, reason: 'protocol' })
+    expect(parseBrowserAddress(':::', APP)).toEqual({ ok: false, reason: 'invalid' })
+    expect(parseBrowserAddress('https://example.test', 'not an origin')).toMatchObject({ ok: true })
+    expect(parseBrowserAddress('https://example.test')).toMatchObject({ ok: true })
+    expect(parseBrowserAddress('https://example.test', 'null')).toMatchObject({ ok: true })
+  })
+})

+ 21 - 0
packages/client/ui-sidebar-browser/tsconfig.json

@@ -0,0 +1,21 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    { "path": "../../../vendor/cordis" },
+    { "path": "../locale" },
+    { "path": "../store" },
+    { "path": "../ui-dockkit" },
+    { "path": "../ui-primitives" },
+    { "path": "../ui-renderer" },
+    { "path": "../ui-session" },
+    { "path": "../ui-sidebar-right" },
+    { "path": "../ui-slots" }
+  ]
+}

+ 3 - 0
packages/client/ui-sidebar-browser/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar-browser', ['lib/types/index.js'])

+ 1 - 1
packages/client/ui-sidebar-right/src/client/contract/params.ts

@@ -5,7 +5,7 @@
  * resource type — the segment after `dsh-resource://` — and filled by the
  * package that owns that type (the `file` provider adds `file: { line?: number }`);
  * `SidebarRightTabParamsMap` is keyed by tab kind and filled by a page type that
- * takes parameters (neither shipped page does). Values are JSON-shaped by
+ * takes parameters (the Browser page adds `browser: { url?: string }`). Values are JSON-shaped by
  * convention; nothing validates them at run time, because caller and body meet
  * at a typed same-process boundary. A body narrows `navigation.params` by the
  * scheme and type of `navigation.address`.

+ 7 - 5
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n      { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:217',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:219',
   },
   {
     key: 'conversation.chat.commandview',
@@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     occupants: [],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n      { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:205',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:207',
   },
   {
     key: 'conversation.chat.node',
@@ -293,7 +293,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n      { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:186',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:188',
   },
   {
     key: 'conversation.chat.turnTail',
@@ -341,7 +341,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'none',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n      { name: \'conversation.chat.turnTail\', select: owner => null },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:211',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:213',
   },
   {
     key: 'conversation.composer',
@@ -1049,7 +1049,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     ],
     replaceRisk: 'shadows-shipped-ui',
     example: 'return {\n  inject: [\'slots\'],\n  apply(ctx) {\n    ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n      { name: \'conversation.message.images\' },\n      () => React.createElement(\'div\', null, \'hello\'),\n    ))\n  },\n}',
-    source: 'packages/client/ui-chat/src/client/contract/slots.ts:199',
+    source: 'packages/client/ui-chat/src/client/contract/slots.ts:201',
   },
   {
     key: 'conversation.session',
@@ -2486,6 +2486,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
     occupants: [
       'client-ui-deliverables ReviewTab',
+      'client-ui-sidebar-browser BrowserBody',
       'client-ui-sidebar-documentpreview TextPreview',
       'client-ui-sidebar-files FilesBody',
       'client-ui-sidebar-right GuideBody',
@@ -2533,6 +2534,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
     slotInject: 'SidebarRightTabInjected',
     declaredBy: 'an entry in \'rightbar.session\' (client-ui-sidebar-right), so it exists while that entry is mounted',
     occupants: [
+      'client-ui-sidebar-browser BrowserTitle',
       'client-ui-sidebar-documentpreview TextTitle',
       'client-ui-sidebar-files FilesTitle',
       'client-ui-sidebar-right GuideTitle',

+ 51 - 0
pnpm-lock.yaml

@@ -2187,6 +2187,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-sidebar':
         specifier: workspace:^
         version: link:../../client/ui-sidebar
+      '@deepseek-ai/dsh-client-ui-sidebar-browser':
+        specifier: workspace:^
+        version: link:../../client/ui-sidebar-browser
       '@deepseek-ai/dsh-client-ui-sidebar-documentpreview':
         specifier: workspace:^
         version: link:../../client/ui-sidebar-documentpreview
@@ -2766,6 +2769,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-settings':
         specifier: workspace:^
         version: link:../ui-settings
+      '@deepseek-ai/dsh-client-ui-sidebar-browser':
+        specifier: workspace:^
+        version: link:../ui-sidebar-browser
       '@deepseek-ai/dsh-client-ui-sidebar-documentpreview':
         specifier: workspace:^
         version: link:../ui-sidebar-documentpreview
@@ -4300,6 +4306,51 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1
 
+  packages/client/ui-sidebar-browser:
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
+      '@deepseek-ai/dsh-client-store':
+        specifier: workspace:^
+        version: link:../store
+      '@deepseek-ai/dsh-client-ui-dockkit':
+        specifier: workspace:^
+        version: link:../ui-dockkit
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-client-ui-renderer':
+        specifier: workspace:^
+        version: link:../ui-renderer
+      '@deepseek-ai/dsh-client-ui-session':
+        specifier: workspace:^
+        version: link:../ui-session
+      '@deepseek-ai/dsh-client-ui-sidebar-right':
+        specifier: workspace:^
+        version: link:../ui-sidebar-right
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@testing-library/react':
+        specifier: ^16.1.0
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      '@types/react-dom':
+        specifier: ~18.3.0
+        version: 18.3.7(@types/react@18.3.31)
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+      react-dom:
+        specifier: ^18.2.0
+        version: 18.3.1(react@18.3.1)
+
   packages/client/ui-sidebar-documentpreview:
     dependencies:
       '@deepseek-ai/schemastery':

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -124,6 +124,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the local Desktop or Host OS chooser; registers nothing model-facing.' },
   'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
   'packages/client/ui-sidebar-documentpreview': { kind: 'none', reason: 'Browser-only text viewer; registers no tool, prompt section, or session event, and what the user reads never enters a model request.' },
+  'packages/client/ui-sidebar-browser': { kind: 'none', reason: 'Browser-only navigation surface; visited content never enters a model request.' },
   'packages/client/ui-sidebar-files': { kind: 'none', reason: 'Browser-only workspace file tree; registers no tool, prompt section, or session event.' },
   'packages/client/resources': { kind: 'none', reason: 'Browser-side resource model (providers, pinning, useResource); registers nothing model-facing.' },
   'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },

+ 9 - 0
snapshots/web/sidebar-browser/browser.expected.md

@@ -0,0 +1,9 @@
+# Sidebar Browser
+
+- HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox
+- Permissions Policy: browser defaults
+- Sandbox toggle: per-tab and temporary
+- Unknown navigation: marker shown; Back, Forward, and external-open disabled
+- HTTPS history: one -> two -> one -> two
+- Loopback HTTP: loaded under the default sandbox
+- Invalid protocol: Only HTTP and HTTPS addresses are supported; use Document Preview for local files.

+ 9 - 0
snapshots/web/sidebar-browser/snapshot.yml

@@ -0,0 +1,9 @@
+version: 1
+scenario: sidebar-browser
+profile: web
+composition: web-default
+recording: authored
+header:
+  class: web-default
+session:
+  source: ../lifecycle-chrome/session.v3.jsonl

+ 2 - 0
tsconfig.base.json

@@ -189,6 +189,8 @@
       "@deepseek-ai/dsh-client-ui-sidebar-right/client": ["./packages/client/ui-sidebar-right/src/client"],
       "@deepseek-ai/dsh-client-ui-sidebar-documentpreview": ["./packages/client/ui-sidebar-documentpreview/src"],
       "@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client": ["./packages/client/ui-sidebar-documentpreview/src/client"],
+      "@deepseek-ai/dsh-client-ui-sidebar-browser": ["./packages/client/ui-sidebar-browser/src"],
+      "@deepseek-ai/dsh-client-ui-sidebar-browser/client": ["./packages/client/ui-sidebar-browser/src/client"],
       "@deepseek-ai/dsh-api-workspace-files": ["./packages/api/workspace-files/src"],
       "@deepseek-ai/dsh-api-workspace-files/types": ["./packages/api/workspace-files/src/types.ts"],
       "@deepseek-ai/dsh-api-workspace-files/client": ["./packages/api/workspace-files/src/client/index.ts"],

+ 1 - 0
tsconfig.client.json

@@ -91,6 +91,7 @@
     { "path": "./packages/client/ui-dockkit" },
     { "path": "./packages/client/ui-sidebar-right" },
     { "path": "./packages/client/ui-sidebar-documentpreview/tsconfig.client.json" },
+    { "path": "./packages/client/ui-sidebar-browser" },
     { "path": "./packages/client/ui-sidebar-files" },
     { "path": "./packages/client/ui-jobs" },
     { "path": "./packages/client/ui-schedule" },

+ 1 - 0
tsconfig.host.json

@@ -42,6 +42,7 @@
     "apps/web/tests/details-session-lifecycle.e2e.ts",
     "apps/web/tests/document-preview.e2e.ts",
     "apps/web/tests/office-fixture.ts",
+    "apps/web/tests/sidebar-browser.e2e.ts",
     "apps/web/tests/plugin-config.e2e.ts",
     "apps/web/tests/plugin-manager.e2e.ts",
     "apps/web/tests/plugin-install-cancel.e2e.ts",