@jaychang1989/dsh-webchat 0.5.1 → 0.6.0

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
@@ -19,6 +19,8 @@ But the desktop shell keeps a door open for **approved browser guests**: the ren
19
19
 
20
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
21
 
22
+ The sidebar row and the page are both standard DSH **slots** (`sidebar.panellist` and `main`, joined by one shared id), so the shell owns the button and the panel switching. That is why this plugin injects nothing into anyone else's DOM and never has to guess when to step aside.
23
+
22
24
  Where that bridge is absent (a plain `dsh web` profile) the plugin **falls back** to opening a window, so the entry always does something.
23
25
 
24
26
  ## Install
@@ -42,7 +44,7 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
42
44
  1. Click the "DeepSeek 网页 / DeepSeek Web" entry — the page loads in the center column; there is no second click.
43
45
  2. Sign in to DeepSeek once; that holds for the rest of the run.
44
46
 
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.
47
+ Click again to collapse the panel. Switching to another panel (Plugins, Automation Tasks, the task board, a session …) only hides the guest, never unmounts it, so coming back neither reloads the page nor drops the session.
46
48
 
47
49
  ## Requirements
48
50
 
@@ -60,10 +62,12 @@ Click again to collapse the panel. The guest stays mounted while the panel is cl
60
62
 
61
63
  | Symptom | Cause / fix |
62
64
  | --- | --- |
63
- | The entry is missing | Check that `@jaychang1989/dsh-webchat` is in the profile's `dsh.profile.bundles`, then restart the desktop app |
65
+ | No "DeepSeek Web" row in the sidebar | Check that `@jaychang1989/dsh-webchat` is in the profile's `dsh.profile.bundles`, then restart the desktop app |
64
66
  | 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 |
67
+ | The row opens a browser window instead of a panel | This renderer had no `dshDesktop.browser` (a plain web profile, for instance), so the fallback ran |
68
+ | The row opens but the center column is blank | The panel fills the cell the shell allocates; in a very small window, or with the sidebar dragged extremely narrow, that cell can have no area |
66
69
  | It asks for a login again after a restart | See the limitations above — it is the host's partition behaviour |
70
+ | The page shows "Abnormal usage environment" | DeepSeek's front end checks `navigator.userAgent` for the string `electron` — which the desktop default carries — and then recommends its official product. Since 0.5.2 the guest presents a plain Chrome user agent and the dialog no longer appears |
67
71
 
68
72
  ## Development and tests
69
73
 
@@ -71,7 +75,7 @@ Click again to collapse the panel. The guest stays mounted while the panel is cl
71
75
  node --test
72
76
  ```
73
77
 
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.
78
+ Nineteen cases. The host half is driven through a fake context and fake request/response objects; the browser half really executes against a thin React test double plus a DOM stand-in: it checks that the plugin registers its nav row and its page under the slot contract (one shared id), that the label follows the language, that the icon honours the size the shell asks for, and the whole guest lifecycle — reserving a lease, building the webview the way the host requires (`about:blank#<lease>`), setting the user agent before navigating on `dom-ready`, **hiding the guest without detaching it when the panel unmounts and reusing it on remount**, and releasing the lease on plugin disposal; without a bridge it checks the window fallback and its reporting.
75
79
 
76
80
  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).
77
81
 
package/README.md CHANGED
@@ -19,6 +19,8 @@ iframe 和 `<webview>` 常规路径确实都被封死了:
19
19
 
20
20
  所以这个页面**不是嵌在 iframe 里**,而是宿主的原生访客——`frame-ancestors` 管不到它。这也解释了为什么它必须由 DSH 桌面端承载。
21
21
 
22
+ 而「侧边栏那一行」和「中栏那一页」都是 DSH 的**标准槽位**(`sidebar.panellist` 与 `main`,同一个 id 关联),按钮和面板切换都由 shell 自己掌管——所以这个插件不往别人的 DOM 里塞东西,也不需要去猜什么时候该让位。
23
+
22
24
  在没有该桥接的环境(纯 `dsh web`,非桌面端)会**自动降级**为打开一个窗口,保证入口永远有反应。
23
25
 
24
26
  ## 安装
@@ -42,7 +44,7 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
42
44
  1. 点侧边栏的「DeepSeek 网页」入口 —— 页面直接在中栏载入,不需要第二次点击;
43
45
  2. 在里面登录一次 DeepSeek,当前这次运行内一直有效。
44
46
 
45
- 再点一次入口收起面板;面板关闭后访客保持挂载,重新打开不会重新加载、也不会掉登录。点侧边栏里**任何其它行**(插件、自动化任务、任务看板、会话、工作区)都会把中栏让回去——那些页面由 shell 渲染在中栏,本插件不占用它们的席位。
47
+ 再点一次入口收起面板。切换去别的面板(插件、自动化任务、任务看板、会话……)时访客只是被隐藏、从不被卸载,所以切回来不会重新加载、也不会掉登录。
46
48
 
47
49
  ## 环境要求
48
50
 
@@ -60,10 +62,12 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
60
62
 
61
63
  | 现象 | 原因 / 处理 |
62
64
  | --- | --- |
63
- | 入口没出现 | 确认 profile 的 `dsh.profile.bundles` 里有 `@jaychang1989/dsh-webchat`,然后重启桌面端 |
65
+ | 侧边栏没出现「DeepSeek 网页」这一行 | 确认 profile 的 `dsh.profile.bundles` 里有 `@jaychang1989/dsh-webchat`,然后重启桌面端 |
64
66
  | 中栏提示「载入失败:…」 | 宿主拒绝了访客(桥接返回异常)。文本里带着宿主给的原因 |
65
- | 点了入口却弹出一个浏览器窗口 | 说明当前渲染进程拿不到 `dshDesktop.browser`(例如在纯 web 环境),插件走了降级路径 |
67
+ | 这一行点了但中栏没有页面,反而弹出浏览器窗口 | 说明当前渲染进程拿不到 `dshDesktop.browser`(例如在纯 web 环境),插件走了降级路径 |
68
+ | 这一行点了但中栏是空白 | 面板显示的空间是 shell 分配的那个格子;若窗口极小或侧栏被拖到极窄,格子可能没有面积 |
66
69
  | 重启后要求重新登录 | 见上面的「已知限制」,属于宿主分区机制 |
70
+ | 页面弹出「使用环境异常」 | DeepSeek 前端会检查 `navigator.userAgent` 里是否含 `electron`(桌面端默认 UA 就含),命中就提示"建议使用官方产品"。0.5.2 起访客改用普通 Chrome UA,不再触发 |
67
71
 
68
72
  ## 开发与测试
69
73
 
@@ -71,9 +75,9 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
71
75
  node --test
72
76
  ```
73
77
 
74
- 14 个用例,其中浏览器半区在 DOM 桩里**真实执行**:桌面端环境下验证入口挂载、点击后申请租约、按宿主约定生成 `about:blank#<lease>` 的 webview、`dom-ready` 后导航到目标地址、关闭重开复用同一个访客;无桥接环境下验证降级为请求宿主开窗并如实提示结果。
78
+ 19 个用例。宿主半区用假 context 与假 request/response 驱动;浏览器半区用一层薄的 React 测试替身 + DOM 桩**真实执行**:验证它按槽位契约注册导航行与页面(同一个 id)、标签随语言变化、图标遵循 shell 要求的尺寸,以及访客生命周期——申请租约、按宿主约定生成 `about:blank#<lease>` 的 webview、`dom-ready` 后先改 UA 再导航、**卸载只隐藏不卸载访客、重挂复用同一个**、插件卸载时释放租约;无桥接环境下验证降级为请求宿主开窗并如实反馈。
75
79
 
76
- 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码,包内**零运行时依赖**。访客机制的来龙去脉见 [MAINTAINING.md](./MAINTAINING.md)。
80
+ 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码(React 取自浏览器的模块表),包内**零运行时依赖**。访客机制与槽位契约见 [MAINTAINING.md](./MAINTAINING.md)。
77
81
 
78
82
  ## 来源与许可
79
83
 
package/lib/client.js CHANGED
@@ -1,92 +1,91 @@
1
1
  /**
2
2
  * dsh-webchat — browser half.
3
3
  *
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).
4
+ * One sidebar entry that renders the official chat.deepseek.com web app inside
5
+ * the DSH window, filling the center column like any other full-page view.
7
6
  *
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.
7
+ * Both halves of that are **slots**, so the shell owns them:
15
8
  *
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.
9
+ * - `sidebar.panellist` gets an icon and a label; the sidebar owns the button
10
+ * and selects the main panel whose id matches, which is why this plugin no
11
+ * longer injects a row into someone else's DOM;
12
+ * - `main` (keyed by the same id) gets the page. The layout renders only the
13
+ * selected key, so switching panels is the shell's business and this panel
14
+ * never has to hide another page or ask to be closed.
18
15
  *
19
- * Everything here is plain DOM — no React, no build step.
16
+ * The page itself is the host's native browser guest, not an iframe: the
17
+ * renderer asks `globalThis.dshDesktop.browser` for a reservation and attaches a
18
+ * `<webview>` whose `src` is `about:blank#<lease>` and whose `partition`
19
+ * matches, which the shell's `will-attach-webview` approves and rewrites with
20
+ * the real web preferences. That is the same channel the built-in side-card
21
+ * browser uses, and it is why `frame-ancestors 'none'` does not apply.
22
+ *
23
+ * The guest element deliberately lives outside the React tree, in a container
24
+ * this plugin owns at the document root. A `<webview>` is destroyed when it
25
+ * leaves the document, so a guest owned by the slot would be torn down and
26
+ * reloaded every time the user looked at another panel. Instead the slot hands
27
+ * this plugin a measured area, and the persistent guest is positioned onto it
28
+ * while this panel is the selected one, and hidden when it is not.
29
+ *
30
+ * React comes from the browser module table; everything else is plain DOM.
20
31
  */
21
32
  window.__ModuleLoader__.load({
22
33
  id: "@jaychang1989/dsh-webchat",
23
- factory: () => {
24
- var module = { exports: {} }
25
- var exports = module.exports
26
-
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
-
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'
44
- var STYLE_ID = 'dsh-webchat-style'
45
- var TOAST_ATTR = 'data-dsh-webchat-toast'
46
- var FRAME_CLASS = 'dsh-wc-frame'
47
-
48
- var ZH = typeof navigator === 'object' && typeof navigator.language === 'string'
49
- && navigator.language.toLowerCase().indexOf('zh') === 0
34
+ factory: (require) => {
35
+ const React = require("react")
36
+ const h = React.createElement
37
+
38
+ /** This plugin's panel id: the sidebar row and the main cell share it. */
39
+ const PANEL_ID = "webchat"
40
+ /** The page this plugin exists to show. */
41
+ const PAGE_URL = "https://chat.deepseek.com/"
42
+ /** Storage identity: one guest partition per identity, per app run. */
43
+ const STORAGE_IDENTITY = "dsh-webchat"
44
+ /** Host route used by the window fallback. */
45
+ const OPEN_ROUTE = "/api/dsh-webchat/open"
46
+
47
+ const STYLE_ID = "dsh-webchat-style"
48
+ const OVERLAY_ATTR = "data-dsh-webchat-overlay"
49
+
50
+ /** Locale at read time, so the label thunk follows the app's language. */
51
+ function isChinese() {
52
+ const tag = document.documentElement.getAttribute("lang")
53
+ || (typeof navigator === "object" ? navigator.language : "")
54
+ || ""
55
+ return String(tag).toLowerCase().indexOf("zh") === 0
56
+ }
50
57
 
51
- /** Surface copy; the plugin ships both languages rather than a locale service. */
52
- var T = ZH
53
- ? {
54
- label: 'DeepSeek 网页',
55
- tooltip: '在 Harness 内打开 chat.deepseek.com',
56
- failed: '载入失败',
57
- fallbackOpened: '已打开 DeepSeek 网页',
58
- fallbackHanded: '已交给系统默认浏览器打开',
59
- fallbackFailed: '打开失败',
60
- }
61
- : {
62
- label: 'DeepSeek Web',
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',
68
- }
58
+ /** Every visible string this plugin owns. */
59
+ function copy() {
60
+ return isChinese()
61
+ ? {
62
+ label: "DeepSeek 网页",
63
+ failed: "载入失败",
64
+ fallbackNote: "这个 Harness 没有提供原生浏览器访客(桌面端才有),无法在窗口内显示官方页面。",
65
+ fallbackAction: "在窗口中打开",
66
+ fallbackOpened: "已打开 DeepSeek 网页",
67
+ fallbackHanded: "已交给系统默认浏览器打开",
68
+ fallbackFailed: "打开失败",
69
+ }
70
+ : {
71
+ label: "DeepSeek Web",
72
+ failed: "Could not load it",
73
+ fallbackNote: "This Harness provides no native browser guest (only the desktop app does), so the official page cannot render in the window.",
74
+ fallbackAction: "Open it in a window",
75
+ fallbackOpened: "DeepSeek Web is open",
76
+ fallbackHanded: "Handed to the system default browser",
77
+ fallbackFailed: "Could not open",
78
+ }
79
+ }
69
80
 
70
- /** Entry, center-column takeover and guest styling. */
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)}',
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}',
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}',
82
- '[' + ENTRY_ATTR + '][disabled]{opacity:.55;cursor:progress}',
83
- '[' + ENTRY_ATTR + '] [data-part=icon]{display:inline-flex;align-items:center;justify-content:center;flex:none}',
84
- '[' + ENTRY_ATTR + '] [data-part=label]{overflow:hidden;text-overflow:ellipsis}',
85
- '[data-dsh-frame][data-sidebar-collapsed] [' + ENTRY_ATTR + ']{justify-content:center;padding:0}',
86
- '[data-dsh-frame][data-sidebar-collapsed] [' + ENTRY_ATTR + '] [data-part=label]{display:none}',
87
- '[' + TOAST_ATTR + ']{position:fixed;right:20px;bottom:20px;z-index:200;max-width:420px;padding:10px 14px;border-radius:10px;background:var(--dsw-alias-bg-elevated);border:1px solid var(--dsw-alias-border-l2);box-shadow:0 8px 24px rgba(0,0,0,.18);font-size:13px;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}',
88
- '[' + TOAST_ATTR + '][data-state=bad]{border-color:var(--dsw-alias-state-danger,#ef4444);color:var(--dsw-alias-state-danger,#ef4444)}',
89
- ].join('\n')
81
+ const CSS = [
82
+ ".dsh-wc-host{width:100%;height:100%;min-width:0;min-height:0;display:flex;align-items:center;justify-content:center}",
83
+ "[" + OVERLAY_ATTR + "]{position:fixed;display:none;z-index:40}",
84
+ "[" + OVERLAY_ATTR + "] webview{-webkit-app-region:no-drag;border:0;flex:auto;width:100%;min-width:0;height:100%;min-height:0;display:flex}",
85
+ ".dsh-wc-message{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)}",
86
+ ".dsh-wc-action{margin-top:14px;padding:6px 14px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-elevated);color:var(--dsw-alias-label-primary);cursor:pointer;font-size:13px;font-family:var(--dsw-font-family)}",
87
+ ".dsh-wc-action[disabled]{opacity:.55;cursor:progress}",
88
+ ].join("\n")
90
89
 
91
90
  /**
92
91
  * The desktop shell's browser bridge, when this renderer is the desktop
@@ -94,344 +93,277 @@ window.__ModuleLoader__.load({
94
93
  * version, so the window fallback takes over.
95
94
  */
96
95
  const carrier = globalThis.dshDesktop
97
- const guests = carrier !== null && typeof carrier === 'object' && carrier.protocolVersion === 1
96
+ const bridge = carrier !== null && typeof carrier === "object" && carrier.protocolVersion === 1
98
97
  ? carrier.browser
99
98
  : undefined
100
99
 
100
+ /**
101
+ * The only state that outlives a panel mount. `container` and `element`
102
+ * stay in the document for the whole app run so the guest is never torn
103
+ * down; the slot only decides where they are shown.
104
+ */
105
+ let guest = null
106
+ /** In-flight acquisition, so two mounts cannot reserve two guests. */
107
+ let acquiring = null
108
+ /** Last acquisition failure, if any. */
109
+ let failure = ""
110
+
111
+ /** Render any thrown value as a message. */
112
+ function messageOf(error) {
113
+ return error instanceof Error ? error.message : String(error)
114
+ }
115
+
101
116
  /** Inject the stylesheet once. */
102
117
  function ensureStyle() {
103
118
  if (document.getElementById(STYLE_ID) !== null) return
104
- var tag = document.createElement('style')
119
+ const tag = document.createElement("style")
105
120
  tag.id = STYLE_ID
106
121
  tag.textContent = CSS
107
122
  document.head.appendChild(tag)
108
123
  }
109
124
 
110
- /** Show one transient message in the corner. */
111
- function toast(text, state) {
112
- var previous = document.querySelector('[' + TOAST_ATTR + ']')
113
- if (previous !== null) previous.remove()
114
- var box = document.createElement('div')
115
- box.setAttribute(TOAST_ATTR, '')
116
- if (state !== undefined) box.setAttribute('data-state', state)
125
+ /** A centred message, optionally with one action button. */
126
+ function messageBox(text, action) {
127
+ const box = document.createElement("div")
128
+ box.className = "dsh-wc-message"
117
129
  box.textContent = text
118
- document.body.appendChild(box)
119
- setTimeout(function () {
120
- if (box.isConnected) box.remove()
121
- }, state === 'bad' ? 8000 : 3000)
122
- }
123
-
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' } })
127
- let payload = {}
128
- try { payload = await response.json() } catch (error) { payload = {} }
129
- if (!response.ok && payload.error === undefined) payload.error = 'HTTP ' + response.status
130
- return payload
131
- }
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) {
130
+ if (action !== undefined) {
131
+ const button = document.createElement("button")
132
+ button.type = "button"
133
+ button.className = "dsh-wc-action"
134
+ button.textContent = action.label
135
+ button.addEventListener("click", async function () {
136
+ if (button.disabled) return
137
+ button.disabled = true
139
138
  try {
140
- listener()
141
- } catch (error) {
142
- console.warn('[dsh-webchat] listener failed:', error)
139
+ await action.run(button)
140
+ } finally {
141
+ button.disabled = false
143
142
  }
144
143
  })
144
+ box.appendChild(button)
145
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
-
157
- /** The sidebar shell root, or undefined while it is not mounted. */
158
- function sidebarRoot() {
159
- var column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
160
- if (column === null) return undefined
161
- var logoRow = column.querySelector('[class*="logoRow"]')
162
- var owner = logoRow === null ? null : logoRow.parentElement
163
- return owner !== null && owner !== undefined ? owner : (column.firstElementChild || undefined)
146
+ return box
164
147
  }
165
148
 
166
- /** The New Session button the entry is ordered against. */
167
- function newSessionButton(root) {
168
- var nested = root.querySelector('button[class*="newSession"]')
169
- if (nested !== null) return nested
170
- for (var i = 0; i < root.children.length; i++) {
171
- if (root.children[i].tagName === 'BUTTON') return root.children[i]
149
+ /** Ask the host half to open the page in a window. */
150
+ async function openInWindow(button) {
151
+ const text = copy()
152
+ const note = document.createElement("div")
153
+ note.className = "dsh-wc-message"
154
+ const settle = (message) => {
155
+ note.textContent = message
156
+ const parent = button.parentElement
157
+ if (note.parentElement === null && parent !== null) parent.appendChild(note)
172
158
  }
173
- return undefined
174
- }
175
-
176
- /** Build the entry row. */
177
- function createEntry(onClick) {
178
- var entry = document.createElement('button')
179
- entry.type = 'button'
180
- entry.setAttribute(ENTRY_ATTR, '')
181
- entry.setAttribute('data-dsh-plugin', 'webchat')
182
- entry.setAttribute('data-dsh-part', 'sidebar-entry')
183
- entry.setAttribute('aria-label', T.label)
184
- entry.setAttribute('title', T.tooltip)
185
- var icon = document.createElement('span')
186
- icon.setAttribute('data-part', 'icon')
187
- icon.innerHTML = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 2.5a5.5 5.5 0 0 0-4.7 8.3L2.5 13.5l2.8-.8A5.5 5.5 0 1 0 8 2.5z"/><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none"/></svg>'
188
- var label = document.createElement('span')
189
- label.setAttribute('data-part', 'label')
190
- label.textContent = T.label
191
- entry.appendChild(icon)
192
- entry.appendChild(label)
193
- entry.addEventListener('click', onClick)
194
- return entry
195
- }
196
-
197
- /** Re-insert the entry after the New Session row when React displaces it. */
198
- function placeEntry(root, entry) {
199
- var button = newSessionButton(root)
200
- if (button === undefined) return false
201
- if (entry.parentElement === root) return true
202
- var row = button.closest('[class*="logoRow"]')
203
- var base = row !== null && row.parentElement === root ? row : button
204
- var family = Array.prototype.filter.call(root.children, function (el) {
205
- return el.matches && el.matches('[' + ENTRY_ATTR + '], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]')
206
- })
207
- var anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
208
- root.insertBefore(entry, anchor)
209
- return true
210
- }
211
-
212
- /** Mount the sidebar entry with its self-healing observers. */
213
- function mountSidebarEntry(controller, entry) {
214
- var root
215
- var placed = false
216
-
217
- var tryPlace = function () {
218
- if (root !== undefined && !root.isConnected) {
219
- rootObserver.disconnect()
220
- root = undefined
221
- placed = false
222
- }
223
- if (placed) {
224
- if (document.body.contains(entry)) return
225
- rootObserver.disconnect()
226
- root = undefined
227
- placed = false
228
- }
229
- if (root === undefined) root = sidebarRoot()
230
- if (root === undefined) return
231
- placed = placeEntry(root, entry)
232
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
233
- }
234
-
235
- var waitObserver = new MutationObserver(function () { tryPlace() })
236
- waitObserver.observe(document.body, { childList: true, subtree: true })
237
- var rootObserver = new MutationObserver(function () {
238
- if (root === undefined || !root.isConnected) {
239
- placed = false
240
- tryPlace()
241
- return
159
+ try {
160
+ const response = await fetch(OPEN_ROUTE, { method: "POST", headers: { accept: "application/json" } })
161
+ let payload = {}
162
+ try { payload = await response.json() } catch (error) { payload = {} }
163
+ if (payload.ok === true) {
164
+ settle(String(payload.via) === "system-browser" ? text.fallbackHanded : text.fallbackOpened)
165
+ } else {
166
+ settle(text.fallbackFailed + ":" + String(payload.error || payload.via || "HTTP " + response.status))
242
167
  }
243
- if (!root.contains(entry)) placed = placeEntry(root, entry)
244
- })
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()
252
- tryPlace()
253
-
254
- return function () {
255
- waitObserver.disconnect()
256
- rootObserver.disconnect()
257
- unsubscribe()
258
- entry.remove()
168
+ } catch (error) {
169
+ settle(text.fallbackFailed + ":" + messageOf(error))
259
170
  }
260
171
  }
261
172
 
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
173
  /**
268
- * Mount the panel that carries the guest.
174
+ * A plain Chrome user agent for the guest, derived from this renderer's
175
+ * own so the Chrome major version stays truthful.
269
176
  *
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.
177
+ * chat.deepseek.com greets an `electron` token in the user agent with a
178
+ * "使用环境异常 / Abnormal usage environment" dialog, and dismissing it is
179
+ * stored per guest partition — process-lifetime here, so it would come
180
+ * back on every restart. Its check is literally
181
+ * `navigator.userAgent.toLowerCase().includes("electron")`, so not
182
+ * advertising Electron is what keeps the page clean.
183
+ * @returns a Chrome user agent, or '' when this renderer reports none.
275
184
  */
276
- function mountPanel(controller) {
277
- var container
278
- var frame
279
- var lease
280
- var failed = false
185
+ function cleanUserAgent() {
186
+ const ua = typeof navigator === "object" && typeof navigator.userAgent === "string" ? navigator.userAgent : ""
187
+ const platform = (ua.match(/^Mozilla\/5\.0 \([^)]*\)/) || [])[0]
188
+ const chrome = (ua.match(/Chrome\/([0-9]+)/) || [])[1]
189
+ if (platform === undefined || chrome === undefined) return ""
190
+ return platform + " AppleWebKit/537.36 (KHTML, like Gecko) Chrome/" + chrome + ".0.0.0 Safari/537.36"
191
+ }
281
192
 
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
- }
193
+ /**
194
+ * The document-root container that keeps the guest alive.
195
+ */
196
+ function overlayContainer() {
197
+ const existing = document.querySelector("[" + OVERLAY_ATTR + "]")
198
+ if (existing !== null && existing !== undefined) return existing
199
+ const box = document.createElement("div")
200
+ box.setAttribute(OVERLAY_ATTR, "")
201
+ document.body.appendChild(box)
202
+ return box
203
+ }
288
204
 
289
- /** Reserve a guest and attach its webview. */
290
- var ensureGuest = async function () {
291
- if (frame !== undefined || failed) return
205
+ /**
206
+ * Reserve a guest and attach its webview, once per app run.
207
+ * @returns a promise resolving once the attempt settled.
208
+ */
209
+ function ensureGuest() {
210
+ if (guest !== null || bridge === undefined) return Promise.resolve()
211
+ if (acquiring !== null) return acquiring
212
+ acquiring = (async () => {
292
213
  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')
214
+ const reservation = await bridge.acquire(STORAGE_IDENTITY)
215
+ if (reservation === null || typeof reservation !== "object"
216
+ || typeof reservation.lease !== "string" || typeof reservation.partition !== "string") {
217
+ throw new Error("the desktop browser bridge returned no reservation")
297
218
  }
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 () {
219
+ const element = document.createElement("webview")
220
+ element.setAttribute("name", reservation.lease)
221
+ element.setAttribute("partition", reservation.partition)
222
+ element.setAttribute("allowpopups", "")
223
+ element.setAttribute("src", "about:blank#" + reservation.lease)
224
+ const userAgent = cleanUserAgent()
225
+ if (userAgent !== "") element.setAttribute("useragent", userAgent)
226
+ element.addEventListener("dom-ready", function () {
227
+ // The guest exists by now and nothing has been navigated yet,
228
+ // so this is the user agent the page actually loads with.
229
+ if (userAgent !== "") {
230
+ try {
231
+ element.setUserAgent(userAgent)
232
+ } catch (error) {
233
+ console.warn("[dsh-webchat] could not set the guest user agent:", error)
234
+ }
235
+ }
306
236
  element.loadURL(PAGE_URL).catch(function (error) {
307
- toast(T.failed + ':' + (error instanceof Error ? error.message : String(error)), 'bad')
237
+ failure = messageOf(error)
308
238
  })
309
239
  }, { once: true })
310
- frame = element
240
+ const container = overlayContainer()
311
241
  container.appendChild(element)
242
+ guest = { lease: reservation.lease, element, container }
243
+ failure = ""
312
244
  } catch (error) {
313
- failed = true
314
- showMessage(T.failed + ':' + (error instanceof Error ? error.message : String(error)))
245
+ failure = messageOf(error)
246
+ } finally {
247
+ acquiring = null
315
248
  }
316
- }
249
+ })()
250
+ return acquiring
251
+ }
317
252
 
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)
253
+ /** Release the guest, for plugin disposal. */
254
+ function disposeGuest() {
255
+ if (guest === null) return
256
+ const releasing = guest
257
+ guest = null
258
+ releasing.container.remove()
259
+ if (bridge !== undefined) {
260
+ try { void bridge.release(releasing.lease) } catch (error) { /* already gone */ }
331
261
  }
262
+ }
332
263
 
333
- var waitObserver = new MutationObserver(function () { ensure() })
264
+ /** Position the persistent guest over the area the slot gave this panel. */
265
+ function syncBounds(host) {
266
+ if (guest === null) return
267
+ const rect = host.getBoundingClientRect()
268
+ const style = guest.container.style
269
+ style.left = rect.left + "px"
270
+ style.top = rect.top + "px"
271
+ style.width = rect.width + "px"
272
+ style.height = rect.height + "px"
273
+ }
334
274
 
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)
275
+ /** The center-column panel the layout mounts while this id is selected. */
276
+ function WebchatPanel() {
277
+ const hostRef = React.useRef(null)
278
+
279
+ React.useEffect(function () {
280
+ const host = hostRef.current
281
+ if (host === null || host === undefined) return undefined
282
+ let alive = true
283
+ let observer
284
+ let reposition = null
285
+
286
+ const showFallback = function (text) {
287
+ host.replaceChildren()
288
+ host.appendChild(messageBox(text, {
289
+ label: copy().fallbackAction,
290
+ run: (button) => openInWindow(button),
291
+ }))
347
292
  }
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
293
 
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 */ }
294
+ if (bridge === undefined) {
295
+ showFallback(copy().fallbackNote)
296
+ return undefined
391
297
  }
392
- }
393
- }
394
-
395
- /** Mount the entry, plus the panel when the desktop bridge is present. */
396
- function apply(ctx) {
397
- ensureStyle()
398
- var controller = createController()
399
- var disposers = []
400
298
 
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')
299
+ void ensureGuest().then(function () {
300
+ if (!alive) return
301
+ if (guest === null) {
302
+ showFallback(copy().failed + ":" + (failure || "unknown"))
303
+ return
304
+ }
305
+ host.replaceChildren()
306
+ syncBounds(host)
307
+ guest.container.style.display = "block"
308
+ reposition = function () {
309
+ if (alive) syncBounds(host)
420
310
  }
421
- fallbackEntry.disabled = false
311
+ if (typeof ResizeObserver === "function") {
312
+ observer = new ResizeObserver(reposition)
313
+ observer.observe(host)
314
+ }
315
+ window.addEventListener("resize", reposition)
422
316
  })
423
- disposers.push(mountSidebarEntry(controller, fallbackEntry))
424
- }
425
317
 
426
- ctx.effect(function () {
427
318
  return function () {
428
- disposers.splice(0).forEach(function (dispose) { dispose() })
319
+ alive = false
320
+ if (observer !== undefined) observer.disconnect()
321
+ if (reposition !== null) window.removeEventListener("resize", reposition)
322
+ // Hidden, never detached: the guest keeps its page and its login.
323
+ if (guest !== null) guest.container.style.display = "none"
429
324
  }
430
- }, 'dsh-webchat: ui mounts')
325
+ }, [])
326
+
327
+ return h("div", { ref: hostRef, className: "dsh-wc-host" })
328
+ }
329
+
330
+ /** The sidebar row's icon. The sidebar owns the button around it. */
331
+ function WebchatIcon(props) {
332
+ const size = props !== null && props !== undefined && typeof props.size === "number" ? props.size : 16
333
+ return h("svg", {
334
+ viewBox: "0 0 16 16",
335
+ width: size,
336
+ height: size,
337
+ fill: "none",
338
+ stroke: "currentColor",
339
+ strokeWidth: 1.3,
340
+ strokeLinecap: "round",
341
+ strokeLinejoin: "round",
342
+ "aria-hidden": true,
343
+ },
344
+ h("path", { d: "M8 2.5a5.5 5.5 0 0 0-4.7 8.3L2.5 13.5l2.8-.8A5.5 5.5 0 1 0 8 2.5z" }),
345
+ h("circle", { cx: 8, cy: 8, r: 1.1, fill: "currentColor", stroke: "none" }),
346
+ )
347
+ }
348
+
349
+ /** Register the nav row and the page, then own the guest's lifetime. */
350
+ function apply(ctx) {
351
+ ensureStyle()
352
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
353
+ name: "sidebar.panellist",
354
+ id: PANEL_ID,
355
+ order: -1,
356
+ label: () => copy().label,
357
+ }, WebchatIcon))
358
+ ctx.slots.inject("main", () => ctx.slots.register({
359
+ name: "main",
360
+ key: PANEL_ID,
361
+ }, WebchatPanel))
362
+ ctx.effect(() => () => {
363
+ disposeGuest()
364
+ }, "dsh-webchat: native guest")
431
365
  }
432
366
 
433
- exports.apply = apply
434
- exports.inject = []
435
- return module.exports
367
+ return { inject: ["slots"], apply }
436
368
  },
437
369
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaychang1989/dsh-webchat",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
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",
@@ -48,7 +48,11 @@
48
48
  "patch": "./cordis.patch.yml"
49
49
  },
50
50
  "client": {
51
- "platform": "web"
51
+ "platform": "web",
52
+ "inject": [
53
+ "@deepseek-ai/dsh-client-ui-layout",
54
+ "@deepseek-ai/dsh-client-ui-sidebar"
55
+ ]
52
56
  }
53
57
  },
54
58
  "files": [