@jaychang1989/dsh-webchat 0.5.2 → 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,9 +62,10 @@ 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 |
67
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 |
68
71
 
@@ -72,7 +75,7 @@ Click again to collapse the panel. The guest stays mounted while the panel is cl
72
75
  node --test
73
76
  ```
74
77
 
75
- 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.
76
79
 
77
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).
78
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,9 +62,10 @@ 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
  | 重启后要求重新登录 | 见上面的「已知限制」,属于宿主分区机制 |
67
70
  | 页面弹出「使用环境异常」 | DeepSeek 前端会检查 `navigator.userAgent` 里是否含 `electron`(桌面端默认 UA 就含),命中就提示"建议使用官方产品"。0.5.2 起访客改用普通 Chrome UA,不再触发 |
68
71
 
@@ -72,9 +75,9 @@ dsh plugin --profile desktop add github:jaychang1989/dsh-webchat
72
75
  node --test
73
76
  ```
74
77
 
75
- 14 个用例,其中浏览器半区在 DOM 桩里**真实执行**:桌面端环境下验证入口挂载、点击后申请租约、按宿主约定生成 `about:blank#<lease>` 的 webview、`dom-ready` 后导航到目标地址、关闭重开复用同一个访客;无桥接环境下验证降级为请求宿主开窗并如实提示结果。
78
+ 19 个用例。宿主半区用假 context 与假 request/response 驱动;浏览器半区用一层薄的 React 测试替身 + DOM 桩**真实执行**:验证它按槽位契约注册导航行与页面(同一个 id)、标签随语言变化、图标遵循 shell 要求的尺寸,以及访客生命周期——申请租约、按宿主约定生成 `about:blank#<lease>` 的 webview、`dom-ready` 后先改 UA 再导航、**卸载只隐藏不卸载访客、重挂复用同一个**、插件卸载时释放租约;无桥接环境下验证降级为请求宿主开窗并如实反馈。
76
79
 
77
- 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码,包内**零运行时依赖**。访客机制的来龙去脉见 [MAINTAINING.md](./MAINTAINING.md)。
80
+ 没有构建步骤——`lib/index.js` 与 `lib/client.js` 就是手写的运行时代码(React 取自浏览器的模块表),包内**零运行时依赖**。访客机制与槽位契约见 [MAINTAINING.md](./MAINTAINING.md)。
78
81
 
79
82
  ## 来源与许可
80
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,376 +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
- /**
111
- * A plain Chrome user agent for the guest, derived from this renderer's
112
- * own so the Chrome major version stays truthful.
113
- *
114
- * chat.deepseek.com greets an `electron` token in the user agent with a
115
- * "使用环境异常 / Abnormal usage environment" dialog, and its dismissal is
116
- * stored per guest partition — which is process-lifetime here, so it
117
- * would come back on every restart. Its check is literally
118
- * `navigator.userAgent.toLowerCase().includes("electron")` (the other
119
- * arm, `window.process.type`, cannot fire in a sandboxed, isolated
120
- * guest), so not advertising Electron is what keeps the page clean.
121
- * @returns a Chrome user agent, or '' when this renderer reports none.
122
- */
123
- function cleanUserAgent() {
124
- var ua = typeof navigator === 'object' && typeof navigator.userAgent === 'string' ? navigator.userAgent : ''
125
- var platform = (ua.match(/^Mozilla\/5\.0 \([^)]*\)/) || [])[0]
126
- var chrome = (ua.match(/Chrome\/([0-9]+)/) || [])[1]
127
- if (platform === undefined || chrome === undefined) return ''
128
- return platform + ' AppleWebKit/537.36 (KHTML, like Gecko) Chrome/' + chrome + '.0.0.0 Safari/537.36'
129
- }
130
-
131
- /** Show one transient message in the corner. */
132
- function toast(text, state) {
133
- var previous = document.querySelector('[' + TOAST_ATTR + ']')
134
- if (previous !== null) previous.remove()
135
- var box = document.createElement('div')
136
- box.setAttribute(TOAST_ATTR, '')
137
- 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"
138
129
  box.textContent = text
139
- document.body.appendChild(box)
140
- setTimeout(function () {
141
- if (box.isConnected) box.remove()
142
- }, state === 'bad' ? 8000 : 3000)
143
- }
144
-
145
- /** Ask the host half to open the page in a window (the non-desktop path). */
146
- async function openInWindow() {
147
- const response = await fetch(OPEN_ROUTE, { method: 'POST', headers: { accept: 'application/json' } })
148
- let payload = {}
149
- try { payload = await response.json() } catch (error) { payload = {} }
150
- if (!response.ok && payload.error === undefined) payload.error = 'HTTP ' + response.status
151
- return payload
152
- }
153
-
154
- /** Tiny observable for the panel's open state. */
155
- function createController() {
156
- var open = false
157
- var listeners = new Set()
158
- var notify = function () {
159
- 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
160
138
  try {
161
- listener()
162
- } catch (error) {
163
- console.warn('[dsh-webchat] listener failed:', error)
139
+ await action.run(button)
140
+ } finally {
141
+ button.disabled = false
164
142
  }
165
143
  })
144
+ box.appendChild(button)
166
145
  }
167
- return {
168
- isOpen: function () { return open },
169
- subscribe: function (listener) {
170
- listeners.add(listener)
171
- return function () { listeners.delete(listener) }
172
- },
173
- toggle: function () { open = !open; notify() },
174
- close: function () { if (!open) return; open = false; notify() },
175
- }
176
- }
177
-
178
- /** The sidebar shell root, or undefined while it is not mounted. */
179
- function sidebarRoot() {
180
- var column = document.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
181
- if (column === null) return undefined
182
- var logoRow = column.querySelector('[class*="logoRow"]')
183
- var owner = logoRow === null ? null : logoRow.parentElement
184
- return owner !== null && owner !== undefined ? owner : (column.firstElementChild || undefined)
146
+ return box
185
147
  }
186
148
 
187
- /** The New Session button the entry is ordered against. */
188
- function newSessionButton(root) {
189
- var nested = root.querySelector('button[class*="newSession"]')
190
- if (nested !== null) return nested
191
- for (var i = 0; i < root.children.length; i++) {
192
- 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)
193
158
  }
194
- return undefined
195
- }
196
-
197
- /** Build the entry row. */
198
- function createEntry(onClick) {
199
- var entry = document.createElement('button')
200
- entry.type = 'button'
201
- entry.setAttribute(ENTRY_ATTR, '')
202
- entry.setAttribute('data-dsh-plugin', 'webchat')
203
- entry.setAttribute('data-dsh-part', 'sidebar-entry')
204
- entry.setAttribute('aria-label', T.label)
205
- entry.setAttribute('title', T.tooltip)
206
- var icon = document.createElement('span')
207
- icon.setAttribute('data-part', 'icon')
208
- 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>'
209
- var label = document.createElement('span')
210
- label.setAttribute('data-part', 'label')
211
- label.textContent = T.label
212
- entry.appendChild(icon)
213
- entry.appendChild(label)
214
- entry.addEventListener('click', onClick)
215
- return entry
216
- }
217
-
218
- /** Re-insert the entry after the New Session row when React displaces it. */
219
- function placeEntry(root, entry) {
220
- var button = newSessionButton(root)
221
- if (button === undefined) return false
222
- if (entry.parentElement === root) return true
223
- var row = button.closest('[class*="logoRow"]')
224
- var base = row !== null && row.parentElement === root ? row : button
225
- var family = Array.prototype.filter.call(root.children, function (el) {
226
- return el.matches && el.matches('[' + ENTRY_ATTR + '], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]')
227
- })
228
- var anchor = family.length > 0 ? family[family.length - 1].nextElementSibling : base.nextElementSibling
229
- root.insertBefore(entry, anchor)
230
- return true
231
- }
232
-
233
- /** Mount the sidebar entry with its self-healing observers. */
234
- function mountSidebarEntry(controller, entry) {
235
- var root
236
- var placed = false
237
-
238
- var tryPlace = function () {
239
- if (root !== undefined && !root.isConnected) {
240
- rootObserver.disconnect()
241
- root = undefined
242
- placed = false
243
- }
244
- if (placed) {
245
- if (document.body.contains(entry)) return
246
- rootObserver.disconnect()
247
- root = undefined
248
- placed = false
249
- }
250
- if (root === undefined) root = sidebarRoot()
251
- if (root === undefined) return
252
- placed = placeEntry(root, entry)
253
- if (placed) rootObserver.observe(root, { childList: true, subtree: true })
254
- }
255
-
256
- var waitObserver = new MutationObserver(function () { tryPlace() })
257
- waitObserver.observe(document.body, { childList: true, subtree: true })
258
- var rootObserver = new MutationObserver(function () {
259
- if (root === undefined || !root.isConnected) {
260
- placed = false
261
- tryPlace()
262
- 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))
263
167
  }
264
- if (!root.contains(entry)) placed = placeEntry(root, entry)
265
- })
266
-
267
- var syncActive = function () {
268
- if (controller.isOpen()) entry.dataset.active = 'true'
269
- else delete entry.dataset.active
270
- }
271
- var unsubscribe = controller.subscribe(syncActive)
272
- syncActive()
273
- tryPlace()
274
-
275
- return function () {
276
- waitObserver.disconnect()
277
- rootObserver.disconnect()
278
- unsubscribe()
279
- entry.remove()
168
+ } catch (error) {
169
+ settle(text.fallbackFailed + ":" + messageOf(error))
280
170
  }
281
171
  }
282
172
 
283
- /** The center column that hosts full-page views. */
284
- function conversationColumn() {
285
- return document.querySelector('[data-pane="conversation"], [class*="centerCol"]') || undefined
286
- }
287
-
288
173
  /**
289
- * 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.
290
176
  *
291
- * The guest is created lazily on first open and then kept mounted while
292
- * the panel is closed, so re-opening does not reload the page and the
293
- * DeepSeek session stays where it was.
294
- * @param controller - panel open state.
295
- * @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.
296
184
  */
297
- function mountPanel(controller) {
298
- var container
299
- var frame
300
- var lease
301
- 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
+ }
302
192
 
303
- var showMessage = function (text) {
304
- var box = document.createElement('div')
305
- box.className = 'dsh-wc-message'
306
- box.textContent = text
307
- container.appendChild(box)
308
- }
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
+ }
309
204
 
310
- /** Reserve a guest and attach its webview. */
311
- var ensureGuest = async function () {
312
- 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 () => {
313
213
  try {
314
- const reservation = await guests.acquire(STORAGE_IDENTITY)
315
- if (reservation === null || typeof reservation !== 'object'
316
- || typeof reservation.lease !== 'string' || typeof reservation.partition !== 'string') {
317
- 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")
318
218
  }
319
- lease = reservation.lease
320
- const element = document.createElement('webview')
321
- element.className = FRAME_CLASS
322
- element.setAttribute('name', reservation.lease)
323
- element.setAttribute('partition', reservation.partition)
324
- element.setAttribute('allowpopups', '')
325
- element.setAttribute('src', 'about:blank#' + reservation.lease)
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)
326
224
  const userAgent = cleanUserAgent()
327
- if (userAgent !== '') element.setAttribute('useragent', userAgent)
328
- element.addEventListener('dom-ready', function () {
329
- // The guest exists by now, and nothing has been navigated
330
- // yet, so this UA is the one the page actually loads with.
331
- if (userAgent !== '') {
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 !== "") {
332
230
  try {
333
231
  element.setUserAgent(userAgent)
334
232
  } catch (error) {
335
- console.warn('[dsh-webchat] could not set the guest user agent:', error)
233
+ console.warn("[dsh-webchat] could not set the guest user agent:", error)
336
234
  }
337
235
  }
338
236
  element.loadURL(PAGE_URL).catch(function (error) {
339
- toast(T.failed + ':' + (error instanceof Error ? error.message : String(error)), 'bad')
237
+ failure = messageOf(error)
340
238
  })
341
239
  }, { once: true })
342
- frame = element
240
+ const container = overlayContainer()
343
241
  container.appendChild(element)
242
+ guest = { lease: reservation.lease, element, container }
243
+ failure = ""
344
244
  } catch (error) {
345
- failed = true
346
- showMessage(T.failed + ':' + (error instanceof Error ? error.message : String(error)))
245
+ failure = messageOf(error)
246
+ } finally {
247
+ acquiring = null
347
248
  }
348
- }
249
+ })()
250
+ return acquiring
251
+ }
349
252
 
350
- var ensure = function () {
351
- if (container !== undefined) {
352
- if (container.isConnected) return
353
- container.remove()
354
- container = undefined
355
- frame = undefined
356
- }
357
- var column = conversationColumn()
358
- if (column === undefined) return
359
- container = document.createElement('div')
360
- container.setAttribute(VIEW_ATTR, '')
361
- container.setAttribute('data-dsh-plugin', 'webchat')
362
- 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 */ }
363
261
  }
262
+ }
364
263
 
365
- 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
+ }
366
274
 
367
- var applyActive = function () {
368
- if (controller.isOpen()) {
369
- OTHER_ACTIVE_ATTRS.forEach(function (attr) { document.documentElement.removeAttribute(attr) })
370
- document.documentElement.setAttribute(ACTIVE_ATTR, '')
371
- // Mount before announcing: the arbitration event dispatches into
372
- // other plugins, and a throwing listener must not be able to leave
373
- // this panel half-open.
374
- ensure()
375
- void ensureGuest()
376
- document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
377
- } else {
378
- 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
+ }))
379
292
  }
380
- }
381
- var onOtherActivate = function (event) {
382
- var detail = event.detail
383
- if ((detail === 'taskboard' || detail === 'ssh') && controller.isOpen()) controller.close()
384
- }
385
- /**
386
- * The shell's own panel list owns the center column. Its rows —
387
- * Plugins, Automation Tasks, the task board, sessions, workspaces —
388
- * do not take part in the third-party `data-*-active` handshake, so
389
- * this panel has to yield on any navigation from the sidebar, or its
390
- * overlay would keep hiding the page the shell just selected.
391
- */
392
- var SIDEBAR_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"]'
393
- var onClickSidebarRow = function (event) {
394
- if (!controller.isOpen()) return
395
- var target = event.target
396
- if (target === null || target.closest === undefined) return
397
- // This plugin's own row toggles the panel; it is not navigation.
398
- if (target.closest('[' + ENTRY_ATTR + ']') !== null) return
399
- if (target.closest(SIDEBAR_SELECTOR) !== null) controller.close()
400
- }
401
-
402
- document.addEventListener('click', onClickSidebarRow, true)
403
- document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
404
- var unsubscribe = controller.subscribe(applyActive)
405
- applyActive()
406
- ensure()
407
- waitObserver.observe(document.body, { childList: true, subtree: true })
408
293
 
409
- return function () {
410
- document.removeEventListener('click', onClickSidebarRow, true)
411
- document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
412
- waitObserver.disconnect()
413
- unsubscribe()
414
- document.documentElement.removeAttribute(ACTIVE_ATTR)
415
- if (frame !== undefined) frame.remove()
416
- if (container !== undefined) container.remove()
417
- frame = undefined
418
- container = undefined
419
- if (lease !== undefined) {
420
- const releasing = lease
421
- lease = undefined
422
- try { void guests.release(releasing) } catch (error) { /* already gone */ }
294
+ if (bridge === undefined) {
295
+ showFallback(copy().fallbackNote)
296
+ return undefined
423
297
  }
424
- }
425
- }
426
-
427
- /** Mount the entry, plus the panel when the desktop bridge is present. */
428
- function apply(ctx) {
429
- ensureStyle()
430
- var controller = createController()
431
- var disposers = []
432
298
 
433
- if (guests !== undefined) {
434
- // Desktop: the entry toggles the in-app panel.
435
- var entry = createEntry(function () { controller.toggle() })
436
- disposers.push(mountSidebarEntry(controller, entry))
437
- disposers.push(mountPanel(controller))
438
- } else {
439
- // Web: no native guest to lend, so the entry asks the host for a window.
440
- var fallbackEntry = createEntry(async function () {
441
- if (fallbackEntry.disabled) return
442
- fallbackEntry.disabled = true
443
- try {
444
- var outcome = await openInWindow()
445
- if (outcome.ok === true) {
446
- toast(String(outcome.via) === 'system-browser' ? T.fallbackHanded : T.fallbackOpened)
447
- } else {
448
- toast(T.fallbackFailed + ':' + String(outcome.error || outcome.via || ''), 'bad')
449
- }
450
- } catch (error) {
451
- 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)
310
+ }
311
+ if (typeof ResizeObserver === "function") {
312
+ observer = new ResizeObserver(reposition)
313
+ observer.observe(host)
452
314
  }
453
- fallbackEntry.disabled = false
315
+ window.addEventListener("resize", reposition)
454
316
  })
455
- disposers.push(mountSidebarEntry(controller, fallbackEntry))
456
- }
457
317
 
458
- ctx.effect(function () {
459
318
  return function () {
460
- 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"
461
324
  }
462
- }, '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")
463
365
  }
464
366
 
465
- exports.apply = apply
466
- exports.inject = []
467
- return module.exports
367
+ return { inject: ["slots"], apply }
468
368
  },
469
369
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jaychang1989/dsh-webchat",
3
- "version": "0.5.2",
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": [