@tianyulinfeng1/dsh-plugin-deepseek-chat 1.1.2 → 1.1.6

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/lib/client.js CHANGED
@@ -1,41 +1,69 @@
1
1
  /**
2
- * Browser half of dsh-plugin-deepseek-chat (v1.1.1).
2
+ * Browser half of dsh-plugin-deepseek-chat (v1.1.6).
3
3
  *
4
- * Registers one global left-sidebar panel — the same mechanism the Plugins and
5
- * Scheduled tasks entries use:
6
- * `sidebar.panellist` (list) contributes the icon row, and the root `main`
7
- * keyed slot with the same key renders the panel page.
4
+ * Three contributions, all showing chat.deepseek.com in an Electron <webview>:
5
+ * - the global left-sidebar panel (a `sidebar.panellist` icon row + the root
6
+ * `main` keyed slot), the same mechanism Plugins / Scheduled tasks use;
7
+ * - a conversation top-bar tab registered in the `conversation.view` list
8
+ * slot, so the web chat is reachable from the 对话 / 轨迹 / 上下文 … tab row
9
+ * without leaving the current Session (this one can be switched off);
10
+ * - a Settings page (`settings.section`) with two preferences: whether the
11
+ * top-bar tab is shown, and the tab's display name (default "DeepSeek 聊天",
12
+ * user-customizable).
8
13
  *
9
- * The page hosts chat.deepseek.com in an Electron `<webview>`. The site sends
10
- * `content-security-policy: frame-ancestors 'none'`, so an iframe cannot carry
11
- * it; the desktop shell exposes `globalThis.dshDesktop.browser`, the same guest
12
- * lease bridge the built-in sidebar browser uses: acquire a lease → create a
13
- * webview named by the lease with its approved partition → load the URL.
14
- *
15
- * Stable login session (v1.1.1)
14
+ * Filling the conversation area
16
15
  * -----------------------------
16
+ * The conversation renderer places each view in `viewArea`, a sibling of the
17
+ * sticky `composerSeat`. In the `active` phase `viewArea` becomes
18
+ * `flex:1 0 auto`, so `height:100%` on the panel has no definite parent height
19
+ * and the Session's own messages/composer stay visible underneath. The tab is
20
+ * therefore wrapped in an absolutely positioned overlay: its containing block
21
+ * is the conversation body (`position:relative`), `inset:0` covers exactly
22
+ * that body, an opaque background plus a z-index above the composer's 9 hide
23
+ * the Session UI below, and the header with the tab row sits above the body so
24
+ * switching back to 对话 remains possible.
25
+ *
26
+ * The site sends `content-security-policy: frame-ancestors 'none'`, so an
27
+ * iframe cannot carry it; the desktop shell exposes
28
+ * `globalThis.dshDesktop.browser`, the same guest lease bridge the built-in
29
+ * sidebar browser uses: acquire a lease → create a webview named by the lease
30
+ * with its approved partition → load the URL.
31
+ *
32
+ * Stable login session
33
+ * --------------------
17
34
  * The desktop shell caches ONE process-lifetime session per `workspace` string
18
35
  * passed to acquire(), and release() destroys only the guest, never the
19
36
  * partition's cookies. So the workspace MUST be an absolute constant:
20
- * `panel:deepseek-chat`. v1.1.0 keyed it to an async account probe, which
21
- * transiently fails during reconnects — every change handed the panel a fresh
22
- * cookie jar and forced another QR scan. That regression is fixed here.
37
+ * `panel:deepseek-chat`. Changing it hands the panel a fresh cookie jar and
38
+ * forces another QR scan.
23
39
  *
24
- * Account awareness (display only)
25
- * --------------------------------
26
- * DSH's own sign-in is a platform.deepseek.com OAuth grant; the chat site uses
27
- * a different, site-owned Token. There is no official exchange, and the
28
- * renderer bridge exposes no token/cookie API, so a pure plugin cannot reuse
29
- * the DSH token for the web chat (see docs/login-persistence.md §6). All this
30
- * file does with the account is DISPLAY it:
40
+ * Settings persistence (v1.1.4)
41
+ * -----------------------------
42
+ * Settings live in window.localStorage under CONFIG_KEY, separate from the
43
+ * last-URL memory. The Settings page writes through writeConfig(), which
44
+ * notifies module-level subscribers. apply() uses that notification to dispose
45
+ * and re-inject the `conversation.view` entry: the tab row only recomputes its
46
+ * labels when the slot registry changes, so a label thunk alone cannot reflect
47
+ * a renamed tab. Re-registering also drives the showTab on/off switch.
31
48
  *
32
- * - a soft probe reads the Client `remote.account` namespace
33
- * (`getState`/`getProfile`, generated `{ ok, value }` envelopes) and shows
34
- * the state/name in a small strip;
35
- * - the account state NEVER feeds the lease identity, the storage key, an
36
- * epoch, or the guest lifecycle — no rebuild, no partition change, ever;
37
- * - a transient `unknown` is ignored; only a confirmed `signed-out` updates
38
- * the strip; a missing/late Remote never blocks the guest from starting.
49
+ * About & update check (v1.1.5)
50
+ * ----------------------------
51
+ * The Settings page also shows the plugin's metadata (version, author, license,
52
+ * a short description and links to npm/GitHub) and a "检查更新" button. The
53
+ * latest published version is read directly from the two public JSON endpoints
54
+ * (npm registry first, GitHub raw package.json as a fallback) using the
55
+ * renderer's fetch — the main window sets no connect-src CSP restriction and
56
+ * both hosts answer with permissive CORS. No Host-side route is added, so the
57
+ * empty host half stays untouched. compareVersion() does a numeric semver
58
+ * comparison rather than string equality, because a local unreleased build
59
+ * (e.g. 1.1.6) can already be ahead of the published "latest" (1.1.2).
60
+ *
61
+ * No account awareness (v1.1.3)
62
+ * -----------------------------
63
+ * Earlier versions probed the DSH account and rendered a status strip. That
64
+ * strip and the whole account sync store (polling / probes / listeners) are
65
+ * removed; the chat uses its own site-owned login, and no DSH account state is
66
+ * read, displayed, or fed into the guest lifecycle.
39
67
  *
40
68
  * Failure containment is still the hard rule of this file. DSH treats any
41
69
  * uncaught error thrown by a plugin as a crash of its slot entry, and a crash
@@ -61,19 +89,34 @@ window.__ModuleLoader__.load({
61
89
  const react = require("react");
62
90
 
63
91
  const PANEL_ID = "deepseek-chat";
92
+ /** Cell key of the conversation top-bar tab (conversation.view list). */
93
+ const VIEW_ID = "deepseek-chat-view";
94
+ /** Position after the shipped views (chat 0, trajectory 10, stats …). */
95
+ const VIEW_ORDER = 100;
96
+ /** Cell key / position of the Settings page (settings.section list). */
97
+ const SETTINGS_ID = "deepseek-chat-settings";
98
+ const SETTINGS_ORDER = 48;
99
+ const CHAT_URL = "https://chat.deepseek.com/";
100
+
64
101
  /**
65
- * DSH runtime version sent as `x-client-version` on Platform account
66
- * reads. The boot manifest carries the exact connected release; the
67
- * minimum supported release is the fallback for a page without it.
102
+ * Plugin metadata shown in Settings. Kept as plain constants (the client
103
+ * bundle cannot import package.json) and asserted against package.json by
104
+ * the offline self-test — the same pattern the factory id uses.
68
105
  */
69
- const HOST_VERSION_FALLBACK = "0.2.2-rc.1";
70
- const CHAT_URL = "https://chat.deepseek.com/";
106
+ const PLUGIN_NAME = "@tianyulinfeng1/dsh-plugin-deepseek-chat";
107
+ const PLUGIN_VERSION = "1.1.6";
108
+ const PLUGIN_AUTHOR = "sweet560";
109
+ const PLUGIN_LICENSE = "MIT";
110
+ const NPM_PAGE_URL = "https://www.npmjs.com/package/@tianyulinfeng1/dsh-plugin-deepseek-chat";
111
+ const GITHUB_PAGE_URL = "https://github.com/sweet560/dsh-plugin-deepseek-chat";
112
+ /** Public JSON endpoints used by "检查更新" (npm first, GitHub fallback). */
113
+ const NPM_VERSION_URL = "https://registry.npmjs.org/-/package/@tianyulinfeng1%2fdsh-plugin-deepseek-chat/dist-tags";
114
+ const GITHUB_VERSION_URL = "https://raw.githubusercontent.com/sweet560/dsh-plugin-deepseek-chat/main/package.json";
71
115
  /**
72
116
  * Constant storage identities for the guest partition and the
73
117
  * last-conversation memory. The main process keeps one partition per
74
- * `workspace` string for the process lifetime; both values MUST NOT
75
- * depend on account state, or a transient probe failure would swap the
76
- * cookie jar and force a fresh QR scan. v1.1.1 regression fix.
118
+ * `workspace` string for the process lifetime; these MUST NOT change,
119
+ * or a fresh cookie jar would force a fresh QR scan.
77
120
  */
78
121
  const PARTITION_BASE = "panel:deepseek-chat";
79
122
  const STORAGE_BASE = "dsh-plugin-deepseek-chat:last-url";
@@ -89,11 +132,33 @@ window.__ModuleLoader__.load({
89
132
  crashed: "The embedded chat stopped responding",
90
133
  timeout: "The embedded chat did not start",
91
134
  retry: "Retry",
92
- probing: "Syncing the DSH account…",
93
- synced: "Synced with the DSH account",
94
- syncedAs: (name) => `Synced with the DSH account · ${name}`,
95
- unsigned: "No DSH account signed in — the chat panel is not synced",
96
- unknown: "Account state unavailable — conversation kept locally"
135
+ settingsNav: "DeepSeek Chat",
136
+ settingsTitle: "DeepSeek Chat settings",
137
+ settingsEnableTab: "Show the conversation top-bar tab",
138
+ settingsEnableHint: "Add a web-chat entry to the 对话 / 轨迹 / 上下文 … tab row. Turn this off to keep only the left-sidebar panel.",
139
+ settingsTabLabel: "Top-bar tab name",
140
+ settingsTabLabelHint: "Default: DeepSeek 聊天. Leave the box empty to restore the default.",
141
+ settingsPlaceholder: "DeepSeek 聊天",
142
+ aboutTitle: "About this plugin",
143
+ infoVersion: "Version",
144
+ infoAuthor: "Author",
145
+ infoLicense: "License",
146
+ infoDescription: "Description",
147
+ infoSummary: "Opens chat.deepseek.com from the sidebar and the conversation tab row: a switchable, renameable top-bar tab, a full-area web chat, and last-conversation memory.",
148
+ infoLinks: "Links",
149
+ linkNpm: "npm package page",
150
+ linkGithub: "GitHub repository",
151
+ updateTitle: "Check for updates",
152
+ checkUpdate: "Check for updates",
153
+ updateIdle: "Click the button to read the latest published version from npm or GitHub.",
154
+ updateChecking: "Checking for updates…",
155
+ updateUpToDate: "You're up to date",
156
+ updateFound: "New version available",
157
+ updateFailed: "Update check failed",
158
+ updateNoFetch: "This environment cannot reach the network (fetch is unavailable).",
159
+ updateSource: "Source",
160
+ updateLatest: "Latest",
161
+ updateCurrent: "Current"
97
162
  } : {
98
163
  label: "DeepSeek 聊天",
99
164
  loading: "正在加载 DeepSeek 聊天…",
@@ -102,29 +167,247 @@ window.__ModuleLoader__.load({
102
167
  crashed: "内嵌聊天进程已退出",
103
168
  timeout: "内嵌聊天页面没有启动",
104
169
  retry: "重试",
105
- probing: "正在同步 DSH 登录账号…",
106
- synced: "已与 DSH 登录账号同步",
107
- syncedAs: (name) => `已与 DSH 登录账号同步 · ${name}`,
108
- unsigned: "未登录 DSH 账号,聊天面板暂未同步",
109
- unknown: "账号状态不可用,会话按本地模式保存"
170
+ settingsNav: "DeepSeek 聊天",
171
+ settingsTitle: "DeepSeek 聊天设置",
172
+ settingsEnableTab: "显示顶部标签栏",
173
+ settingsEnableHint: "在会话顶栏「对话 / 轨迹 / 上下文 …」标签行加入网页聊天入口;关闭后只保留左侧栏面板。",
174
+ settingsTabLabel: "顶部标签栏名称",
175
+ settingsTabLabelHint: "默认:DeepSeek 聊天;留空即恢复默认。",
176
+ settingsPlaceholder: "DeepSeek 聊天",
177
+ aboutTitle: "关于本插件",
178
+ infoVersion: "插件版本号",
179
+ infoAuthor: "作者",
180
+ infoLicense: "开源许可证",
181
+ infoDescription: "功能简介",
182
+ infoSummary: "在左侧栏与会话顶栏直接打开 chat.deepseek.com 网页聊天;顶栏标签可开关、可改名,网页铺满会话区并记住上次会话。",
183
+ infoLinks: "相关链接",
184
+ linkNpm: "npm 插件页面",
185
+ linkGithub: "GitHub 仓库",
186
+ updateTitle: "检查更新",
187
+ checkUpdate: "检查更新",
188
+ updateIdle: "点击按钮,从 npm 或 GitHub 获取最新发布版本号。",
189
+ updateChecking: "正在检查更新…",
190
+ updateUpToDate: "已是最新版本",
191
+ updateFound: "发现新版本",
192
+ updateFailed: "检查更新失败",
193
+ updateNoFetch: "当前环境无法联网(没有可用的 fetch)。",
194
+ updateSource: "来源",
195
+ updateLatest: "最新版本",
196
+ updateCurrent: "当前版本"
110
197
  };
111
198
 
112
- /** Normalized account states the panel can distinguish. */
113
- const ACCOUNT_UNKNOWN = "unknown";
114
- const ACCOUNT_SIGNED_IN = "signed-in";
115
- const ACCOUNT_SIGNED_OUT = "signed-out";
199
+ /** localStorage key for the two user preferences (separate from last-url memory). */
200
+ const CONFIG_KEY = "dsh-plugin-deepseek-chat:config";
201
+ const DEFAULT_TAB_LABEL = "DeepSeek 聊天";
202
+ const DEFAULTS = { showTab: true, tabLabel: DEFAULT_TAB_LABEL };
203
+ const configListeners = new Set();
204
+ let config = readConfig();
205
+
206
+ /** Read preferences, validating each field's type against DEFAULTS. */
207
+ function readConfig() {
208
+ const result = { ...DEFAULTS };
209
+ try {
210
+ const raw = window.localStorage.getItem(CONFIG_KEY);
211
+ if (typeof raw !== "string" || raw.length === 0) return result;
212
+ const parsed = JSON.parse(raw);
213
+ if (parsed === null || typeof parsed !== "object") return result;
214
+ if (typeof parsed.showTab === "boolean") result.showTab = parsed.showTab;
215
+ if (typeof parsed.tabLabel === "string") result.tabLabel = normalizeTabLabel(parsed.tabLabel);
216
+ } catch {
217
+ /* storage unavailable or corrupt: keep defaults */
218
+ }
219
+ return result;
220
+ }
221
+
222
+ /** Trim, bound length, and fall back to the default when empty. */
223
+ function normalizeTabLabel(value) {
224
+ const trimmed = value.trim();
225
+ if (trimmed.length === 0) return DEFAULT_TAB_LABEL;
226
+ return trimmed.slice(0, 40);
227
+ }
228
+
229
+ /** Snapshot of the in-memory preferences (authoritative after load). */
230
+ function getConfig() {
231
+ return { ...config };
232
+ }
233
+
234
+ /** Current tab label (never empty). */
235
+ function tabLabel() {
236
+ return config.tabLabel.length > 0 ? config.tabLabel : DEFAULT_TAB_LABEL;
237
+ }
238
+
239
+ /** Merge a patch, persist it, and notify subscribers (Settings → tab re-injection). */
240
+ function writeConfig(patch) {
241
+ const next = { ...config };
242
+ if (typeof patch.showTab === "boolean") next.showTab = patch.showTab;
243
+ if (typeof patch.tabLabel === "string") next.tabLabel = normalizeTabLabel(patch.tabLabel);
244
+ config = next;
245
+ try {
246
+ window.localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
247
+ } catch {
248
+ /* storage unavailable: the in-memory value still applies this session */
249
+ }
250
+ for (const listener of [...configListeners]) {
251
+ try {
252
+ listener(config);
253
+ } catch {
254
+ /* one bad subscriber must not stop the others */
255
+ }
256
+ }
257
+ }
258
+
259
+ /** Subscribe to preference changes; returns a disposer. */
260
+ function subscribeConfig(listener) {
261
+ configListeners.add(listener);
262
+ return () => {
263
+ configListeners.delete(listener);
264
+ };
265
+ }
266
+
267
+ // ── 关于 & 检查更新(v1.1.5)────────────────────────────────────────────
268
+ /** status: idle | checking | up-to-date | found | failed. */
269
+ let updateState = { status: "idle" };
270
+ function getUpdateState() {
271
+ return { ...updateState };
272
+ }
273
+ function setUpdateState(next) {
274
+ updateState = next;
275
+ }
276
+ function resetUpdateState() {
277
+ updateState = { status: "idle" };
278
+ }
116
279
 
117
280
  /**
118
- * ONE stable guest partition for the whole process.
119
- *
120
- * The desktop shell keys a process-lifetime partition Map by the
121
- * `workspace` string passed to acquire() and reuses the same session for
122
- * every acquire() with that key. release() destroys only the guest, never
123
- * the partition's cookies. Consequently, changing this value — even once
124
- * — hands the panel a fresh cookie jar and forces the user to scan the
125
- * chat login QR code again. It MUST NOT depend on the DSH account probe:
126
- * that probe is asynchronous, runs every 5 seconds, and can transiently
127
- * return "unknown" during a reconnect or session switch.
281
+ * Numeric comparison of dotted versions, so 1.1.10 > 1.1.9. Missing
282
+ * parts count as zero and a non-numeric part parses as zero. A local
283
+ * unreleased build can legitimately be ahead of the published "latest".
284
+ * @param a - first version.
285
+ * @param b - second version.
286
+ * @returns -1 when a < b, 0 when equal, 1 when a > b.
287
+ */
288
+ function compareVersion(a, b) {
289
+ const partsA = String(a ?? "").split(".").map((part) => parseInt(part, 10) || 0);
290
+ const partsB = String(b ?? "").split(".").map((part) => parseInt(part, 10) || 0);
291
+ const length = Math.max(partsA.length, partsB.length);
292
+ for (let index = 0; index < length; index += 1) {
293
+ const left = partsA[index] ?? 0;
294
+ const right = partsB[index] ?? 0;
295
+ if (left > right) return 1;
296
+ if (left < right) return -1;
297
+ }
298
+ return 0;
299
+ }
300
+
301
+ /** Extract a dotted version from an npm dist-tags or package.json payload. */
302
+ function versionFromPayload(text) {
303
+ let parsed;
304
+ try {
305
+ parsed = JSON.parse(text);
306
+ } catch {
307
+ return void 0;
308
+ }
309
+ if (parsed === null || typeof parsed !== "object") return void 0;
310
+ const candidate = typeof parsed.latest === "string" && parsed.latest.length > 0
311
+ ? parsed.latest
312
+ : parsed.version;
313
+ if (typeof candidate !== "string") return void 0;
314
+ const trimmed = candidate.trim();
315
+ return /^\d+(?:\.\d+)*$/u.test(trimmed) ? trimmed : void 0;
316
+ }
317
+
318
+ /** The renderer's fetch, if available (no Host route is added). */
319
+ function getFetch() {
320
+ try {
321
+ if (typeof fetch === "function") return fetch;
322
+ if (typeof globalThis !== "undefined" && typeof globalThis.fetch === "function") return globalThis.fetch;
323
+ } catch {
324
+ /* fetch unavailable */
325
+ }
326
+ return void 0;
327
+ }
328
+
329
+ /** GET one JSON endpoint with an abort timeout; return its version. */
330
+ async function readVersionFrom(fetchImpl, url, timeoutMs) {
331
+ const controller = typeof AbortController !== "undefined" ? new AbortController() : void 0;
332
+ const timer = controller !== void 0 ? setTimeout(() => controller.abort(), timeoutMs) : void 0;
333
+ try {
334
+ const options = controller !== void 0 ? { signal: controller.signal } : {};
335
+ const response = await fetchImpl(url, options);
336
+ if (response === null || response === void 0 || response.ok === false) {
337
+ throw new Error(`HTTP ${response?.status ?? "no response"}`);
338
+ }
339
+ const text = await response.text();
340
+ const version = versionFromPayload(text);
341
+ if (version === void 0) throw new Error("no version in payload");
342
+ return version;
343
+ } finally {
344
+ if (timer !== void 0) clearTimeout(timer);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * npm first, GitHub raw package.json as a fallback. Both endpoints are
350
+ * public, need no authentication, and answer with permissive CORS.
351
+ * @param fetchImpl - fetch implementation.
352
+ * @param timeoutMs - per-request abort timeout.
353
+ * @returns {Promise<{version: string, source: string}>}
354
+ * @throws when both sources fail.
355
+ */
356
+ async function fetchLatestVersion(fetchImpl, timeoutMs = 8000) {
357
+ let npmError;
358
+ try {
359
+ const version = await readVersionFrom(fetchImpl, NPM_VERSION_URL, timeoutMs);
360
+ return { version, source: "npm" };
361
+ } catch (error) {
362
+ npmError = error;
363
+ }
364
+ try {
365
+ const version = await readVersionFrom(fetchImpl, GITHUB_VERSION_URL, timeoutMs);
366
+ return { version, source: "GitHub" };
367
+ } catch {
368
+ /* fall through to the npm error */
369
+ }
370
+ throw npmError ?? new Error("npm and GitHub both failed");
371
+ }
372
+
373
+ /**
374
+ * Run the update check. State transitions are published through
375
+ * setUpdateState() and onChange() lets the Settings view repaint. Overlap
376
+ * is ignored while a check is in flight.
377
+ * @param onChange - called after each published state.
378
+ */
379
+ async function checkForUpdates(onChange) {
380
+ if (updateState.status === "checking") return;
381
+ const publish = (next) => {
382
+ setUpdateState(next);
383
+ if (typeof onChange === "function") {
384
+ try {
385
+ onChange();
386
+ } catch {
387
+ /* a dead view must not break the check */
388
+ }
389
+ }
390
+ };
391
+ const fetchImpl = getFetch();
392
+ if (fetchImpl === void 0) {
393
+ publish({ status: "failed", reason: COPY.updateNoFetch });
394
+ return;
395
+ }
396
+ publish({ status: "checking" });
397
+ try {
398
+ const { version, source } = await fetchLatestVersion(fetchImpl);
399
+ publish(compareVersion(version, PLUGIN_VERSION) > 0
400
+ ? { status: "found", latest: version, source }
401
+ : { status: "up-to-date", latest: version, source });
402
+ } catch (error) {
403
+ publish({ status: "failed", reason: String((error && error.message) || error) });
404
+ }
405
+ }
406
+
407
+ /**
408
+ * ONE stable guest partition for the whole process. release() destroys
409
+ * only the guest, never the partition's cookies, so this value is an
410
+ * absolute constant.
128
411
  * @returns the fixed workspace identity for guest leases.
129
412
  */
130
413
  function partitionKeyFor() {
@@ -132,9 +415,7 @@ window.__ModuleLoader__.load({
132
415
  }
133
416
 
134
417
  /**
135
- * ONE stable conversation-memory key. Account status changes must not
136
- * move it either, or a transient probe failure would silently lose the
137
- * remembered conversation.
418
+ * ONE stable conversation-memory key.
138
419
  * @returns the fixed localStorage key.
139
420
  */
140
421
  function storageKeyFor() {
@@ -190,8 +471,8 @@ window.__ModuleLoader__.load({
190
471
  }
191
472
 
192
473
  /**
193
- * Read the conversation remembered from the last visit for one account.
194
- * @param storageKey - account-scoped storage key.
474
+ * Read the conversation remembered from the last visit.
475
+ * @param storageKey - storage key.
195
476
  * @returns the URL to open; the site root when nothing usable is remembered.
196
477
  */
197
478
  function rememberedTarget(storageKey) {
@@ -209,7 +490,7 @@ window.__ModuleLoader__.load({
209
490
  * A brand-new chat clears the memory so the next visit starts clean. An auth
210
491
  * page never overwrites it, so a login detour does not lose the conversation.
211
492
  * @param raw - current page URL reported by the guest.
212
- * @param storageKey - account-scoped storage key.
493
+ * @param storageKey - storage key.
213
494
  */
214
495
  function rememberTarget(raw, storageKey) {
215
496
  const url = parseChatUrl(raw);
@@ -223,215 +504,6 @@ window.__ModuleLoader__.load({
223
504
  }
224
505
  }
225
506
 
226
- // ── account sync store ────────────────────────────────────────────────
227
- /**
228
- * One module-level snapshot shared by every mounted panel. The account
229
- * Remote is display/awareness only; it never feeds the lease identity or
230
- * the guest lifecycle, so a flaky probe can never recreate the webview.
231
- */
232
- let accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
233
- const accountListeners = new Set();
234
-
235
- function notifyAccount() {
236
- for (const listener of [...accountListeners]) {
237
- try {
238
- listener(accountSnapshot);
239
- } catch {
240
- /* a dead panel must not break another one */
241
- }
242
- }
243
- }
244
-
245
- /** Subscribe a mounted panel; returns its disposer. */
246
- function subscribeAccount(listener) {
247
- accountListeners.add(listener);
248
- return () => {
249
- accountListeners.delete(listener);
250
- };
251
- }
252
-
253
- /**
254
- * Publish a probed account state. This ONLY updates the account strip:
255
- * it never bumps an epoch and never rebuilds the guest. A transient
256
- * "unknown" during reconnect is deliberately ignored so the displayed
257
- * state does not flicker; a confirmed sign-out is shown immediately.
258
- * @param status - normalized account state.
259
- */
260
- function publishAccount(status) {
261
- const previous = accountSnapshot;
262
- // Ignore a transient unknown once we know a real state: it is usually
263
- // a reconnect, not a sign-out, and must not disturb the panel.
264
- const settled = status === ACCOUNT_UNKNOWN && previous.probed && previous.status !== ACCOUNT_UNKNOWN
265
- ? previous.status
266
- : status;
267
- accountSnapshot = {
268
- status: settled,
269
- // Preserve the name through a transient unknown; clear it only on a
270
- // confirmed sign-out when the display explicitly publishes "".
271
- label: settled === ACCOUNT_SIGNED_OUT ? "" : previous.label,
272
- probed: true
273
- };
274
- notifyAccount();
275
- }
276
-
277
- /** Publish a display name; a stale result from an older probe is dropped. */
278
- function publishAccountLabel(label) {
279
- if (accountSnapshot.label === label) return;
280
- accountSnapshot = { ...accountSnapshot, label };
281
- notifyAccount();
282
- }
283
-
284
- /**
285
- * Read one generated `ctx.remote.account` method. Generated Remotes answer
286
- * with an `{ ok, value, error }` envelope, so a bare payload is never
287
- * assumed; an absent namespace or a refused call degrades to undefined.
288
- * @param ctx - client plugin context.
289
- * @param method - generated account method name.
290
- * @param args - method arguments.
291
- * @returns the envelope's `value`, or undefined when unavailable.
292
- */
293
- async function callAccount(ctx, method, args = []) {
294
- let remote;
295
- try {
296
- remote = typeof ctx.get === "function" ? ctx.get("remote") : ctx.remote;
297
- } catch {
298
- remote = void 0;
299
- }
300
- const account = remote?.account;
301
- const call = account?.[method];
302
- if (typeof call !== "function") return void 0;
303
- const result = await call.apply(account, args);
304
- if (result === null || typeof result !== "object" || result.ok !== true) return void 0;
305
- return result.value;
306
- }
307
-
308
- /** Platform client identity headers, read at call time as the UI does. */
309
- function accountClient(ctx) {
310
- return {
311
- version: globalThis.__DSH_BOOT__?.release?.version || HOST_VERSION_FALLBACK,
312
- locale: typeof navigator !== "undefined" ? navigator.language || "en-US" : "en-US",
313
- timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60
314
- };
315
- }
316
-
317
- /**
318
- * Read the live DSH account state and display name. This is awareness
319
- * ONLY: no value here may ever feed the guest lease or rebuild the
320
- * webview, or a transient Remote failure would drop the chat session.
321
- * @param ctx - client plugin context.
322
- * @returns `{ status, label }`.
323
- */
324
- async function probeAccount(ctx) {
325
- let state;
326
- try {
327
- state = await callAccount(ctx, "getState");
328
- } catch {
329
- state = void 0;
330
- }
331
- if (state?.status !== "credential-stored") {
332
- return {
333
- status: state?.status === "signed-out" ? ACCOUNT_SIGNED_OUT : ACCOUNT_UNKNOWN,
334
- label: ""
335
- };
336
- }
337
- const probe = { status: ACCOUNT_SIGNED_IN, label: "" };
338
- try {
339
- const outcome = await callAccount(ctx, "getProfile", [accountClient(ctx)]);
340
- const value = outcome?.status === "ready" ? outcome.value : void 0;
341
- probe.label = [value?.name, value?.contact, value?.id]
342
- .find((item) => typeof item === "string" && item.length > 0) ?? "";
343
- } catch {
344
- /* profile is display-only: failure keeps the bare state */
345
- }
346
- return probe;
347
- }
348
-
349
- /**
350
- * Start the account watcher once per plugin activation. A 5-second poll
351
- * plus `connection/reset` gives self-heal without depending on the exact
352
- * Client shape of the account `watch` stream, and every resource is
353
- * released through the plugin's own ctx effect.
354
- * @param ctx - client plugin context.
355
- */
356
- let accountSyncStarted = false;
357
- function startAccountSync(ctx) {
358
- if (accountSyncStarted) return;
359
- accountSyncStarted = true;
360
- let stopped = false;
361
- let timer;
362
-
363
- const refresh = async () => {
364
- if (stopped) return;
365
- let probe = { status: ACCOUNT_UNKNOWN, label: "" };
366
- try {
367
- probe = await probeAccount(ctx);
368
- } catch {
369
- /* Remote unavailable: publishAccount ignores a transient unknown */
370
- probe = { status: ACCOUNT_UNKNOWN, label: "" };
371
- }
372
- if (stopped) return;
373
- publishAccount(probe.status);
374
- if (probe.label !== "") publishAccountLabel(probe.label);
375
- };
376
-
377
- const schedule = () => {
378
- if (stopped) return;
379
- timer = setTimeout(() => {
380
- void refresh().finally(schedule);
381
- }, 5000);
382
- // A long-lived page keeps the poll; a minimal context (or a Node
383
- // self-check) must not be held open by its timer alone.
384
- try {
385
- if (typeof timer.unref === "function") timer.unref();
386
- } catch {
387
- /* not a Node timer */
388
- }
389
- };
390
-
391
- void refresh().finally(schedule);
392
-
393
- let offReset;
394
- try {
395
- if (typeof ctx.on === "function") offReset = ctx.on("connection/reset", () => {
396
- void refresh();
397
- });
398
- } catch {
399
- /* optional: the poll still self-heals */
400
- }
401
-
402
- let hasDisposal = false;
403
- try {
404
- if (typeof ctx.effect === "function") {
405
- ctx.effect(() => () => {
406
- stopped = true;
407
- if (timer !== void 0) clearTimeout(timer);
408
- try {
409
- if (typeof offReset === "function") offReset();
410
- } catch {
411
- /* already detached */
412
- }
413
- accountListeners.clear();
414
- accountSyncStarted = false;
415
- }, "deepseek-chat: account sync");
416
- hasDisposal = true;
417
- }
418
- } catch {
419
- /* fall through to the no-disposal path */
420
- }
421
- if (!hasDisposal) {
422
- stopped = true;
423
- if (timer !== void 0) clearTimeout(timer);
424
- try {
425
- if (typeof offReset === "function") offReset();
426
- } catch {
427
- /* already detached */
428
- }
429
- // Without a real ctx effect there is no teardown to stop the poll;
430
- // keep the first probe but never leave a repeating timer behind.
431
- accountSyncStarted = false;
432
- }
433
- }
434
-
435
507
  // ── guest helpers ─────────────────────────────────────────────────────
436
508
  /**
437
509
  * Whether the guest element is still in the document. Electron's webview
@@ -502,29 +574,12 @@ window.__ModuleLoader__.load({
502
574
  root: {
503
575
  display: "flex",
504
576
  flexDirection: "column",
577
+ flex: "1 1 auto",
505
578
  height: "100%",
506
579
  minHeight: "0",
507
580
  background: "var(--dsw-alias-bg-base)",
508
581
  color: "var(--dsw-alias-label-primary)"
509
582
  },
510
- accountBar: {
511
- flex: "none",
512
- display: "flex",
513
- alignItems: "center",
514
- gap: "6px",
515
- minHeight: "24px",
516
- padding: "3px 10px",
517
- borderBottom: ".5px solid var(--dsw-alias-border-l3)",
518
- color: "var(--dsw-alias-label-tertiary)",
519
- font: "var(--dsw-font-xxxs-11)"
520
- },
521
- accountDot: {
522
- width: "6px",
523
- height: "6px",
524
- borderRadius: "50%",
525
- background: "currentColor",
526
- flex: "none"
527
- },
528
583
  host: {
529
584
  position: "relative",
530
585
  flex: "1",
@@ -560,20 +615,121 @@ window.__ModuleLoader__.load({
560
615
  padding: "4px 12px",
561
616
  cursor: "pointer",
562
617
  font: "var(--dsw-font-xxs-12)"
618
+ },
619
+ /**
620
+ * Tab entry wrapper. The conversation renderer's `viewArea` is a
621
+ * sibling of the sticky composerSeat; in the active phase it becomes
622
+ * `flex:1 0 auto` so `height:100%` on the panel has no definite
623
+ * parent height and the Session's own messages/composer stay visible
624
+ * below. An absolutely positioned overlay whose containing block is
625
+ * the conversation body (position:relative) covers exactly that body,
626
+ * hides the composer with an opaque background and a z-index above
627
+ * its 9, and leaves the header/tab row above the body clickable.
628
+ */
629
+ overlay: {
630
+ position: "absolute",
631
+ inset: "0",
632
+ zIndex: "50",
633
+ display: "flex",
634
+ flexDirection: "column",
635
+ minHeight: "0",
636
+ overflow: "hidden",
637
+ background: "var(--dsw-alias-bg-base)"
638
+ },
639
+ settingsPage: {
640
+ boxSizing: "border-box",
641
+ maxWidth: "620px",
642
+ padding: "20px 24px",
643
+ color: "var(--dsw-alias-label-primary)"
644
+ },
645
+ settingsTitle: {
646
+ margin: "0 0 12px",
647
+ fontSize: "16px",
648
+ fontWeight: 600
649
+ },
650
+ settingsRow: {
651
+ display: "flex",
652
+ flexDirection: "column",
653
+ gap: "6px",
654
+ padding: "14px 0",
655
+ borderBottom: ".5px solid var(--dsw-alias-border-l3)"
656
+ },
657
+ settingsLabelRow: {
658
+ display: "flex",
659
+ alignItems: "center",
660
+ gap: "10px",
661
+ fontSize: "13px"
662
+ },
663
+ settingsCheckbox: {
664
+ width: "16px",
665
+ height: "16px",
666
+ margin: "0",
667
+ cursor: "pointer"
668
+ },
669
+ settingsHint: {
670
+ margin: "0",
671
+ fontSize: "12px",
672
+ lineHeight: "1.6",
673
+ color: "var(--dsw-alias-label-tertiary)"
674
+ },
675
+ settingsInput: {
676
+ boxSizing: "border-box",
677
+ width: "100%",
678
+ marginTop: "2px",
679
+ padding: "6px 10px",
680
+ border: ".5px solid var(--dsw-alias-border-l3)",
681
+ borderRadius: "var(--dsw-radius-sm)",
682
+ background: "var(--dsw-alias-bg-layer-1)",
683
+ color: "var(--dsw-alias-label-primary)",
684
+ font: "var(--dsw-font-xxs-12)"
685
+ },
686
+ aboutSectionTitle: {
687
+ margin: "18px 0 8px",
688
+ fontSize: "13px",
689
+ fontWeight: 600
690
+ },
691
+ aboutRow: {
692
+ display: "flex",
693
+ gap: "8px",
694
+ padding: "3px 0",
695
+ fontSize: "12px",
696
+ lineHeight: "1.6"
697
+ },
698
+ aboutLabel: {
699
+ flex: "0 0 88px",
700
+ color: "var(--dsw-alias-label-tertiary)"
701
+ },
702
+ aboutValue: {
703
+ flex: "1 1 auto",
704
+ minWidth: "0",
705
+ wordBreak: "break-word"
706
+ },
707
+ aboutLink: {
708
+ color: "var(--dsw-alias-label-link, var(--dsw-alias-label-primary))",
709
+ textDecoration: "underline",
710
+ cursor: "pointer",
711
+ marginRight: "14px"
712
+ },
713
+ updateButton: {
714
+ color: "var(--dsw-alias-label-primary)",
715
+ background: "var(--dsw-alias-bg-layer-1)",
716
+ border: ".5px solid var(--dsw-alias-border-l3)",
717
+ borderRadius: "var(--dsw-radius-sm)",
718
+ padding: "6px 14px",
719
+ cursor: "pointer",
720
+ font: "var(--dsw-font-xxs-12)"
721
+ },
722
+ updateResult: {
723
+ margin: "8px 0 0",
724
+ padding: "8px 10px",
725
+ borderRadius: "var(--dsw-radius-sm)",
726
+ background: "var(--dsw-alias-bg-layer-1)",
727
+ fontSize: "12px",
728
+ lineHeight: "1.7",
729
+ color: "var(--dsw-alias-label-primary)"
563
730
  }
564
731
  };
565
732
 
566
- /** Copy for the account strip; the dot's opacity encodes the state. */
567
- function accountCopy(account) {
568
- if (account.status === ACCOUNT_SIGNED_IN) return {
569
- text: account.label ? COPY.syncedAs(account.label) : COPY.synced,
570
- opacity: "1"
571
- };
572
- if (account.status === ACCOUNT_SIGNED_OUT) return { text: COPY.unsigned, opacity: ".45" };
573
- if (!account.probed) return { text: COPY.probing, opacity: ".3" };
574
- return { text: COPY.unknown, opacity: ".3" };
575
- }
576
-
577
733
  /**
578
734
  * Keeps a rendering failure inside this panel. DSH has no boundary of its own
579
735
  * around slot entries: an escaping error unmounts the root slot, which is the
@@ -608,32 +764,22 @@ window.__ModuleLoader__.load({
608
764
  }
609
765
 
610
766
  /**
611
- * The global chat panel: an account strip and a guest webview filling the
612
- * main column.
767
+ * The web chat panel: a guest webview filling the available column, with
768
+ * no account strip above it.
613
769
  *
614
770
  * The guest is created only after the lease resolves, and it is mounted with
615
771
  * `name`/`partition`/`src` already set, because the main process inspects
616
772
  * exactly that bootstrap src when it approves the attachment. The identity
617
- * and the conversation memory are both scoped to the current DSH account;
618
- * an account transition bumps `epoch`, which tears down the old guest and
619
- * re-acquires a lease for the new identity.
773
+ * and the conversation memory are both absolute constants, so switching
774
+ * between the sidebar panel and the conversation tab preserves the login.
620
775
  */
621
776
  function ChatPanel() {
622
777
  const hostRef = react.useRef(null);
623
778
  const lastUrlRef = react.useRef(void 0);
624
- const [account, setAccount] = react.useState(accountSnapshot);
625
779
  const [failure, setFailure] = react.useState(void 0);
626
780
  const [loading, setLoading] = react.useState(true);
627
781
  const [attempt, setAttempt] = react.useState(0);
628
782
 
629
- react.useEffect(() => subscribeAccount((next) => {
630
- try {
631
- setAccount(next);
632
- } catch {
633
- /* the panel is gone */
634
- }
635
- }), []);
636
-
637
783
  const retry = react.useCallback(() => {
638
784
  try {
639
785
  setFailure(void 0);
@@ -852,12 +998,7 @@ window.__ModuleLoader__.load({
852
998
  };
853
999
  }, [attempt]);
854
1000
 
855
- const copy = accountCopy(account);
856
-
857
1001
  return react.createElement("div", { style: styles.root },
858
- react.createElement("div", { style: styles.accountBar },
859
- react.createElement("span", { style: { ...styles.accountDot, opacity: copy.opacity }, "aria-hidden": "true" }),
860
- react.createElement("span", null, copy.text)),
861
1002
  react.createElement("div", { style: styles.host, ref: hostRef },
862
1003
  failure === void 0 && loading
863
1004
  ? react.createElement("div", { style: styles.notice }, COPY.loading)
@@ -874,49 +1015,234 @@ window.__ModuleLoader__.load({
874
1015
  return react.createElement(PanelBoundary, null, react.createElement(ChatPanel));
875
1016
  }
876
1017
 
1018
+ /**
1019
+ * The conversation top-bar tab. Unlike the left-sidebar panel, this entry
1020
+ * must fill the whole conversation body and cover the Session's own
1021
+ * messages/composer (the "页面没有铺满下面" bug): an opaque, absolutely
1022
+ * positioned overlay on the conversation body's containing block.
1023
+ * The component is created ONCE (not per render) so React never remounts
1024
+ * the guest webview on an unrelated re-render.
1025
+ */
1026
+ function ViewTabShell() {
1027
+ return react.createElement("div", {
1028
+ "data-dsh-plugin-deepseek-chat": "view",
1029
+ style: styles.overlay
1030
+ }, react.createElement(PanelBoundary, null, react.createElement(ChatPanel)));
1031
+ }
1032
+
1033
+ /** One metadata row: a fixed-width label plus a value. */
1034
+ function infoRow(label, value) {
1035
+ return react.createElement("div", { style: styles.aboutRow },
1036
+ react.createElement("span", { style: styles.aboutLabel }, label),
1037
+ react.createElement("span", { style: styles.aboutValue }, value));
1038
+ }
1039
+
1040
+ /** External https link; the desktop shell opens it in the system browser. */
1041
+ function externalLink(href, text) {
1042
+ return react.createElement("a", {
1043
+ href,
1044
+ target: "_blank",
1045
+ rel: "noopener noreferrer",
1046
+ style: styles.aboutLink
1047
+ }, text);
1048
+ }
1049
+
1050
+ /** The two jump links shown in the About block and after an update check. */
1051
+ function linkRow() {
1052
+ return react.createElement("div", { style: styles.aboutRow },
1053
+ react.createElement("span", { style: styles.aboutLabel }, COPY.infoLinks),
1054
+ react.createElement("span", { style: styles.aboutValue },
1055
+ externalLink(NPM_PAGE_URL, COPY.linkNpm),
1056
+ externalLink(GITHUB_PAGE_URL, COPY.linkGithub)));
1057
+ }
1058
+
1059
+ /** Human-readable one-line status for the current update-check state. */
1060
+ function updateStatusLine(state) {
1061
+ if (state.status === "idle") return COPY.updateIdle;
1062
+ if (state.status === "checking") return COPY.updateChecking;
1063
+ let line;
1064
+ if (state.status === "found") {
1065
+ line = `${COPY.updateFound} ${state.latest}(${COPY.updateCurrent} ${PLUGIN_VERSION})`;
1066
+ } else if (state.status === "up-to-date") {
1067
+ line = `${COPY.updateUpToDate}(${COPY.updateLatest} ${state.latest})`;
1068
+ } else {
1069
+ line = `${COPY.updateFailed}:${state.reason ?? ""}`;
1070
+ }
1071
+ if (state.source !== void 0) line += ` · ${COPY.updateSource} ${state.source}`;
1072
+ return line;
1073
+ }
1074
+
1075
+ /**
1076
+ * Settings page. Built once and reused: writing a checkbox or typing in the
1077
+ * text box calls writeConfig(), which persists and notifies apply()'s
1078
+ * subscriber; the local counter only refreshes this form's own state. The
1079
+ * About block and the "检查更新" button are also part of this one component.
1080
+ */
1081
+ function makeSettingsView() {
1082
+ return function SettingsView() {
1083
+ const state = react.useState(0);
1084
+ const rerender = () => state[1]((value) => value + 1);
1085
+ const current = getConfig();
1086
+ const update = getUpdateState();
1087
+ // The default tab label is shown as a grey placeholder, never as a
1088
+ // solid value the user must delete first. `draft` is component-local:
1089
+ // it stays empty while the config holds the default, and follows what
1090
+ // the user types. Clearing it makes writeConfig() fall back to the
1091
+ // default again, so the box returns to its empty placeholder state.
1092
+ const draftState = react.useState(current.tabLabel === DEFAULT_TAB_LABEL ? "" : current.tabLabel);
1093
+ const draft = draftState[0];
1094
+ const setDraft = draftState[1];
1095
+
1096
+ const onToggle = (event) => {
1097
+ writeConfig({ showTab: !!event?.target?.checked });
1098
+ rerender();
1099
+ };
1100
+ const onLabelChange = (event) => {
1101
+ const value = String(event?.target?.value ?? "");
1102
+ // Whitespace-only input resolves back to the default name, so keep
1103
+ // the box empty to let the grey placeholder show even without a
1104
+ // remount (real React keeps this component mounted).
1105
+ setDraft(value.trim().length === 0 ? "" : value);
1106
+ writeConfig({ tabLabel: value });
1107
+ rerender();
1108
+ };
1109
+ const onCheckUpdate = () => {
1110
+ void checkForUpdates(rerender);
1111
+ };
1112
+
1113
+ const showLinks = update.status === "found" || update.status === "up-to-date" || update.status === "failed";
1114
+
1115
+ return react.createElement("div", { style: styles.settingsPage },
1116
+ react.createElement("h2", { style: styles.settingsTitle }, COPY.settingsTitle),
1117
+ react.createElement("div", { style: styles.settingsRow },
1118
+ react.createElement("label", { style: styles.settingsLabelRow },
1119
+ react.createElement("input", {
1120
+ type: "checkbox",
1121
+ style: styles.settingsCheckbox,
1122
+ checked: current.showTab !== false,
1123
+ onChange: onToggle
1124
+ }),
1125
+ COPY.settingsEnableTab),
1126
+ react.createElement("p", { style: styles.settingsHint }, COPY.settingsEnableHint)),
1127
+ react.createElement("div", { style: styles.settingsRow },
1128
+ react.createElement("label", { style: styles.settingsLabelRow }, COPY.settingsTabLabel),
1129
+ react.createElement("input", {
1130
+ type: "text",
1131
+ style: styles.settingsInput,
1132
+ value: draft,
1133
+ placeholder: COPY.settingsPlaceholder,
1134
+ maxLength: 40,
1135
+ onChange: onLabelChange
1136
+ }),
1137
+ react.createElement("p", { style: styles.settingsHint }, COPY.settingsTabLabelHint)),
1138
+
1139
+ // 关于本插件:版本号 / 作者 / 功能简介 / 许可证 / 相关链接。
1140
+ react.createElement("h3", { style: styles.aboutSectionTitle }, COPY.aboutTitle),
1141
+ infoRow(COPY.infoVersion, PLUGIN_VERSION),
1142
+ infoRow(COPY.infoAuthor, PLUGIN_AUTHOR),
1143
+ infoRow(COPY.infoDescription, COPY.infoSummary),
1144
+ infoRow(COPY.infoLicense, PLUGIN_LICENSE),
1145
+ linkRow(),
1146
+
1147
+ // 检查更新:按钮 + 文字状态 + 跳转链接。
1148
+ react.createElement("h3", { style: styles.aboutSectionTitle }, COPY.updateTitle),
1149
+ react.createElement("button", {
1150
+ type: "button",
1151
+ "data-dsh-plugin-deepseek-chat": "check-update",
1152
+ style: styles.updateButton,
1153
+ disabled: update.status === "checking",
1154
+ onClick: onCheckUpdate
1155
+ }, COPY.checkUpdate),
1156
+ react.createElement("div", {
1157
+ "data-dsh-plugin-deepseek-chat": "update-status",
1158
+ style: styles.updateResult
1159
+ },
1160
+ react.createElement("div", null, updateStatusLine(update)),
1161
+ showLinks ? linkRow() : null));
1162
+ };
1163
+ }
1164
+
877
1165
  /** Client service this plugin reads; the loader waits for the slot registry. */
878
1166
  const inject = ["slots"];
879
1167
 
880
1168
  /**
881
- * Register the left-sidebar entry and its main panel.
1169
+ * Register the contributions. On the Web surface there is no desktop guest
1170
+ * bridge and an iframe cannot carry the site, so the plugin stays inert
1171
+ * there. Each registration is isolated: one bad slot must not undo the
1172
+ * others.
1173
+ *
1174
+ * The conversation.view tab is dynamic: its label comes from the Settings
1175
+ * name, and its visibility from the showTab switch. The tab row only
1176
+ * recomputes when the slot registry changes, so on any preference change we
1177
+ * dispose that single injection and re-inject it (label thunk + showTab
1178
+ * check). The main/sidebar/settings entries are registered once.
882
1179
  * @param ctx - client plugin context.
883
1180
  */
884
1181
  function apply(ctx) {
1182
+ let bridge;
885
1183
  try {
886
- // The panel needs the desktop guest bridge; on the Web surface an iframe
887
- // cannot carry the site, so the plugin stays inert there.
888
- if (globalThis.dshDesktop?.browser === void 0) return;
1184
+ bridge = globalThis.dshDesktop?.browser;
889
1185
  } catch {
890
1186
  return;
891
1187
  }
1188
+ if (bridge === void 0 || bridge === null) return;
892
1189
 
893
- try {
894
- startAccountSync(ctx);
895
- } catch (error) {
1190
+ const register = (slot, options, component) => {
896
1191
  try {
897
- console.error("dsh-plugin-deepseek-chat: account sync failed", error);
898
- } catch {
899
- /* console unavailable */
1192
+ return ctx.slots.inject(slot, () => ctx.slots.register(options, component));
1193
+ } catch (error) {
1194
+ try {
1195
+ console.error(`dsh-plugin-deepseek-chat: ${slot} registration failed`, error);
1196
+ } catch {
1197
+ /* console unavailable */
1198
+ }
1199
+ return void 0;
900
1200
  }
901
- }
1201
+ };
902
1202
 
903
- try {
904
- ctx.slots.inject("main", () => ctx.slots.register({
905
- name: "main",
906
- key: PANEL_ID
907
- }, ChatPanelShell));
908
-
909
- ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
910
- name: "sidebar.panellist",
911
- id: PANEL_ID,
912
- order: 5,
913
- label: () => COPY.label
914
- }, ChatGlyph));
915
- } catch (error) {
1203
+ // Static entries: left-sidebar panel + full main panel + Settings page.
1204
+ register("main", { name: "main", key: PANEL_ID }, ChatPanelShell);
1205
+ register("sidebar.panellist", {
1206
+ name: "sidebar.panellist",
1207
+ id: PANEL_ID,
1208
+ order: 5,
1209
+ label: () => COPY.label
1210
+ }, ChatGlyph);
1211
+ register("settings.section", {
1212
+ name: "settings.section",
1213
+ id: SETTINGS_ID,
1214
+ order: SETTINGS_ORDER,
1215
+ label: () => COPY.settingsNav
1216
+ }, makeSettingsView());
1217
+
1218
+ // Dynamic entry: the conversation top-bar tab. Re-injected whenever
1219
+ // a preference changes so the tab row recomputes id/label/visibility.
1220
+ let disposeView = void 0;
1221
+ const syncView = () => {
1222
+ if (typeof disposeView === "function") {
1223
+ try {
1224
+ disposeView();
1225
+ } catch {
1226
+ /* already gone */
1227
+ }
1228
+ }
1229
+ disposeView = void 0;
1230
+ if (config.showTab === false) return;
1231
+ disposeView = register("conversation.view", {
1232
+ name: "conversation.view",
1233
+ id: VIEW_ID,
1234
+ order: VIEW_ORDER,
1235
+ label: () => tabLabel()
1236
+ }, ViewTabShell);
1237
+ };
1238
+ syncView();
1239
+
1240
+ const unsubscribe = subscribeConfig(syncView);
1241
+ if (typeof ctx.effect === "function") {
916
1242
  try {
917
- console.error("dsh-plugin-deepseek-chat: registration failed", error);
1243
+ ctx.effect(() => unsubscribe, "dsh-plugin-deepseek-chat: settings sync");
918
1244
  } catch {
919
- /* console unavailable */
1245
+ /* effect registration unavailable: the subscriber still lives */
920
1246
  }
921
1247
  }
922
1248
  }
@@ -924,24 +1250,41 @@ window.__ModuleLoader__.load({
924
1250
  exports.apply = apply;
925
1251
  exports.inject = inject;
926
1252
  exports.__test__ = {
927
- ACCOUNT_UNKNOWN,
928
- ACCOUNT_SIGNED_IN,
929
- ACCOUNT_SIGNED_OUT,
930
1253
  CHAT_URL,
931
1254
  STORAGE_BASE,
932
- accountCopy,
933
- getSnapshot: () => accountSnapshot,
1255
+ PANEL_ID,
1256
+ VIEW_ID,
1257
+ VIEW_ORDER,
1258
+ SETTINGS_ID,
1259
+ SETTINGS_ORDER,
1260
+ CONFIG_KEY,
1261
+ DEFAULTS,
1262
+ PLUGIN_NAME,
1263
+ PLUGIN_VERSION,
1264
+ PLUGIN_AUTHOR,
1265
+ PLUGIN_LICENSE,
1266
+ NPM_PAGE_URL,
1267
+ GITHUB_PAGE_URL,
1268
+ NPM_VERSION_URL,
1269
+ GITHUB_VERSION_URL,
1270
+ parseChatUrl,
1271
+ parsePopupUrl,
934
1272
  partitionKeyFor,
935
1273
  storageKeyFor,
936
- publishAccount,
937
- publishAccountLabel,
938
- probeAccount,
939
- callAccount,
940
- resetAccountSync() {
941
- accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
942
- accountListeners.clear();
943
- accountSyncStarted = false;
944
- }
1274
+ readConfig,
1275
+ getConfig,
1276
+ normalizeTabLabel,
1277
+ tabLabel,
1278
+ writeConfig,
1279
+ subscribeConfig,
1280
+ compareVersion,
1281
+ versionFromPayload,
1282
+ readVersionFrom,
1283
+ fetchLatestVersion,
1284
+ getUpdateState,
1285
+ setUpdateState,
1286
+ resetUpdateState,
1287
+ checkForUpdates
945
1288
  };
946
1289
  return module.exports;
947
1290
  }