Kaynağa Gözat

feat(client): add Sidebar Browser

imccyu 4 hafta önce
ebeveyn
işleme
9fed351d3d
36 değiştirilmiş dosya ile 2161 ekleme ve 1 silme
  1. 6 0
      .agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml
  2. 73 0
      .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md
  3. 73 0
      .agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md
  4. 6 0
      packages/client/ui-sidebar-browser/README.i18n.yaml
  5. 114 0
      packages/client/ui-sidebar-browser/README.md
  6. 114 0
      packages/client/ui-sidebar-browser/README.zh.md
  7. 66 0
      packages/client/ui-sidebar-browser/package.json
  8. 179 0
      packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts
  9. 90 0
      packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts
  10. 190 0
      packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts
  11. 38 0
      packages/client/ui-sidebar-browser/src/client/browser/store.ts
  12. 58 0
      packages/client/ui-sidebar-browser/src/client/browser/url.ts
  13. 26 0
      packages/client/ui-sidebar-browser/src/client/definition.tsx
  14. 46 0
      packages/client/ui-sidebar-browser/src/client/index.ts
  15. 61 0
      packages/client/ui-sidebar-browser/src/client/locales.ts
  16. 167 0
      packages/client/ui-sidebar-browser/src/client/view/Browser.module.css
  17. 142 0
      packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx
  18. 17 0
      packages/client/ui-sidebar-browser/src/client/view/BrowserTitle.tsx
  19. 4 0
      packages/client/ui-sidebar-browser/src/css-modules.d.ts
  20. 4 0
      packages/client/ui-sidebar-browser/src/index.ts
  21. 82 0
      packages/client/ui-sidebar-browser/tests/apply.client.spec.ts
  22. 200 0
      packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx
  23. 127 0
      packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts
  24. 29 0
      packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts
  25. 78 0
      packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts
  26. 32 0
      packages/client/ui-sidebar-browser/tests/browser-title.client.spec.tsx
  27. 21 0
      packages/client/ui-sidebar-browser/tests/store.client.spec.ts
  28. 42 0
      packages/client/ui-sidebar-browser/tests/url.client.spec.ts
  29. 21 0
      packages/client/ui-sidebar-browser/tsconfig.json
  30. 3 0
      packages/client/ui-sidebar-browser/tsdown.config.ts
  31. 1 1
      packages/client/ui-sidebar-right/src/client/contract/params.ts
  32. 2 0
      packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
  33. 45 0
      pnpm-lock.yaml
  34. 1 0
      scripts/verify-package-readme-model-experience.ts
  35. 2 0
      tsconfig.base.json
  36. 1 0
      tsconfig.client.json

+ 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: ce4362f42b7664a6943712ec716d550b658afdc1
+2026-09-16-sidebar-browser.zh.md: 4ba6f27f8458a8e17b37b86ab667c608133e5bb9

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

@@ -0,0 +1,73 @@
+# 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.
+
+The address parser accepts `https:` and `http:` only for `localhost`, `[::1]`, and `127.0.0.0/8`; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, public HTTP, `file:` URLs, and every other scheme. The controller additionally refuses loopback Web targets while that tab's sandbox is enabled. 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"`, without download or top-navigation capability; popups escape the sandbox. 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 and allows controller-directed loopback navigation for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page that reaches the DSH origin can access that origin's Web data. 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, and removes the tab bucket when that occurrence ends.
+
+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. |
+| `failed` | The current target is refused by carrier policy before the iframe loads. | The known target remains editable and the failure is shown. History, Reload, and external-open continue to follow the known target. |
+
+Address editing is available in every state. An invalid or sandbox-blocked 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; restoring the sandbox refuses a current loopback target. `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 HTTPS or explicit loopback HTTP. Requests may use HTTPS, secure WebSocket, data, and Blob URLs, plus HTTP and insecure WebSocket only on loopback; direct file, public HTTP, 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 HTTPS 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, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, and plugin disposal. The keyless Web scenario boots the shipped composition and exercises HTTPS, 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 HTTPS sites refuse iframe embedding or require downloads or top-level navigation withheld by the default sandbox. Disabling the sandbox trades those protections for compatibility and exposes loopback services to controller-directed navigation, but it does not add 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.

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

@@ -0,0 +1,73 @@
+# 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。
+
+地址解析器接受 `https:`,以及仅指向 `localhost`、`[::1]` 与 `127.0.0.0/8` 的 `http:`;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、公共 HTTP、`file:` URL,以及所有其他 scheme。该 tab 的 sandbox 启用时,controller 还会拒绝 loopback Web 目标。本地文件继续由 Document Preview 负责。
+
+当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`,不具备下载或顶层导航能力;popup 会脱离 sandbox。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会移除 sandbox attribute,并为当前 tab occurrence 允许 controller 发起 loopback 导航;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面一旦到达 DSH origin,就可以访问该 origin 的 Web 数据。本包不执行 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,供标题渲染与应用刷新恢复,并在该 tab occurrence 结束时删除 bucket。
+
+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。 |
+| `failed` | iframe 加载前,当前目标被载体策略拒绝。 | 已知目标保持可编辑并显示失败信息。history、刷新与外部打开继续遵循已知目标。 |
+
+每个状态都允许编辑地址。无效或被 sandbox 阻止的草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标;恢复 sandbox 会拒绝当前 loopback 目标。不产生 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 导航与重定向前往不含凭据的 HTTPS 或显式 loopback HTTP。请求可以使用 HTTPS、安全 WebSocket、data 与 Blob URL,也可仅在 loopback 使用 HTTP 与不安全 WebSocket;直接 file、公共 HTTP、自定义协议、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 代理 HTTPS 页面。** 不采用,因为兼容代理必须重写 URL、CSP、Cookie、module、stream、form 与 download,同时会把 Host 变成通用出站请求器。
+
+**在首个 Browser 变更中实现 Electron 载体。** 延期处理,避免在没有打包应用证据覆盖 overlay stacking、target lifetime、Cookie 隔离与全部 permission 拒绝路径时启用新的 Electron guest surface。
+
+## Verification
+
+单元测试覆盖协议解析、controller 命令与生命周期、确定性导航状态转换、有界 history 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖 HTTPS、后退、前进、sandbox 控制、unknown 导航和协议拒绝。
+
+## Consequences
+
+Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多 HTTPS 站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不提供的下载或顶层导航。关闭 sandbox 会用这些保护换取兼容性,并允许 controller 发起 loopback 导航,但不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。
+
+站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。

+ 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: 12c50ceae7d2d7dd6c7c35ef94deaa13dea766ad
+README.zh.md: c482ca8ec5d864a9c854b84d7d8fe4cea124d84f

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

@@ -0,0 +1,114 @@
+---
+description: "Right-Sidebar browser tabs for isolated HTTPS pages and opt-in loopback services."
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-browser
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Browse HTTPS pages and opt-in loopback HTTP 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, then enter an HTTPS URL or a host name that should become HTTPS. Disable that tab's sandbox before opening a loopback HTTP URL. Each guide action 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, displays a warning, and permits controller-directed loopback navigation. The external action accepts a known HTTPS or loopback HTTP 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 HTTPS and HTTP only for `localhost`, `[::1]`, and `127.0.0.0/8`. The controller permits those loopback targets only while the current tab's sandbox is disabled. It rejects public HTTP, `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, without download or top-navigation capability. Popups leave the sandbox. 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 that reaches the DSH origin can access that origin's Web data. 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.
+
+### 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 by the default sandbox. Disabling the sandbox trades those restrictions for compatibility and permits controller-directed loopback navigation. It does not isolate the visited origin's cookies per Browser tab and cannot prevent an in-frame page from choosing its own next URL.
+- 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.
+- 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.

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

@@ -0,0 +1,114 @@
+---
+description: "右侧 Sidebar 浏览器 tab:隔离访问 HTTPS 页面与选择性开放的 loopback 服务。"
+kind: "package-reference"
+---
+
+# @deepseek-ai/dsh-client-ui-sidebar-browser
+
+[English](README.md) | 中文
+
+## 概述
+
+在独立的右侧 Sidebar tab 中浏览 HTTPS 页面与选择性开放的 loopback HTTP 服务。当前 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 打开 **浏览器**,再输入 HTTPS URL 或应补全为 HTTPS 的主机名。打开 loopback HTTP URL 前,需要关闭该 tab 的 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 是临时选择,会显示警告并允许 controller 发起 loopback 导航。外部打开接受已知的 HTTPS 或 loopback HTTP 目标。tab 标题显示 Web 主机名。
+
+-----
+
+<a id="understand-the-implementation"></a>
+## 了解实现
+
+<details>
+<summary>实现细节——点击展开</summary>
+
+### 协议策略
+
+地址解析器接受 HTTPS,以及仅指向 `localhost`、`[::1]` 与 `127.0.0.0/8` 的 HTTP。controller 只在当前 tab 的 sandbox 关闭时允许这些 loopback 目标。公共 HTTP、`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"`,不授予下载或顶层导航能力。popup 会脱离 sandbox。被访问的 origin 可以使用自身 Cookie 与 Web storage,但跨域目标无法读取 DSH DOM、storage 或 API 响应。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。toolbar 可以为当前 tab occurrence 移除 sandbox;该选择不持久化。未受 sandbox 约束的页面一旦到达 DSH origin,就可以访问该 origin 的 Web 数据。本包不代理或探测远程页面。
+
+Web 记录 toolbar 提交和 typed tab 打开。导航状态机把每个受控 revision 的第一次 iframe load 视为已知,把后续 load 视为页面已经变化到不可读取 URL 的证据。进入 unknown 状态后,地址会显示标记,后退、前进和外部打开会禁用,刷新则返回最后一个受控 URL。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。不产生 iframe load 的 History API 与 fragment 变化仍不可见。
+
+### 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 不授予的下载与顶层导航。关闭 sandbox 会用这些限制换取兼容性,并允许 controller 发起 loopback 导航。该模式不会按 Browser tab 隔离被访问 origin 的 Cookie,也无法阻止 iframe 内页面自行选择后续 URL。
+- 后续 iframe load 能表明发生了导航,但无法给出新的跨域 URL。History API 与 fragment 变化可能仍不可见;状态变成 unknown 后,Web 的后退与前进不可用。
+- 本地文件会被拒绝,并继续由 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"
+  ]
+}

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

@@ -0,0 +1,179 @@
+/** 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 { isLoopbackHostname, parseBrowserAddress } from './url.ts'
+import type { BrowserTarget } 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
+    }
+    if (this.loopbackBlocked(parsed.target)) {
+      this.navigation.addressFailed('loopback')
+      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()
+    if (this.loopbackBlocked(request.target)) {
+      this.navigation.requestBlocked(request.revision, 'loopback')
+      this.publish()
+      return
+    }
+    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 loopbackBlocked(target: BrowserTarget): boolean {
+    return this.frame.getSnapshot().sandboxed
+      && isLoopbackHostname(new URL(target.url).hostname)
+  }
+
+  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 local-document link 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
+}
+
+/**
+ * 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) },
+  }
+}

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

@@ -0,0 +1,90 @@
+/** 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
+}
+
+/** 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
+  /** @returns the detached document, if one existed. */
+  clearDocument(): BrowserDocument | undefined
+  /** @param revision - rendered document revision reported by the carrier. */
+  reportLoaded(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,
+  })
+
+  /**
+   * @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.
+   * @internal
+   */
+  setDocument(document: BrowserDocument): void {
+    this.store.set({ ...this.store.getSnapshot(), document })
+  }
+
+  /**
+   * Remove and return the previous frame for resource cleanup.
+   * @returns the detached frame, if one existed.
+   * @internal
+   */
+  clearDocument(): BrowserDocument | undefined {
+    const document = this.store.getSnapshot().document
+    if (document !== undefined) this.store.set({ ...this.store.getSnapshot(), document: undefined })
+    return document
+  }
+
+  /** @param revision - rendered document revision reported by the iframe. */
+  reportLoaded(revision: number): void {
+    this.documentLoaded(revision)
+  }
+}

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

@@ -0,0 +1,190 @@
+/** 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 }
+  | { readonly status: 'failed'; 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 } }
+    }
+  }
+
+  /**
+   * Refuse a current request after its target becomes disallowed by carrier policy.
+   * @param revision - blocked request revision.
+   * @param reason - address-policy refusal.
+   */
+  requestBlocked(revision: number, reason: BrowserAddressFailure): void {
+    const navigation = this.value.navigation
+    if (navigation.status !== 'loading' || navigation.revision !== revision) return
+    this.value = {
+      ...this.value,
+      navigation: { status: 'failed', revision },
+      failure: { kind: 'address', reason },
+    }
+  }
+
+  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>

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

@@ -0,0 +1,58 @@
+/** Address parsing for the Sidebar browser's HTTPS and loopback HTTP 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' | 'loopback'
+
+/** 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+.-]*:\/\//u.test(trimmed)
+  const nonHierarchicalScheme = /^(?:about|blob|data|javascript|mailto|tel|view-source):/iu.test(trimmed)
+  const candidate = explicitScheme || nonHierarchicalScheme ? 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:' && isLoopbackHostname(url.hostname))) {
+    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' }
+}
+
+/**
+ * Test whether a normalized URL host belongs to the local loopback interface.
+ * @param hostname - URL hostname with IPv6 brackets retained.
+ * @returns whether the hostname is localhost, IPv6 loopback, or IPv4 127/8.
+ */
+export function isLoopbackHostname(hostname: string): boolean {
+  if (hostname === 'localhost' || hostname === '[::1]') return true
+  const octets = hostname.split('.')
+  return octets.length === 4 && octets[0] === '127'
+    && octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255)
+}

+ 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 HTTPS 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': '浏览 HTTPS 网页或 loopback 服务',
+  'address.placeholder': '输入 HTTPS 或本地 HTTP 地址',
+  'address.changed': 'URL 已变化',
+  back: '后退',
+  forward: '前进',
+  reload: '刷新',
+  go: '前往',
+  external: '在系统浏览器中打开',
+  'sandbox.disable': '关闭沙箱限制并允许 loopback',
+  'sandbox.enable': '恢复沙箱限制并禁止 loopback',
+  'sandbox.warning': '沙箱限制已关闭;页面若到达 DSH 同源地址,可以访问该 origin 的网页数据。',
+  start: '输入 HTTPS 地址开始浏览',
+  loading: '正在打开…',
+  'error.empty': '请输入地址。',
+  'error.invalid': '这个地址无效或过长。',
+  'error.protocol': '只支持 HTTPS 和 loopback HTTP 地址;本地文件请使用文档预览。',
+  'error.credentials': '地址不能包含用户名或密码。',
+  'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。',
+  'error.loopback': '关闭沙箱限制后才能打开 loopback 地址。',
+  '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 HTTPS pages or loopback services',
+  'address.placeholder': 'Enter an HTTPS or local HTTP address',
+  'address.changed': 'URL changed',
+  back: 'Back',
+  forward: 'Forward',
+  reload: 'Reload',
+  go: 'Go',
+  external: 'Open in system browser',
+  'sandbox.disable': 'Disable sandbox and allow loopback',
+  'sandbox.enable': 'Restore sandbox and block loopback',
+  'sandbox.warning': 'Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.',
+  start: 'Enter an HTTPS address to start browsing',
+  loading: 'Opening…',
+  'error.empty': 'Enter an address.',
+  'error.invalid': 'That address is invalid or too long.',
+  'error.protocol': 'Only HTTPS and loopback HTTP 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.',
+  'error.loopback': 'Disable sandbox restrictions before opening a loopback address.',
+  '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;
+}

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

@@ -0,0 +1,142 @@
+/** Browser toolbar and Web iframe renderer. */
+import { useEffect, useRef, useState } from 'react'
+import type { FormEvent, ReactNode } from 'react'
+import {
+  IconChevronLeftOutline14,
+  IconChevronRightOutline14,
+  IconLinkOutline14,
+  IconRefreshOutline14,
+  IconRightUpOutline16,
+  IconShieldOutline16,
+} 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 }
+
+/** 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, 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, 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 } = 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')
+    : state.navigation.status === 'failed' ? undefined : 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) }}
+        ><IconShieldOutline16 size={15} /></button>
+      </form>
+      {!sandboxed && <div className={css.sandboxWarning} role="status">{t('sandbox.warning')}</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) }}
+          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)
+  })
+})

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

@@ -0,0 +1,200 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { 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,
+  }
+}
+
+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'] })
+    fireEvent.click(disableSandbox)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
+    expect(mounted.view.getByRole('status').textContent).toBe(zh['sandbox.warning'])
+    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
+
+    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 loopback policy failure without a loading placeholder', async () => {
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }))
+    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/') })
+
+    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
+    expect(mounted.view.getByRole('alert').textContent).toBe(zh['error.loopback'])
+    expect(mounted.view.queryByText(zh.loading)).toBeNull()
+  })
+
+  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('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')
+  })
+
+})

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

@@ -0,0 +1,127 @@
+// @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('allows loopback only while this tab has disabled its sandbox', () => {
+    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(store.getSnapshot().byTab[TAB]).toMatchObject({
+      navigation: { status: 'empty' }, failure: { kind: 'address', reason: 'loopback' },
+    })
+    expect(frame.getSnapshot().document).toBeUndefined()
+
+    face.toggleSandbox(TAB)
+    expect(frame.getSnapshot().sandboxed).toBe(false)
+    face.loadUrl(TAB, 'http://localhost:5173/app')
+    expect(frame.getSnapshot().document?.src).toBe('http://localhost:5173/app')
+
+    face.toggleSandbox(TAB)
+    expect(frame.getSnapshot()).toEqual({ document: undefined, sandboxed: true })
+    expect(store.getSnapshot().byTab[TAB]).toMatchObject({
+      navigation: { status: 'failed' }, failure: { kind: 'address', reason: 'loopback' },
+    })
+  })
+})

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

@@ -0,0 +1,29 @@
+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.setDocument(document)
+    frame.reportLoaded(4)
+    expect(loaded).toHaveBeenCalledWith(4)
+    expect(frame.clearDocument()).toBe(document)
+    expect(frame.clearDocument()).toBeUndefined()
+    expect(listener).toHaveBeenCalled()
+    unsubscribe()
+  })
+})

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

@@ -0,0 +1,78 @@
+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('keeps blocked requests revision-scoped and 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.reload()
+    const reloadRevision = navigation.snapshot.request!.revision
+    navigation.requestBlocked(reloadRevision - 1, 'loopback')
+    expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision: reloadRevision })
+    navigation.requestBlocked(reloadRevision, 'loopback')
+    expect(navigation.snapshot).toMatchObject({
+      navigation: { status: 'failed', revision: reloadRevision },
+      failure: { kind: 'address', reason: 'loopback' },
+    })
+    navigation.addressFailed('invalid')
+    expect(navigation.snapshot).toMatchObject({
+      navigation: { status: 'failed', revision: reloadRevision },
+      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()
+  })
+})

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

@@ -0,0 +1,42 @@
+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('allows HTTP only on explicit 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: false, reason: 'protocol' })
+    expect(parseBrowserAddress('http://128.0.0.1/', APP)).toEqual({ ok: false, reason: 'protocol' })
+  })
+
+  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(':::', 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`.

+ 2 - 0
packages/extensions/cordis-client-runner/src/client/slot-catalog.ts

@@ -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',

+ 45 - 0
pnpm-lock.yaml

@@ -4294,6 +4294,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.' },

+ 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" },