@jaychang1989/dsh-webchat 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -2,23 +2,24 @@
2
2
 
3
3
  [简体中文](./README.md) | **English**
4
4
 
5
- Opens the official [chat.deepseek.com](https://chat.deepseek.com) web app from [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) in one click.
5
+ Opens the official [chat.deepseek.com](https://chat.deepseek.com) web app inside [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) — **click the sidebar entry and the page renders in the DSH window itself**, filling the center column like the Automation Tasks page does.
6
6
 
7
- **One sidebar entry. Click it, and the official page opens in a window.**
7
+ No chat UI is reimplemented: model picker, deep think, smart search, history and attachments are all the official ones.
8
8
 
9
- That is the whole plugin. No chat UI, no transcript store, no agent tools — the official web app already is the client: model picker, deep think, smart search, history and attachments all live there.
9
+ ## How it can render inside the window
10
10
 
11
- ## Why it opens a window instead of a pane
11
+ The obvious routes really are closed:
12
12
 
13
- Three routes are closed, and these are measurements rather than preferences:
14
-
15
- | Route | Measured result |
13
+ | Route | Result |
16
14
  | --- | --- |
17
- | `<iframe>` | `chat.deepseek.com` answers with `Content-Security-Policy: frame-ancestors 'none'`, so the browser refuses to render it in a frame |
18
- | Electron `<webview>` | both desktop windows run with `webviewTag: false`, and `will-attach-webview` is explicitly blocked |
19
- | The desktop app's own `WebContentsView` | reserved for platform pages (account/top-up) with navigation pinned to that origin; not reachable from a plugin |
15
+ | `<iframe>` | the site answers `Content-Security-Policy: frame-ancestors 'none'`, so the browser refuses |
16
+ | A plain `<webview>` | the desktop build disables the tag and blocks `will-attach-webview` |
17
+
18
+ But the desktop shell keeps a door open for **approved browser guests**: the renderer asks `globalThis.dshDesktop.browser` for a lease, and the shell approves exactly the `<webview>` whose `src` is `about:blank#<lease>` and whose `partition` matches — everything else stays blocked. This is the mechanism the built-in side-card browser uses; this plugin takes the same channel.
20
19
 
21
- A real window is therefore the only faithful way to show the official page.
20
+ So the page is **not an iframe** — it is a native guest owned by the host, which is why `frame-ancestors` does not apply to it. It also means the desktop app is what makes it possible.
21
+
22
+ Where that bridge is absent (a plain `dsh web` profile) the plugin **falls back** to opening a window, so the entry always does something.
22
23
 
23
24
  ## Install
24
25
 
@@ -38,40 +39,31 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
38
39
 
39
40
  ## Use
40
41
 
41
- 1. Click the "DeepSeek 网页 / DeepSeek Web" entry in the sidebar.
42
- 2. Sign in to DeepSeek once in the window it opens; the session is kept afterwards.
43
-
44
- A short toast in the corner reports the outcome.
45
-
46
- ## How it opens the page
47
-
48
- Tried in order; the first that works wins, and the toast tells you which one it was:
49
-
50
- 1. **App window** — a window created by the DSH desktop process itself (the desktop app is Electron). An already-open one is focused instead of duplicated.
51
- 2. **Chromeless window** — Edge/Chrome in `--app=` mode with its own `--user-data-dir` (`~/.dsh/dsh-webchat/app-window`), so its login stays separate from your everyday browser. Used when (1) cannot reach Electron.
52
- 3. **System browser** — the fallback, so a click always does something.
42
+ 1. Click the "DeepSeek 网页 / DeepSeek Web" entry — the page loads in the center column; there is no second click.
43
+ 2. Sign in to DeepSeek once; that holds for the rest of the run.
53
44
 
54
- None of the three needs configuration.
45
+ Click again to collapse the panel. The guest stays mounted while the panel is closed, so reopening neither reloads the page nor drops the session. Clicking **any other sidebar row** — Plugins, Automation Tasks, the task board, a session, a workspace — hands the center column back, because those pages are rendered there by the shell and this plugin does not hold their seat.
55
46
 
56
47
  ## Requirements
57
48
 
58
49
  - DeepSeek Harness **0.2.0-rc.1 or a newer 0.2.x**
59
50
  - Node.js >= 22
60
- - Strategy 2 needs Edge or Chrome installed; strategy 1 does not
51
+ - The desktop app: only it provides the native guest bridge; plain `dsh web` uses the window fallback
61
52
 
62
53
  ## Known limitations
63
54
 
64
- - The DeepSeek web front end has its own rate limiting and sign-in flow; the plugin only hands the page over and does not mediate its requests.
65
- - A strategy-1 window is owned by the DSH process: if you close the DSH main window while it is still open, DSH will not quit (Electron's `window-all-closed`). Close that window too.
66
- - Reloading the plugin does not close a window that is already open — otherwise a settings change would close the conversation you are reading.
55
+ - **Restarting the desktop app means signing in to DeepSeek again.** The host hands out a **process-lifetime** guest partition (a fresh random name per run, with no `persist:` prefix), so the session is not written to disk. That is the host's mechanism, not a choice this plugin makes.
56
+ - The DeepSeek web front end has its own rate limiting and sign-in flow; the plugin only hosts it and does not mediate its requests.
57
+ - Links the page opens are handled inside the guest; the plugin adds no navigation policy of its own.
67
58
 
68
59
  ## Troubleshooting
69
60
 
70
61
  | Symptom | Cause / fix |
71
62
  | --- | --- |
72
- | The toast says `HTTP 404` | The host half in memory is an older version (the host half loads once per process; the client half is re-fetched on every page load). **Restart the desktop app** |
73
- | The toast says it handed the page to the system browser | Strategies 1 and 2 were both unavailable — check whether the host refused a window, or whether Edge/Chrome is installed |
74
- | The entry is missing from the sidebar | Check that `@jaychang1989/dsh-webchat` is in the profile's `dsh.profile.bundles`, then restart the desktop app |
63
+ | The entry is missing | Check that `@jaychang1989/dsh-webchat` is in the profile's `dsh.profile.bundles`, then restart the desktop app |
64
+ | The center column says "载入失败:…" | The host refused the guest (the bridge threw). The text carries the host's reason |
65
+ | Clicking the entry opened a browser window instead | This renderer had no `dshDesktop.browser` (a plain web profile, for instance), so the fallback ran |
66
+ | It asks for a login again after a restart | See the limitations above — it is the host's partition behaviour |
75
67
 
76
68
  ## Development and tests
77
69
 
@@ -79,10 +71,10 @@ None of the three needs configuration.
79
71
  node --test
80
72
  ```
81
73
 
82
- Thirteen cases: the host's two routes and its window-strategy selection, plus the browser half executed against a DOM stand-in (it mounts the entry, posts to the right route on click, and reports success, handoff and failure honestly).
74
+ Fourteen cases, and the browser half really executes against a DOM stand-in: in a desktop environment it checks that the entry mounts, that clicking reserves a lease, that the webview is built the way the host requires (`about:blank#<lease>`), that navigation happens on `dom-ready`, and that closing and reopening reuses the same guest; without a bridge it checks the window fallback and its reporting.
83
75
 
84
- There is no build step — `lib/index.js` and `lib/client.js` are the hand-written runtime, and the package has **zero runtime dependencies**. See [MAINTAINING.md](./MAINTAINING.md) (Chinese).
76
+ There is no build step — `lib/index.js` and `lib/client.js` are the hand-written runtime, and the package has **zero runtime dependencies**. The guest mechanism is written up in [MAINTAINING.md](./MAINTAINING.md) (Chinese).
85
77
 
86
78
  ## Origin and license
87
79
 
88
- [Apache-2.0](./LICENSE). This package started as a fork of [xmuwenxiang/dsh-web-chat](https://github.com/xmuwenxiang/dsh-web-chat): it reuses that project's plugin scaffolding (the dual-half packaging, the `cordis.patch.yml` row, the `window.__ModuleLoader__.load` bundle form) and its approach to injecting a sidebar entry, while the current behaviour — the window strategies, the entry-as-action, the toast — is new. See [NOTICE](./NOTICE) for the copyright and license statements.
80
+ [Apache-2.0](./LICENSE). This package started as a fork of [xmuwenxiang/dsh-web-chat](https://github.com/xmuwenxiang/dsh-web-chat): it reuses that project's plugin scaffolding (the dual-half packaging, the `cordis.patch.yml` row, the `window.__ModuleLoader__.load` bundle form) and its approach to injecting a sidebar entry, while the current behaviour — the in-window guest panel, the window fallback chain, the entry-as-action — was written here. See [NOTICE](./NOTICE) for the copyright and license statements.
package/README.md CHANGED
@@ -2,23 +2,24 @@
2
2
 
3
3
  **简体中文** | [English](./README.en.md)
4
4
 
5
- 在 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 里一键打开 [chat.deepseek.com](https://chat.deepseek.com) 官方网页版。
5
+ 在 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) 里打开 [chat.deepseek.com](https://chat.deepseek.com) 官方网页版——**点侧边栏入口,页面就直接显示在 DSH 窗口里**,占满中栏,和「自动化任务」那些页面一样。
6
6
 
7
- **侧边栏一个入口,点一下,官方页面就开在一个窗口里。**
7
+ 不重新实现聊天界面:模型选择、深度思考、智能搜索、历史记录、附件上传,全都还是官方那一套。
8
8
 
9
- 仅此而已。不做聊天界面,不做会话存储,不做 agent 工具——官方网页版本身就是完整的客户端,模型选择、深度思考、智能搜索、历史记录、附件上传都在里面。
9
+ ## 它凭什么能显示在窗口内
10
10
 
11
- ## 为什么不嵌在面板里
11
+ iframe 和 `<webview>` 常规路径确实都被封死了:
12
12
 
13
- 三条路都被封死,这是实测结果而不是取舍:
14
-
15
- | 方式 | 实测结果 |
13
+ | 方式 | 结果 |
16
14
  | --- | --- |
17
- | `<iframe>` | `chat.deepseek.com` 返回 `Content-Security-Policy: frame-ancestors 'none'`,浏览器直接拒绝渲染 |
18
- | Electron `<webview>` | 桌面端两个窗口都是 `webviewTag: false`,且 `will-attach-webview` 被显式拦截 |
19
- | 桌面端内置的 `WebContentsView` | 只服务平台页面(账号/充值),导航被限制在该 origin,第三方插件拿不到 |
15
+ | `<iframe>` | 站点返回 `Content-Security-Policy: frame-ancestors 'none'`,浏览器拒绝渲染 |
16
+ | 直接写 `<webview>` | 桌面端默认关闭该标签,且 `will-attach-webview` 一律拦截 |
17
+
18
+ 但桌面端为**受信任的浏览器访客**留了通道:渲染进程向 `globalThis.dshDesktop.browser` 申请一个租约,宿主只放行 `src="about:blank#<lease>"` 且 `partition` 匹配的那一个 `<webview>`,其余照旧拦截。DSH 自带的侧栏浏览器用的就是这套机制,本插件走同一条通道。
20
19
 
21
- 所以忠实呈现官方页面的唯一方式,就是开一个真实窗口。
20
+ 所以这个页面**不是嵌在 iframe 里**,而是宿主的原生访客——`frame-ancestors` 管不到它。这也解释了为什么它必须由 DSH 桌面端承载。
21
+
22
+ 在没有该桥接的环境(纯 `dsh web`,非桌面端)会**自动降级**为打开一个窗口,保证入口永远有反应。
22
23
 
23
24
  ## 安装
24
25
 
@@ -38,40 +39,31 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
38
39
 
39
40
  ## 使用
40
41
 
41
- 1. 点侧边栏的「DeepSeek 网页」入口;
42
- 2. 在弹出的窗口里登录一次 DeepSeek,之后登录态会保留。
43
-
44
- 点击后右下角会有一条短提示说明结果。
45
-
46
- ## 它怎么打开页面
47
-
48
- 按顺序尝试,第一个成功的生效,提示条会告诉你实际用了哪一种:
49
-
50
- 1. **应用窗口** —— 由 DSH 桌面端进程自己创建的窗口(桌面端就是 Electron)。已经开着就聚焦,不会重复开。
51
- 2. **无边框窗口** —— Edge/Chrome 的 `--app=` 模式,使用独立 `--user-data-dir`(`~/.dsh/dsh-webchat/app-window`),因此登录态与你日常浏览器互不干扰。用于第 1 种拿不到 Electron 的场景。
52
- 3. **系统默认浏览器** —— 兜底,保证点击一定有反应。
42
+ 1. 点侧边栏的「DeepSeek 网页」入口 —— 页面直接在中栏载入,不需要第二次点击;
43
+ 2. 在里面登录一次 DeepSeek,当前这次运行内一直有效。
53
44
 
54
- 三种都不需要额外配置。
45
+ 再点一次入口收起面板;面板关闭后访客保持挂载,重新打开不会重新加载、也不会掉登录。点侧边栏里**任何其它行**(插件、自动化任务、任务看板、会话、工作区)都会把中栏让回去——那些页面由 shell 渲染在中栏,本插件不占用它们的席位。
55
46
 
56
47
  ## 环境要求
57
48
 
58
49
  - DeepSeek Harness **0.2.0-rc.1 或更新的 0.2.x**
59
50
  - Node.js >= 22
60
- - 第 2 种方式需要本机装有 Edge 或 Chrome;第 1 种不需要
51
+ - 桌面端(DSH Desktop):只有它提供原生访客桥接;纯 `dsh web` 会走窗口降级
61
52
 
62
53
  ## 已知限制
63
54
 
64
- - 官方网页端有它自己的风控和登录流程,插件只负责把页面交出去,不介入其请求。
65
- - 第 1 种方式创建的窗口由 DSH 进程拥有:如果你先关掉 DSH 主窗口、而它仍开着,DSH 不会退出(Electron 的 `window-all-closed` 语义)。把它一起关掉即可。
66
- - 插件被重载时不会主动关闭已经打开的窗口——否则一次设置变更就会把你正在看的会话关掉。
55
+ - **重启桌面端后需要重新登录 DeepSeek。** 宿主给访客分配的是**进程内**分区(每次运行随机命名、不带 `persist:`),登录态不落盘。这是宿主的机制,插件无法改变。
56
+ - 官方网页端有自己的风控与登录流程,插件只负责把它承载起来,不介入其请求。
57
+ - 页面里指向外部的链接在访客内处理;插件不追加自己的导航策略。
67
58
 
68
59
  ## 排查
69
60
 
70
61
  | 现象 | 原因 / 处理 |
71
62
  | --- | --- |
72
- | 提示条显示 `HTTP 404` | 宿主的旧版本还在内存里(宿主半区只在进程启动时加载,客户端半区每次刷新页面重新取)。**重启桌面端**即可 |
73
- | 提示条显示「已交给系统默认浏览器打开」 | 前两种方式都不可用;看窗口是否被宿主拒绝,或本机没装 Edge/Chrome |
74
- | 入口没出现在侧边栏 | 确认 profile 的 `dsh.profile.bundles` 里有 `@jaychang1989/dsh-webchat`,并重启桌面端 |
63
+ | 入口没出现 | 确认 profile 的 `dsh.profile.bundles` 里有 `@jaychang1989/dsh-webchat`,然后重启桌面端 |
64
+ | 中栏提示「载入失败:…」 | 宿主拒绝了访客(桥接返回异常)。文本里带着宿主给的原因 |
65
+ | 点了入口却弹出一个浏览器窗口 | 说明当前渲染进程拿不到 `dshDesktop.browser`(例如在纯 web 环境),插件走了降级路径 |
66
+ | 重启后要求重新登录 | 见上面的「已知限制」,属于宿主分区机制 |
75
67
 
76
68
  ## 开发与测试
77
69
 
@@ -79,10 +71,10 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
79
71
  node --test
80
72
  ```
81
73
 
82
- 13 个用例:宿主两条路由与窗口策略选择,以及浏览器半区在 DOM 桩里真实执行(挂载入口、点击后请求正确路由、成功/兜底/失败是否都如实提示)。
74
+ 14 个用例,其中浏览器半区在 DOM 桩里**真实执行**:桌面端环境下验证入口挂载、点击后申请租约、按宿主约定生成 `about:blank#<lease>` 的 webview、`dom-ready` 后导航到目标地址、关闭重开复用同一个访客;无桥接环境下验证降级为请求宿主开窗并如实提示结果。
83
75
 
84
- 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码,包内**零运行时依赖**。细节见 [MAINTAINING.md](./MAINTAINING.md)。
76
+ 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码,包内**零运行时依赖**。访客机制的来龙去脉见 [MAINTAINING.md](./MAINTAINING.md)。
85
77
 
86
78
  ## 来源与许可
87
79
 
88
- [Apache-2.0](./LICENSE)。本包最初由 [xmuwenxiang/dsh-web-chat](https://github.com/xmuwenxiang/dsh-web-chat) 分叉而来:沿用了它的插件骨架(双半区打包、`cordis.patch.yml` 行、浏览器 bundle 的 `window.__ModuleLoader__.load` 形式)与侧边栏入口的 DOM 注入思路,当前功能(打开官方页面的窗口策略、入口即动作、提示条)是新写的。版权与许可声明见 [NOTICE](./NOTICE)。
80
+ [Apache-2.0](./LICENSE)。本包最初由 [xmuwenxiang/dsh-web-chat](https://github.com/xmuwenxiang/dsh-web-chat) 分叉而来:沿用其插件骨架(双半区打包、`cordis.patch.yml` 行、浏览器 bundle 的 `window.__ModuleLoader__.load` 形式)与侧边栏入口的 DOM 注入思路;当前功能——中栏访客面板、窗口降级链、入口即动作——都是在这里写的。版权与许可声明见 [NOTICE](./NOTICE)。
package/lib/client.js CHANGED
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * dsh-webchat — browser half.
3
3
  *
4
- * One sidebar entry. Clicking it opens the official DeepSeek web app in a
5
- * window (see lib/index.js) and reports the outcome in a short toast. There is
6
- * no panel and no in-GUI chat: chat.deepseek.com cannot be framed
7
- * (`frame-ancestors 'none'`) and this build has `webviewTag: false`, so the
8
- * official page can only live in a window of its own.
4
+ * One sidebar entry that opens the official chat.deepseek.com web app **inside
5
+ * the DSH window**, in the center column, exactly like the other full-page
6
+ * views (Automation Tasks and friends).
9
7
  *
10
- * Everything here is plain DOM — no React, no build step. The shell exposes no
11
- * slot an external plugin can register into, so the entry row is injected at
12
- * the DOM level and self-heals against React re-renders.
8
+ * It can do that because the desktop shell lends out native browser guests: the
9
+ * renderer asks `globalThis.dshDesktop.browser` for a reservation and then
10
+ * attaches a `<webview>` whose `src` is `about:blank#<lease>` and whose
11
+ * `partition` matches — the shell approves exactly that element and assigns the
12
+ * real web preferences, so the page is not subject to `frame-ancestors` the way
13
+ * an iframe would be. This is the same mechanism the built-in side-card browser
14
+ * uses; see MAINTAINING.md.
15
+ *
16
+ * Where that bridge is absent (a plain `dsh web` profile, not the desktop app)
17
+ * the entry falls back to asking the host half to open the page in a window.
18
+ *
19
+ * Everything here is plain DOM — no React, no build step.
13
20
  */
14
21
  window.__ModuleLoader__.load({
15
22
  id: "@jaychang1989/dsh-webchat",
@@ -17,11 +24,26 @@ window.__ModuleLoader__.load({
17
24
  var module = { exports: {} }
18
25
  var exports = module.exports
19
26
 
20
- /** Routes served by lib/index.js. */
21
- var API = { open: '/api/dsh-webchat/open' }
27
+ /** Host route used only by the window fallback. */
28
+ var OPEN_ROUTE = '/api/dsh-webchat/open'
29
+ /** The page the panel shows. */
30
+ var PAGE_URL = 'https://chat.deepseek.com/'
31
+ /**
32
+ * Storage identity for the guest partition. The shell keys one
33
+ * process-lifetime partition per identity, so this string is what makes
34
+ * the DeepSeek login survive panel close/reopen within one run.
35
+ */
36
+ var STORAGE_IDENTITY = 'dsh-webchat'
37
+
22
38
  var ENTRY_ATTR = 'data-dsh-webchat-entry'
39
+ var VIEW_ATTR = 'data-dsh-webchat-view'
40
+ var ACTIVE_ATTR = 'data-dsh-webchat-active'
41
+ var OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
42
+ var ACTIVATE_EVENT = 'dsh-panel-activate'
43
+ var PANEL_NAME = 'webchat'
23
44
  var STYLE_ID = 'dsh-webchat-style'
24
45
  var TOAST_ATTR = 'data-dsh-webchat-toast'
46
+ var FRAME_CLASS = 'dsh-wc-frame'
25
47
 
26
48
  var ZH = typeof navigator === 'object' && typeof navigator.language === 'string'
27
49
  && navigator.language.toLowerCase().indexOf('zh') === 0
@@ -30,25 +52,33 @@ window.__ModuleLoader__.load({
30
52
  var T = ZH
31
53
  ? {
32
54
  label: 'DeepSeek 网页',
33
- tooltip: '打开 chat.deepseek.com',
34
- busy: '正在打开…',
35
- opened: '已打开 DeepSeek 网页',
36
- handed: '已交给系统默认浏览器打开',
37
- failed: '打开失败',
55
+ tooltip: '在 Harness 内打开 chat.deepseek.com',
56
+ failed: '载入失败',
57
+ fallbackOpened: '已打开 DeepSeek 网页',
58
+ fallbackHanded: '已交给系统默认浏览器打开',
59
+ fallbackFailed: '打开失败',
38
60
  }
39
61
  : {
40
62
  label: 'DeepSeek Web',
41
- tooltip: 'Open chat.deepseek.com',
42
- busy: 'Opening…',
43
- opened: 'DeepSeek Web is open',
44
- handed: 'Handed to the system default browser',
45
- failed: 'Could not open',
63
+ tooltip: 'Open chat.deepseek.com inside Harness',
64
+ failed: 'Could not load it',
65
+ fallbackOpened: 'DeepSeek Web is open',
66
+ fallbackHanded: 'Handed to the system default browser',
67
+ fallbackFailed: 'Could not open',
46
68
  }
47
69
 
48
- /** Entry and toast styling, scoped to this plugin's own attribute. */
70
+ /** Entry, center-column takeover and guest styling. */
49
71
  var CSS = [
72
+ "[data-pane='conversation'],[class*='centerCol']{position:relative}",
73
+ '[' + VIEW_ATTR + ']{position:absolute;inset:0;display:none;z-index:60;background:var(--dsw-alias-bg-base)}',
74
+ 'html[' + ACTIVE_ATTR + ']:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [' + VIEW_ATTR + ']{display:flex}',
75
+ "html[" + ACTIVE_ATTR + "]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [data-pane='conversation'] > :not([" + VIEW_ATTR + ']),',
76
+ 'html[' + ACTIVE_ATTR + "]:not([data-dsh-taskboard-active]):not([data-dsh-ssh-active]) [class*='centerCol'] > :not([" + VIEW_ATTR + ']){display:none !important}',
77
+ '.' + FRAME_CLASS + '{-webkit-app-region:no-drag;border:0;flex:auto;width:100%;min-width:0;height:100%;min-height:0;display:flex}',
78
+ '.dsh-wc-message{margin:auto;max-width:460px;padding:22px;text-align:center;font-size:13px;line-height:1.65;color:var(--dsw-alias-label-secondary);font-family:var(--dsw-font-family)}',
50
79
  '[' + ENTRY_ATTR + ']{display:flex;align-items:center;gap:8px;width:100%;height:32px;padding:0 12px;background:transparent;border:none;border-radius:8px;color:var(--dsw-alias-label-secondary);cursor:pointer;font-size:13px;white-space:nowrap}',
51
80
  '[' + ENTRY_ATTR + ']:hover{background:var(--dsw-specific-sidebar-nav-item-hover);color:var(--dsw-alias-label-primary)}',
81
+ '[' + ENTRY_ATTR + '][data-active]{background:var(--dsw-specific-sidebar-nav-item-active);color:var(--dsw-alias-label-primary);font-weight:600}',
52
82
  '[' + ENTRY_ATTR + '][disabled]{opacity:.55;cursor:progress}',
53
83
  '[' + ENTRY_ATTR + '] [data-part=icon]{display:inline-flex;align-items:center;justify-content:center;flex:none}',
54
84
  '[' + ENTRY_ATTR + '] [data-part=label]{overflow:hidden;text-overflow:ellipsis}',
@@ -58,6 +88,16 @@ window.__ModuleLoader__.load({
58
88
  '[' + TOAST_ATTR + '][data-state=bad]{border-color:var(--dsw-alias-state-danger,#ef4444);color:var(--dsw-alias-state-danger,#ef4444)}',
59
89
  ].join('\n')
60
90
 
91
+ /**
92
+ * The desktop shell's browser bridge, when this renderer is the desktop
93
+ * app's primary frame. A plain web profile exposes only a protocol
94
+ * version, so the window fallback takes over.
95
+ */
96
+ const carrier = globalThis.dshDesktop
97
+ const guests = carrier !== null && typeof carrier === 'object' && carrier.protocolVersion === 1
98
+ ? carrier.browser
99
+ : undefined
100
+
61
101
  /** Inject the stylesheet once. */
62
102
  function ensureStyle() {
63
103
  if (document.getElementById(STYLE_ID) !== null) return
@@ -67,7 +107,7 @@ window.__ModuleLoader__.load({
67
107
  document.head.appendChild(tag)
68
108
  }
69
109
 
70
- /** Show one transient message next to the panel corner. */
110
+ /** Show one transient message in the corner. */
71
111
  function toast(text, state) {
72
112
  var previous = document.querySelector('[' + TOAST_ATTR + ']')
73
113
  if (previous !== null) previous.remove()
@@ -81,15 +121,39 @@ window.__ModuleLoader__.load({
81
121
  }, state === 'bad' ? 8000 : 3000)
82
122
  }
83
123
 
84
- /** Call the host route; a non-JSON or non-2xx answer becomes an error field. */
85
- async function openPage() {
86
- const response = await fetch(API.open, { method: 'POST', headers: { accept: 'application/json' } })
124
+ /** Ask the host half to open the page in a window (the non-desktop path). */
125
+ async function openInWindow() {
126
+ const response = await fetch(OPEN_ROUTE, { method: 'POST', headers: { accept: 'application/json' } })
87
127
  let payload = {}
88
128
  try { payload = await response.json() } catch (error) { payload = {} }
89
129
  if (!response.ok && payload.error === undefined) payload.error = 'HTTP ' + response.status
90
130
  return payload
91
131
  }
92
132
 
133
+ /** Tiny observable for the panel's open state. */
134
+ function createController() {
135
+ var open = false
136
+ var listeners = new Set()
137
+ var notify = function () {
138
+ listeners.forEach(function (listener) {
139
+ try {
140
+ listener()
141
+ } catch (error) {
142
+ console.warn('[dsh-webchat] listener failed:', error)
143
+ }
144
+ })
145
+ }
146
+ return {
147
+ isOpen: function () { return open },
148
+ subscribe: function (listener) {
149
+ listeners.add(listener)
150
+ return function () { listeners.delete(listener) }
151
+ },
152
+ toggle: function () { open = !open; notify() },
153
+ close: function () { if (!open) return; open = false; notify() },
154
+ }
155
+ }
156
+
93
157
  /** The sidebar shell root, or undefined while it is not mounted. */
94
158
  function sidebarRoot() {
95
159
  var column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
@@ -110,7 +174,7 @@ window.__ModuleLoader__.load({
110
174
  }
111
175
 
112
176
  /** Build the entry row. */
113
- function createEntry() {
177
+ function createEntry(onClick) {
114
178
  var entry = document.createElement('button')
115
179
  entry.type = 'button'
116
180
  entry.setAttribute(ENTRY_ATTR, '')
@@ -126,26 +190,7 @@ window.__ModuleLoader__.load({
126
190
  label.textContent = T.label
127
191
  entry.appendChild(icon)
128
192
  entry.appendChild(label)
129
-
130
- entry.addEventListener('click', async function () {
131
- if (entry.disabled) return
132
- entry.disabled = true
133
- var labelNode = entry.querySelector('[data-part=label]')
134
- if (labelNode !== null) labelNode.textContent = T.busy
135
- try {
136
- var outcome = await openPage()
137
- if (outcome.ok === true) {
138
- toast(String(outcome.via) === 'system-browser' ? T.handed : T.opened)
139
- } else {
140
- toast(T.failed + ':' + String(outcome.error || outcome.via || ''), 'bad')
141
- }
142
- } catch (error) {
143
- toast(T.failed + ':' + (error instanceof Error ? error.message : String(error)), 'bad')
144
- }
145
- if (labelNode !== null) labelNode.textContent = T.label
146
- entry.disabled = false
147
- })
148
-
193
+ entry.addEventListener('click', onClick)
149
194
  return entry
150
195
  }
151
196
 
@@ -165,9 +210,7 @@ window.__ModuleLoader__.load({
165
210
  }
166
211
 
167
212
  /** Mount the sidebar entry with its self-healing observers. */
168
- function mountSidebarEntry() {
169
- if (document.querySelector('[' + ENTRY_ATTR + ']') !== null) return function () {}
170
- var entry = createEntry()
213
+ function mountSidebarEntry(controller, entry) {
171
214
  var root
172
215
  var placed = false
173
216
 
@@ -200,22 +243,191 @@ window.__ModuleLoader__.load({
200
243
  if (!root.contains(entry)) placed = placeEntry(root, entry)
201
244
  })
202
245
 
246
+ var syncActive = function () {
247
+ if (controller.isOpen()) entry.dataset.active = 'true'
248
+ else delete entry.dataset.active
249
+ }
250
+ var unsubscribe = controller.subscribe(syncActive)
251
+ syncActive()
203
252
  tryPlace()
204
253
 
205
254
  return function () {
206
255
  waitObserver.disconnect()
207
256
  rootObserver.disconnect()
257
+ unsubscribe()
208
258
  entry.remove()
209
259
  }
210
260
  }
211
261
 
212
- /** Mount the entry. */
262
+ /** The center column that hosts full-page views. */
263
+ function conversationColumn() {
264
+ return document.querySelector('[data-pane="conversation"], [class*="centerCol"]') || undefined
265
+ }
266
+
267
+ /**
268
+ * Mount the panel that carries the guest.
269
+ *
270
+ * The guest is created lazily on first open and then kept mounted while
271
+ * the panel is closed, so re-opening does not reload the page and the
272
+ * DeepSeek session stays where it was.
273
+ * @param controller - panel open state.
274
+ * @returns disposer removing the panel and releasing the guest.
275
+ */
276
+ function mountPanel(controller) {
277
+ var container
278
+ var frame
279
+ var lease
280
+ var failed = false
281
+
282
+ var showMessage = function (text) {
283
+ var box = document.createElement('div')
284
+ box.className = 'dsh-wc-message'
285
+ box.textContent = text
286
+ container.appendChild(box)
287
+ }
288
+
289
+ /** Reserve a guest and attach its webview. */
290
+ var ensureGuest = async function () {
291
+ if (frame !== undefined || failed) return
292
+ try {
293
+ const reservation = await guests.acquire(STORAGE_IDENTITY)
294
+ if (reservation === null || typeof reservation !== 'object'
295
+ || typeof reservation.lease !== 'string' || typeof reservation.partition !== 'string') {
296
+ throw new Error('the desktop browser bridge returned no reservation')
297
+ }
298
+ lease = reservation.lease
299
+ const element = document.createElement('webview')
300
+ element.className = FRAME_CLASS
301
+ element.setAttribute('name', reservation.lease)
302
+ element.setAttribute('partition', reservation.partition)
303
+ element.setAttribute('allowpopups', '')
304
+ element.setAttribute('src', 'about:blank#' + reservation.lease)
305
+ element.addEventListener('dom-ready', function () {
306
+ element.loadURL(PAGE_URL).catch(function (error) {
307
+ toast(T.failed + ':' + (error instanceof Error ? error.message : String(error)), 'bad')
308
+ })
309
+ }, { once: true })
310
+ frame = element
311
+ container.appendChild(element)
312
+ } catch (error) {
313
+ failed = true
314
+ showMessage(T.failed + ':' + (error instanceof Error ? error.message : String(error)))
315
+ }
316
+ }
317
+
318
+ var ensure = function () {
319
+ if (container !== undefined) {
320
+ if (container.isConnected) return
321
+ container.remove()
322
+ container = undefined
323
+ frame = undefined
324
+ }
325
+ var column = conversationColumn()
326
+ if (column === undefined) return
327
+ container = document.createElement('div')
328
+ container.setAttribute(VIEW_ATTR, '')
329
+ container.setAttribute('data-dsh-plugin', 'webchat')
330
+ column.appendChild(container)
331
+ }
332
+
333
+ var waitObserver = new MutationObserver(function () { ensure() })
334
+
335
+ var applyActive = function () {
336
+ if (controller.isOpen()) {
337
+ OTHER_ACTIVE_ATTRS.forEach(function (attr) { document.documentElement.removeAttribute(attr) })
338
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
339
+ // Mount before announcing: the arbitration event dispatches into
340
+ // other plugins, and a throwing listener must not be able to leave
341
+ // this panel half-open.
342
+ ensure()
343
+ void ensureGuest()
344
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
345
+ } else {
346
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
347
+ }
348
+ }
349
+ var onOtherActivate = function (event) {
350
+ var detail = event.detail
351
+ if ((detail === 'taskboard' || detail === 'ssh') && controller.isOpen()) controller.close()
352
+ }
353
+ /**
354
+ * The shell's own panel list owns the center column. Its rows —
355
+ * Plugins, Automation Tasks, the task board, sessions, workspaces —
356
+ * do not take part in the third-party `data-*-active` handshake, so
357
+ * this panel has to yield on any navigation from the sidebar, or its
358
+ * overlay would keep hiding the page the shell just selected.
359
+ */
360
+ var SIDEBAR_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"]'
361
+ var onClickSidebarRow = function (event) {
362
+ if (!controller.isOpen()) return
363
+ var target = event.target
364
+ if (target === null || target.closest === undefined) return
365
+ // This plugin's own row toggles the panel; it is not navigation.
366
+ if (target.closest('[' + ENTRY_ATTR + ']') !== null) return
367
+ if (target.closest(SIDEBAR_SELECTOR) !== null) controller.close()
368
+ }
369
+
370
+ document.addEventListener('click', onClickSidebarRow, true)
371
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
372
+ var unsubscribe = controller.subscribe(applyActive)
373
+ applyActive()
374
+ ensure()
375
+ waitObserver.observe(document.body, { childList: true, subtree: true })
376
+
377
+ return function () {
378
+ document.removeEventListener('click', onClickSidebarRow, true)
379
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
380
+ waitObserver.disconnect()
381
+ unsubscribe()
382
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
383
+ if (frame !== undefined) frame.remove()
384
+ if (container !== undefined) container.remove()
385
+ frame = undefined
386
+ container = undefined
387
+ if (lease !== undefined) {
388
+ const releasing = lease
389
+ lease = undefined
390
+ try { void guests.release(releasing) } catch (error) { /* already gone */ }
391
+ }
392
+ }
393
+ }
394
+
395
+ /** Mount the entry, plus the panel when the desktop bridge is present. */
213
396
  function apply(ctx) {
214
397
  ensureStyle()
215
- var dispose = mountSidebarEntry()
398
+ var controller = createController()
399
+ var disposers = []
400
+
401
+ if (guests !== undefined) {
402
+ // Desktop: the entry toggles the in-app panel.
403
+ var entry = createEntry(function () { controller.toggle() })
404
+ disposers.push(mountSidebarEntry(controller, entry))
405
+ disposers.push(mountPanel(controller))
406
+ } else {
407
+ // Web: no native guest to lend, so the entry asks the host for a window.
408
+ var fallbackEntry = createEntry(async function () {
409
+ if (fallbackEntry.disabled) return
410
+ fallbackEntry.disabled = true
411
+ try {
412
+ var outcome = await openInWindow()
413
+ if (outcome.ok === true) {
414
+ toast(String(outcome.via) === 'system-browser' ? T.fallbackHanded : T.fallbackOpened)
415
+ } else {
416
+ toast(T.fallbackFailed + ':' + String(outcome.error || outcome.via || ''), 'bad')
417
+ }
418
+ } catch (error) {
419
+ toast(T.fallbackFailed + ':' + (error instanceof Error ? error.message : String(error)), 'bad')
420
+ }
421
+ fallbackEntry.disabled = false
422
+ })
423
+ disposers.push(mountSidebarEntry(controller, fallbackEntry))
424
+ }
425
+
216
426
  ctx.effect(function () {
217
- return function () { dispose() }
218
- }, 'dsh-webchat: sidebar entry')
427
+ return function () {
428
+ disposers.splice(0).forEach(function (dispose) { dispose() })
429
+ }
430
+ }, 'dsh-webchat: ui mounts')
219
431
  }
220
432
 
221
433
  exports.apply = apply
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jaychang1989/dsh-webchat",
3
- "version": "0.4.2",
4
- "description": "Opens the official DeepSeek web app (chat.deepseek.com) in a window from DeepSeek Harness: one sidebar entry that opens the page directly, so the official client keeps its model picker, deep think, smart search, history and attachments. No chat UI, no agent tools, no runtime dependencies.",
3
+ "version": "0.5.1",
4
+ "description": "Opens the official DeepSeek web app (chat.deepseek.com) inside DeepSeek Harness: one sidebar entry renders the real page in the center column, through the same native browser guest the built-in side-card browser uses. No chat UI, no agent tools, no runtime dependencies.",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "deepseek-harness",