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

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,5 +1,5 @@
1
1
  /**
2
- * Browser half of dsh-plugin-deepseek-chat.
2
+ * Browser half of dsh-plugin-deepseek-chat (v1.1.1).
3
3
  *
4
4
  * Registers one global left-sidebar panel — the same mechanism the Plugins and
5
5
  * Scheduled tasks entries use:
@@ -12,22 +12,48 @@
12
12
  * lease bridge the built-in sidebar browser uses: acquire a lease → create a
13
13
  * webview named by the lease with its approved partition → load the URL.
14
14
  *
15
- * Failure containment is the hard rule of this file. DSH treats any uncaught
16
- * error thrown by a plugin as a crash of its slot entry, and a crash inside the
17
- * `root` slot unmounts the entire application UI (the native frame stays, every
18
- * panel disappears). Electron throws from webview methods such as `getURL()`
19
- * and `loadURL()` as soon as the element leaves the document, and React runs
20
- * effect cleanups after the DOM removal — so every guest call here is attached-
21
- * checked and wrapped, every event handler catches, and a React error boundary
22
- * keeps a render failure inside this panel. A dead guest is reported in the
23
- * panel with a Retry button; it never escapes to the shell.
15
+ * Stable login session (v1.1.1)
16
+ * -----------------------------
17
+ * The desktop shell caches ONE process-lifetime session per `workspace` string
18
+ * passed to acquire(), and release() destroys only the guest, never the
19
+ * 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.
24
23
  *
25
- * Leaving the panel unmounts this page and releases the guest, so the page
26
- * remembers the last conversation URL and reopens there instead of starting a
27
- * new chat every time.
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:
31
+ *
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.
39
+ *
40
+ * Failure containment is still the hard rule of this file. DSH treats any
41
+ * uncaught error thrown by a plugin as a crash of its slot entry, and a crash
42
+ * inside the `root` slot unmounts the entire application UI. Electron throws
43
+ * from webview methods such as `getURL()` / `loadURL()` as soon as the element
44
+ * leaves the document, and React runs effect cleanups after the DOM removal —
45
+ * so every guest call is attached-checked and wrapped, every event handler
46
+ * catches, a React error boundary keeps a render failure inside this panel, and
47
+ * a failure removes the dead guest before showing the in-panel Retry card.
28
48
  */
29
49
  window.__ModuleLoader__.load({
30
- id: "dsh-plugin-deepseek-chat",
50
+ // MUST equal package.json "name" (the scoped npm name). The Host keys every
51
+ // client graph row by the package name and, after executing a bundle script,
52
+ // checks `factories.has(<row id>)`. A mismatched id makes arrive() think the
53
+ // bundle registered nothing, re-execute the same script, and throw
54
+ // "duplicate factory registration ... (bundle executed twice without
55
+ // invalidate?)" during web boot. See docs/RELEASE_NOTES_v1.1.1.md.
56
+ id: "@tianyulinfeng1/dsh-plugin-deepseek-chat",
31
57
  factory: (require) => {
32
58
  var module = { exports: {} };
33
59
  var exports = module.exports;
@@ -35,13 +61,24 @@ window.__ModuleLoader__.load({
35
61
  const react = require("react");
36
62
 
37
63
  const PANEL_ID = "deepseek-chat";
64
+ /**
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.
68
+ */
69
+ const HOST_VERSION_FALLBACK = "0.2.2-rc.1";
38
70
  const CHAT_URL = "https://chat.deepseek.com/";
39
71
  /**
40
- * Storage identity for the guest partition. The main process keeps one
41
- * partition per identity for the lifetime of the process, so switching
42
- * panels or sessions keeps the page state and its login.
72
+ * Constant storage identities for the guest partition and the
73
+ * 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.
43
77
  */
44
- const PARTITION_KEY = "panel:deepseek-chat";
78
+ const PARTITION_BASE = "panel:deepseek-chat";
79
+ const STORAGE_BASE = "dsh-plugin-deepseek-chat:last-url";
80
+ /** Paths that are not a conversation: a brand-new chat, or an auth page. */
81
+ const NON_CONVERSATION_PATHS = new Set(["/", "/sign_in", "/login", "/signup", "/auth"]);
45
82
 
46
83
  const english = typeof navigator !== "undefined" && /^en\b/i.test(navigator.language ?? "");
47
84
  const COPY = english ? {
@@ -51,7 +88,12 @@ window.__ModuleLoader__.load({
51
88
  failed: "The chat page could not be opened",
52
89
  crashed: "The embedded chat stopped responding",
53
90
  timeout: "The embedded chat did not start",
54
- retry: "Retry"
91
+ 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"
55
97
  } : {
56
98
  label: "DeepSeek 聊天",
57
99
  loading: "正在加载 DeepSeek 聊天…",
@@ -59,13 +101,45 @@ window.__ModuleLoader__.load({
59
101
  failed: "聊天页面打开失败",
60
102
  crashed: "内嵌聊天进程已退出",
61
103
  timeout: "内嵌聊天页面没有启动",
62
- retry: "重试"
104
+ retry: "重试",
105
+ probing: "正在同步 DSH 登录账号…",
106
+ synced: "已与 DSH 登录账号同步",
107
+ syncedAs: (name) => `已与 DSH 登录账号同步 · ${name}`,
108
+ unsigned: "未登录 DSH 账号,聊天面板暂未同步",
109
+ unknown: "账号状态不可用,会话按本地模式保存"
63
110
  };
64
111
 
65
- /** Where the last visited conversation URL is remembered; the shell keeps this storage across restarts. */
66
- const STORAGE_KEY = "dsh-plugin-deepseek-chat:last-url";
67
- /** Paths that are not a conversation: a brand-new chat, or an auth page. */
68
- const NON_CONVERSATION_PATHS = new Set(["/", "/sign_in", "/login", "/signup", "/auth"]);
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";
116
+
117
+ /**
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.
128
+ * @returns the fixed workspace identity for guest leases.
129
+ */
130
+ function partitionKeyFor() {
131
+ return PARTITION_BASE;
132
+ }
133
+
134
+ /**
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.
138
+ * @returns the fixed localStorage key.
139
+ */
140
+ function storageKeyFor() {
141
+ return STORAGE_BASE;
142
+ }
69
143
 
70
144
  /**
71
145
  * Parse a chat.deepseek.com page URL.
@@ -77,13 +151,33 @@ window.__ModuleLoader__.load({
77
151
  try {
78
152
  const url = new URL(value);
79
153
  if (url.protocol !== "https:" || url.hostname !== "chat.deepseek.com") return void 0;
80
- if (url.username !== "" || url.password !== "") return void 0;
154
+ if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
81
155
  return url;
82
156
  } catch {
83
157
  return void 0;
84
158
  }
85
159
  }
86
160
 
161
+ /**
162
+ * Destinations a guest popup may open. The chat site and the Platform
163
+ * login pages are the only hosts this panel deliberately navigates to;
164
+ * an arbitrary URL from the open-request bridge is never loaded.
165
+ * @param value - candidate URL.
166
+ * @returns the normalized URL, or undefined.
167
+ */
168
+ function parsePopupUrl(value) {
169
+ if (typeof value !== "string" || value.length === 0 || value.length > 4096) return void 0;
170
+ try {
171
+ const url = new URL(value);
172
+ if (url.protocol !== "https:") return void 0;
173
+ if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
174
+ if (url.hostname !== "chat.deepseek.com" && url.hostname !== "platform.deepseek.com") return void 0;
175
+ return url.toString();
176
+ } catch {
177
+ return void 0;
178
+ }
179
+ }
180
+
87
181
  /**
88
182
  * Whether a URL deep enough to be a conversation page.
89
183
  * @param url - parsed chat URL, possibly undefined.
@@ -96,12 +190,13 @@ window.__ModuleLoader__.load({
96
190
  }
97
191
 
98
192
  /**
99
- * Read the conversation remembered from the last visit.
193
+ * Read the conversation remembered from the last visit for one account.
194
+ * @param storageKey - account-scoped storage key.
100
195
  * @returns the URL to open; the site root when nothing usable is remembered.
101
196
  */
102
- function rememberedTarget() {
197
+ function rememberedTarget(storageKey) {
103
198
  try {
104
- const saved = parseChatUrl(window.localStorage.getItem(STORAGE_KEY));
199
+ const saved = parseChatUrl(window.localStorage.getItem(storageKey));
105
200
  return isConversation(saved) ? saved.toString() : CHAT_URL;
106
201
  } catch {
107
202
  return CHAT_URL;
@@ -114,19 +209,230 @@ window.__ModuleLoader__.load({
114
209
  * A brand-new chat clears the memory so the next visit starts clean. An auth
115
210
  * page never overwrites it, so a login detour does not lose the conversation.
116
211
  * @param raw - current page URL reported by the guest.
212
+ * @param storageKey - account-scoped storage key.
117
213
  */
118
- function rememberTarget(raw) {
214
+ function rememberTarget(raw, storageKey) {
119
215
  const url = parseChatUrl(raw);
120
216
  if (url === void 0) return;
121
217
  try {
122
218
  const path = url.pathname.replace(/\/+$/u, "") || "/";
123
- if (isConversation(url)) window.localStorage.setItem(STORAGE_KEY, url.toString());
124
- else if (path === "/") window.localStorage.removeItem(STORAGE_KEY);
219
+ if (isConversation(url)) window.localStorage.setItem(storageKey, url.toString());
220
+ else if (path === "/") window.localStorage.removeItem(storageKey);
125
221
  } catch {
126
222
  /* storage unavailable: the panel still works, it just forgets */
127
223
  }
128
224
  }
129
225
 
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
+ // ── guest helpers ─────────────────────────────────────────────────────
130
436
  /**
131
437
  * Whether the guest element is still in the document. Electron's webview
132
438
  * methods throw once it is detached, so this gates every guest call.
@@ -141,13 +447,7 @@ window.__ModuleLoader__.load({
141
447
  }
142
448
  }
143
449
 
144
- /**
145
- * Guest URL, or undefined when the element cannot report one. This is the
146
- * call that used to escape: React cleans up effects after removing the DOM,
147
- * and Electron then throws from `getURL()`.
148
- * @param element - webview element.
149
- * @returns the URL string, or undefined.
150
- */
450
+ /** Guest URL, or undefined when the element cannot report one. */
151
451
  function readUrl(element) {
152
452
  if (!attached(element)) return void 0;
153
453
  try {
@@ -160,8 +460,6 @@ window.__ModuleLoader__.load({
160
460
 
161
461
  /**
162
462
  * Navigate the guest without ever throwing.
163
- * @param element - webview element.
164
- * @param url - target URL.
165
463
  * @returns false when the guest is gone, so the caller can report it.
166
464
  */
167
465
  function navigate(element, url) {
@@ -178,8 +476,6 @@ window.__ModuleLoader__.load({
178
476
  /**
179
477
  * Panel-list glyph, drawn on currentColor so the sidebar's active state owns
180
478
  * the ink. Matches the sidebar's 16/18px icon seat.
181
- * @param props - size passed by the sidebar panel list.
182
- * @returns the glyph element.
183
479
  */
184
480
  function ChatGlyph({ size = 18 }) {
185
481
  return react.createElement("svg", {
@@ -211,6 +507,24 @@ window.__ModuleLoader__.load({
211
507
  background: "var(--dsw-alias-bg-base)",
212
508
  color: "var(--dsw-alias-label-primary)"
213
509
  },
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
+ },
214
528
  host: {
215
529
  position: "relative",
216
530
  flex: "1",
@@ -249,6 +563,17 @@ window.__ModuleLoader__.load({
249
563
  }
250
564
  };
251
565
 
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
+
252
577
  /**
253
578
  * Keeps a rendering failure inside this panel. DSH has no boundary of its own
254
579
  * around slot entries: an escaping error unmounts the root slot, which is the
@@ -283,24 +608,32 @@ window.__ModuleLoader__.load({
283
608
  }
284
609
 
285
610
  /**
286
- * The global chat panel: a guest webview filling the main column.
611
+ * The global chat panel: an account strip and a guest webview filling the
612
+ * main column.
287
613
  *
288
614
  * The guest is created only after the lease resolves, and it is mounted with
289
615
  * `name`/`partition`/`src` already set, because the main process inspects
290
- * exactly that bootstrap src when it approves the attachment. The first load
291
- * goes to the remembered conversation; navigation events keep that memory
292
- * current. A dead guest (crash, destroy, failed start) turns into a retry card
293
- * inside the panel; cleanup releases the lease and never calls into a detached
294
- * element.
295
- * @returns the panel element tree.
616
+ * 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.
296
620
  */
297
621
  function ChatPanel() {
298
622
  const hostRef = react.useRef(null);
299
623
  const lastUrlRef = react.useRef(void 0);
624
+ const [account, setAccount] = react.useState(accountSnapshot);
300
625
  const [failure, setFailure] = react.useState(void 0);
301
626
  const [loading, setLoading] = react.useState(true);
302
627
  const [attempt, setAttempt] = react.useState(0);
303
628
 
629
+ react.useEffect(() => subscribeAccount((next) => {
630
+ try {
631
+ setAccount(next);
632
+ } catch {
633
+ /* the panel is gone */
634
+ }
635
+ }), []);
636
+
304
637
  const retry = react.useCallback(() => {
305
638
  try {
306
639
  setFailure(void 0);
@@ -325,20 +658,41 @@ window.__ModuleLoader__.load({
325
658
  return;
326
659
  }
327
660
 
661
+ // Both identities are constants. The desktop shell caches one
662
+ // session per workspace string; keeping them fixed is what makes
663
+ // switching panels/sessions preserve the chat login.
664
+ const storageKey = storageKeyFor();
665
+ const partitionKey = partitionKeyFor();
328
666
  let disposed = false;
667
+ let failed = false;
329
668
  let unsubscribeOpen;
330
669
  let lease;
331
670
  let watchdog;
332
671
  const element = document.createElement("webview");
333
- const initialTarget = rememberedTarget();
672
+ const initialTarget = rememberedTarget(storageKey);
334
673
  let restoring = initialTarget !== CHAT_URL;
335
674
 
336
675
  Object.assign(element.style, styles.guest);
337
676
  element.setAttribute("allowpopups", "");
338
677
 
339
- /** Report a guest failure inside the panel; never throw. */
678
+ /**
679
+ * Report a guest failure inside the panel; never throw. The dead
680
+ * guest element is removed immediately so a Retry cannot stack a
681
+ * second webview, and later events are suppressed. The lease is
682
+ * still released by the effect cleanup, exactly once.
683
+ */
340
684
  const fail = (error) => {
341
- if (disposed) return;
685
+ if (disposed || failed) return;
686
+ failed = true;
687
+ try {
688
+ element.remove();
689
+ } catch {
690
+ /* already detached */
691
+ }
692
+ if (watchdog !== void 0) {
693
+ clearTimeout(watchdog);
694
+ watchdog = void 0;
695
+ }
342
696
  try {
343
697
  setLoading(false);
344
698
  setFailure(String((error && error.message) || error));
@@ -351,11 +705,11 @@ window.__ModuleLoader__.load({
351
705
  const remember = (raw) => {
352
706
  if (raw === void 0) return;
353
707
  lastUrlRef.current = raw;
354
- rememberTarget(raw);
708
+ rememberTarget(raw, storageKey);
355
709
  };
356
710
 
357
711
  const onDomReady = () => {
358
- if (disposed) return;
712
+ if (disposed || failed) return;
359
713
  if (watchdog !== void 0) {
360
714
  clearTimeout(watchdog);
361
715
  watchdog = void 0;
@@ -364,7 +718,7 @@ window.__ModuleLoader__.load({
364
718
  else remember(initialTarget);
365
719
  };
366
720
  const onStopLoading = () => {
367
- if (disposed) return;
721
+ if (disposed || failed) return;
368
722
  const raw = readUrl(element);
369
723
  if (raw === void 0) return;
370
724
  remember(raw);
@@ -376,15 +730,15 @@ window.__ModuleLoader__.load({
376
730
  }
377
731
  };
378
732
  const onNavigate = () => {
379
- if (disposed) return;
733
+ if (disposed || failed) return;
380
734
  remember(readUrl(element));
381
735
  };
382
736
  const onNavigateInPage = (event) => {
383
- if (disposed || event?.isMainFrame !== true) return;
737
+ if (disposed || failed || event?.isMainFrame !== true) return;
384
738
  onNavigate();
385
739
  };
386
740
  const onFailLoad = (event) => {
387
- if (disposed || event?.isMainFrame !== true) return;
741
+ if (disposed || failed || event?.isMainFrame !== true) return;
388
742
  // -3 is ERR_ABORTED: a superseded navigation, not a failure.
389
743
  if (event.errorCode === -3) return;
390
744
  if (restoring) {
@@ -398,7 +752,7 @@ window.__ModuleLoader__.load({
398
752
  };
399
753
  /** The guest process exited or the guest was torn down underneath us. */
400
754
  const onGuestGone = () => {
401
- if (disposed) return;
755
+ if (disposed || failed) return;
402
756
  fail(new Error(COPY.crashed));
403
757
  };
404
758
 
@@ -412,18 +766,22 @@ window.__ModuleLoader__.load({
412
766
  ["destroyed", onGuestGone]
413
767
  ];
414
768
 
769
+ // A listener that cannot be attached leaves a guest nobody can
770
+ // drive; abort the start instead of mounting a silent webview.
771
+ let listenersReady = false;
415
772
  try {
416
773
  for (const [name, handler] of listeners) {
417
774
  element.addEventListener(name, handler, name === "dom-ready" ? { once: true } : void 0);
418
775
  }
776
+ listenersReady = true;
419
777
  } catch (error) {
420
778
  fail(error);
421
779
  }
422
780
 
423
781
  const start = async () => {
424
782
  try {
425
- const reservation = await bridge.acquire(PARTITION_KEY);
426
- if (disposed) {
783
+ const reservation = await bridge.acquire(partitionKey);
784
+ if (disposed || failed) {
427
785
  try {
428
786
  Promise.resolve(bridge.release(reservation.lease)).catch(() => {});
429
787
  } catch {
@@ -436,8 +794,10 @@ window.__ModuleLoader__.load({
436
794
  element.setAttribute("partition", reservation.partition);
437
795
  element.setAttribute("src", `about:blank#${reservation.lease}`);
438
796
  try {
439
- unsubscribeOpen = bridge.onOpenRequested(reservation.lease, (url) => {
440
- if (!disposed && typeof url === "string") navigate(element, url);
797
+ unsubscribeOpen = bridge.onOpenRequested(reservation.lease, (raw) => {
798
+ if (disposed || failed || typeof raw !== "string") return;
799
+ const target = parsePopupUrl(raw);
800
+ if (target !== void 0) navigate(element, target);
441
801
  });
442
802
  } catch {
443
803
  /* popup bridge unavailable: the guest still works */
@@ -445,14 +805,14 @@ window.__ModuleLoader__.load({
445
805
  host.append(element);
446
806
  watchdog = setTimeout(() => {
447
807
  watchdog = void 0;
448
- if (disposed) return;
808
+ if (disposed || failed) return;
449
809
  if (readUrl(element) === void 0) fail(new Error(COPY.timeout));
450
810
  }, 15000);
451
811
  } catch (error) {
452
812
  fail(error);
453
813
  }
454
814
  };
455
- void start();
815
+ if (listenersReady) void start();
456
816
 
457
817
  return () => {
458
818
  disposed = true;
@@ -492,7 +852,12 @@ window.__ModuleLoader__.load({
492
852
  };
493
853
  }, [attempt]);
494
854
 
855
+ const copy = accountCopy(account);
856
+
495
857
  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)),
496
861
  react.createElement("div", { style: styles.host, ref: hostRef },
497
862
  failure === void 0 && loading
498
863
  ? react.createElement("div", { style: styles.notice }, COPY.loading)
@@ -521,7 +886,21 @@ window.__ModuleLoader__.load({
521
886
  // The panel needs the desktop guest bridge; on the Web surface an iframe
522
887
  // cannot carry the site, so the plugin stays inert there.
523
888
  if (globalThis.dshDesktop?.browser === void 0) return;
889
+ } catch {
890
+ return;
891
+ }
524
892
 
893
+ try {
894
+ startAccountSync(ctx);
895
+ } catch (error) {
896
+ try {
897
+ console.error("dsh-plugin-deepseek-chat: account sync failed", error);
898
+ } catch {
899
+ /* console unavailable */
900
+ }
901
+ }
902
+
903
+ try {
525
904
  ctx.slots.inject("main", () => ctx.slots.register({
526
905
  name: "main",
527
906
  key: PANEL_ID
@@ -544,6 +923,26 @@ window.__ModuleLoader__.load({
544
923
 
545
924
  exports.apply = apply;
546
925
  exports.inject = inject;
926
+ exports.__test__ = {
927
+ ACCOUNT_UNKNOWN,
928
+ ACCOUNT_SIGNED_IN,
929
+ ACCOUNT_SIGNED_OUT,
930
+ CHAT_URL,
931
+ STORAGE_BASE,
932
+ accountCopy,
933
+ getSnapshot: () => accountSnapshot,
934
+ partitionKeyFor,
935
+ 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
+ }
945
+ };
547
946
  return module.exports;
548
947
  }
549
948
  });