dsh-focus-overlay 0.10.0 → 0.11.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.
Files changed (4) hide show
  1. package/README.en.md +48 -34
  2. package/README.md +50 -35
  3. package/lib/client.js +1370 -377
  4. package/package.json +10 -4
package/lib/client.js CHANGED
@@ -7,6 +7,69 @@ window.__ModuleLoader__.load({
7
7
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
8
8
  let react_jsx_runtime = require("react/jsx-runtime");
9
9
  //#region src/client/model.ts
10
+ function legacySliceOf(chatView, snap) {
11
+ const legacy = chatView && chatView.legacy ? chatView.legacy : null;
12
+ return {
13
+ nodes: legacy && legacy.nodes || snap && snap.nodes || [],
14
+ partial: legacy ? legacy.partial ?? null : snap && snap.partial || null,
15
+ runningCalls: legacy && legacy.runningCalls || snap && snap.runningCalls || []
16
+ };
17
+ }
18
+ /** The current session's pending user interaction (question / plan review /
19
+ * approval) from the uiSession pending map — dsh 0.1.2 replaced the session
20
+ * snapshot's flat `pending` array with this service-owned map. Null when the
21
+ * map (or the interaction for this session) is absent. */
22
+ function pendingInteractionOf(map, sessionId) {
23
+ try {
24
+ if (map && typeof map.get === "function") return map.get(sessionId) || null;
25
+ } catch {}
26
+ return null;
27
+ }
28
+ /** Legacy pending interaction — pre-0.1.2 dsh carried a flat `pending` array
29
+ * on the session snapshot, with a different carrier shape (`payload`
30
+ * envelope, answers via `wait.respond({ok, value})` returning a `receipt`).
31
+ * This adapter reshapes the first legacy item into the 0.1.2 carrier face the
32
+ * overlay's answer card renders (kind / questions / reason / toolName) and
33
+ * shims `wait.answer(...)` onto `wait.respond(...)`, throwing a rejected-
34
+ * marker error when the legacy receipt says the answer was refused. Null
35
+ * when nothing is pending or no legacy carrier exists. */
36
+ function legacyPendingOf(snap) {
37
+ const old = (snap && Array.isArray(snap.pending) ? snap.pending : [])[0];
38
+ if (!old) return null;
39
+ const payload = old.payload || {};
40
+ const kind = old.kind === "question" ? "question" : "approval";
41
+ const shaped = {
42
+ ...old,
43
+ kind,
44
+ questions: Array.isArray(payload.questions) ? payload.questions : [],
45
+ toolName: payload.toolName,
46
+ reason: payload.reason ?? payload.detail,
47
+ approvalId: payload.approvalId
48
+ };
49
+ if (typeof old.respond === "function" && typeof old.answer !== "function") shaped.answer = (answerPayload) => Promise.resolve(old.respond(kind === "approval" ? {
50
+ ok: true,
51
+ value: {
52
+ sessionId: old.sessionId,
53
+ approvalId: payload.approvalId,
54
+ outcome: answerPayload
55
+ }
56
+ } : {
57
+ ok: true,
58
+ value: {
59
+ sessionId: old.sessionId,
60
+ answer: answerPayload
61
+ }
62
+ })).then((receipt) => {
63
+ if (receipt && receipt.accepted === false) {
64
+ const err = new Error(receipt.reason || "rejected");
65
+ err.rejected = true;
66
+ err.reason = receipt.reason;
67
+ throw err;
68
+ }
69
+ return receipt;
70
+ });
71
+ return shaped;
72
+ }
10
73
  function flattenText(content) {
11
74
  if (!content || !content.length) return "";
12
75
  let out = "";
@@ -100,7 +163,120 @@ window.__ModuleLoader__.load({
100
163
  for (const b of blocks || []) if (b && (b.kind === "text" || b.kind === "image")) return true;
101
164
  return false;
102
165
  }
103
- function buildItems(nodes, runningCalls, t) {
166
+ /** Assemble the official TurnProcess disclosure label: "N 次工具调用 · M 条消息
167
+ * · K 个 subagent", falling back to "已思考" when the segment has nothing
168
+ * countable — mirroring the official `message.turnProcess.*` assembly. */
169
+ function processLabel(counts, t) {
170
+ const labels = [];
171
+ if (counts.tools > 0) labels.push(t("process.toolCalls", { n: counts.tools }));
172
+ if (counts.messages > 0) labels.push(t("process.messages", { n: counts.messages }));
173
+ if (counts.subagents > 0) labels.push(t("process.subagents", { n: counts.subagents }));
174
+ return labels.length === 0 ? t("process.thought") : labels.join(t("process.separator"));
175
+ }
176
+ function buildItems(nodes, runningCalls, t, transcriptView = "compact", stable = true) {
177
+ if (transcriptView === "normal") return segmentItems(nodes, runningCalls, t);
178
+ const buckets = [];
179
+ for (const n of nodes) {
180
+ if (!n) continue;
181
+ if (n.kind === "user") buckets.push([n]);
182
+ else if (buckets.length === 0) buckets.push([n]);
183
+ else buckets[buckets.length - 1].push(n);
184
+ }
185
+ if (buckets.length === 0) return segmentItems(nodes, runningCalls, t);
186
+ const items = [];
187
+ let turn = 0;
188
+ for (let b = 0; b < buckets.length; b++) {
189
+ const bucket = buckets[b];
190
+ const isLast = b === buckets.length - 1;
191
+ const head = bucket[0];
192
+ if (head.kind !== "user") {
193
+ items.push(...segmentItems(bucket, isLast ? runningCalls : [], t));
194
+ continue;
195
+ }
196
+ const headText = flattenText(head.content);
197
+ if (headText.trim() !== "") items.push({
198
+ kind: "user",
199
+ text: headText,
200
+ seq: head.seq
201
+ });
202
+ const body = bucket.slice(1);
203
+ let closingIdx = -1;
204
+ for (let k = body.length - 1; k >= 0; k--) {
205
+ const n = body[k];
206
+ if (n && n.kind === "assistant" && hasAssistantContent(n.blocks)) {
207
+ closingIdx = k;
208
+ break;
209
+ }
210
+ }
211
+ if (!(closingIdx >= 0 && closingIdx === body.length - 1 && (stable || !isLast))) {
212
+ items.push(...segmentItems(body, isLast ? runningCalls : [], t));
213
+ continue;
214
+ }
215
+ const closing = body[closingIdx];
216
+ let workNodes = [];
217
+ const flushWork = () => {
218
+ if (workNodes.length > 0) {
219
+ items.push({
220
+ kind: "turnProcess",
221
+ turn: ++turn,
222
+ seq: head.seq,
223
+ collapsed: true,
224
+ counts: countProcess(workNodes),
225
+ workItems: segmentItems(workNodes, [], t)
226
+ });
227
+ workNodes = [];
228
+ }
229
+ };
230
+ for (let k = 0; k < closingIdx; k++) {
231
+ const n = body[k];
232
+ if (n.kind === "steering") {
233
+ flushWork();
234
+ if (flattenText(n.content).trim() !== "") items.push({
235
+ kind: "steering",
236
+ text: flattenText(n.content),
237
+ seq: n.seq
238
+ });
239
+ continue;
240
+ }
241
+ workNodes.push(n);
242
+ }
243
+ flushWork();
244
+ items.push({
245
+ kind: "assistant",
246
+ blocks: closing.blocks,
247
+ seq: closing.seq,
248
+ turn: closing.turn,
249
+ step: closing.step
250
+ });
251
+ }
252
+ return items;
253
+ }
254
+ /** Per-turn process counts for the official disclosure label. */
255
+ function countProcess(workNodes) {
256
+ const metrics = {};
257
+ let assistantMessages = 0;
258
+ for (const n of workNodes) {
259
+ if (!n) continue;
260
+ if (n.kind === "assistant") {
261
+ if (hasAssistantContent(n.blocks)) assistantMessages++;
262
+ continue;
263
+ }
264
+ if (n.kind === "context" || n.kind === "compaction") {
265
+ metrics.context = (metrics.context || 0) + 1;
266
+ continue;
267
+ }
268
+ addMetric(metrics, n.call && n.call.name ? n.call.name : n.name || "");
269
+ }
270
+ return {
271
+ tools: (metrics.commands || 0) + (metrics.edits || 0) + (metrics.searches || 0) + (metrics.files || 0) + (metrics.dirs || 0) + (metrics.todos || 0) + (metrics.goals || 0) + (metrics.workflows || 0) + (metrics.skills || 0) + (metrics.questions || 0) + (metrics.plans || 0) + (metrics.jobs || 0) + (metrics.others || 0),
272
+ messages: (metrics.context || 0) + assistantMessages,
273
+ subagents: metrics.subagents || 0
274
+ };
275
+ }
276
+ /** The classic (pre-0.1.2) segmentation: content rows with tool/context runs
277
+ * folded into metric summary lines. This IS the normal-mode presentation,
278
+ * and the expanded work-part presentation in compact mode. */
279
+ function segmentItems(nodes, runningCalls, t) {
104
280
  const items = [];
105
281
  let metrics = {};
106
282
  const flush = () => {
@@ -180,10 +356,11 @@ window.__ModuleLoader__.load({
180
356
  return items;
181
357
  }
182
358
  function resolveAnchorSeq(chat, key) {
183
- if (!key || !chat || !chat.nodes) return null;
359
+ if (!key || !chat) return null;
184
360
  try {
185
- const node = chat.nodes.get(key);
361
+ const node = typeof chat.get === "function" ? chat.get(key) : chat.nodes ? chat.nodes.get(key) : null;
186
362
  if (!node) return null;
363
+ if (typeof node.anchorSeq === "number") return node.anchorSeq;
187
364
  const d = node.data || node.node || node;
188
365
  return d && typeof d.seq === "number" ? d.seq : null;
189
366
  } catch {
@@ -197,6 +374,72 @@ window.__ModuleLoader__.load({
197
374
  }
198
375
  return -1;
199
376
  }
377
+ /**
378
+ * Decide the overlay's opening scroll from the entry intent, the item list
379
+ * assembled so far, and the entry deadline. Pure so the decision is
380
+ * unit-testable at the seam the effect actually drives:
381
+ *
382
+ * - `auto` — a just-finished turn (auto-focus): open at the question that
383
+ * started it (`seq` known from the turn edge, no key resolution needed).
384
+ * - `anchor` — a reading-position preserve: `seq` is resolved from the captured
385
+ * chat anchor key by the caller (null while the node store is not resolvable
386
+ * yet). The overlay scrolls to the ROW WITH THAT SEQ — never to a different
387
+ * row (a regression here used to read the key-less `seq` field off anchor
388
+ * entries and silently fall back to the newest message).
389
+ * - `lastUser` — position sync off / no anchor captured: open at the newest
390
+ * user prompt.
391
+ *
392
+ * Waiting rules (the 0.1.2 chat view materializes lazily AND pages history
393
+ * asynchronously, so neither `items` nor the resolved seq may exist at mount):
394
+ * - While the history loader is still paging (`hasMore`) we wait — pages
395
+ * PREPEND above any anchor, so scrolling mid-load would drift.
396
+ * - While the row list itself has not landed yet (`items` empty on the mount
397
+ * render, before the chat-view state delivers) we wait until the entry
398
+ * deadline instead of latching — latching on an empty list is what stranded
399
+ * the overlay at the first message on every entry.
400
+ * - Once rows HAVE landed but the target row is absent (blank prompt skipped
401
+ * by {@link buildItems}, steering-only session, …) the row list is final, so
402
+ * we fall back immediately to the classic behaviors — no pointless wait.
403
+ * - A pass never reports "done" without actually scrolling somewhere.
404
+ *
405
+ * A successful target scroll carries the resolved `seq` back (the caller arms
406
+ * its anti-drift corrector with it); fallback scrolls carry null.
407
+ */
408
+ function decideOpenScroll(o) {
409
+ if (o.hasMore) return { action: "wait" };
410
+ if (o.kind === "lastUser") {
411
+ const lastIdx = lastUserIndex(o.items);
412
+ if (lastIdx >= 0) return {
413
+ action: "scroll",
414
+ index: lastIdx,
415
+ seq: null
416
+ };
417
+ if (o.items.length === 0 && !o.deadlinePassed) return { action: "wait" };
418
+ return { action: "bottom" };
419
+ }
420
+ if (o.seq != null) {
421
+ const idx = findSeqIndex(o.items, o.seq);
422
+ if (idx >= 0) return {
423
+ action: "scroll",
424
+ index: idx,
425
+ seq: o.seq
426
+ };
427
+ if (o.items.length === 0 && !o.deadlinePassed) return { action: "wait" };
428
+ const lastIdx = lastUserIndex(o.items);
429
+ return lastIdx >= 0 ? {
430
+ action: "scroll",
431
+ index: lastIdx,
432
+ seq: null
433
+ } : { action: "bottom" };
434
+ }
435
+ if (!o.deadlinePassed) return { action: "wait" };
436
+ const lastIdx = lastUserIndex(o.items);
437
+ return lastIdx >= 0 ? {
438
+ action: "scroll",
439
+ index: lastIdx,
440
+ seq: null
441
+ } : { action: "bottom" };
442
+ }
200
443
  /** Seq of the message that started the latest turn: the last `user` node, or a
201
444
  * `steering` node as a fallback when no user message exists (e.g. a
202
445
  * command-only session). Returns null when neither is present. */
@@ -216,6 +459,18 @@ window.__ModuleLoader__.load({
216
459
  for (let i = items.length - 1; i >= 0; i--) if (items[i].kind === "user") return i;
217
460
  return -1;
218
461
  }
462
+ /** Seq of the newest prompt row (user OR steering) in an already-built focus
463
+ * list — the row a just-sent message occupies once it renders. Unlike
464
+ * {@link lastUserSeq} (raw nodes), this reads the rendered list, so a blank
465
+ * prompt or an unsent queue entry is already filtered out. Null when no
466
+ * prompt row exists. */
467
+ function lastPromptSeq(items) {
468
+ for (let i = items.length - 1; i >= 0; i--) {
469
+ const it = items[i];
470
+ if (it.kind === "user" || it.kind === "steering") return it.seq;
471
+ }
472
+ return null;
473
+ }
219
474
  /**
220
475
  * Decide whether a settled conversation snapshot (`running === false`) ends in
221
476
  * a *normal* completed reply, and which user message started it.
@@ -315,6 +570,18 @@ window.__ModuleLoader__.load({
315
570
  return prev;
316
571
  }
317
572
  /**
573
+ * Whether the overlay should scroll to uncover the just-sent prompt row. The
574
+ * send path arms the reveal only while the reader sits in the bottom zone
575
+ * (sending from up in history must never yank the view), and the reveal fires
576
+ * only once the newest prompt seq has advanced — the sent row has actually
577
+ * rendered; delivery can lag the click by a snapshot or two through queue
578
+ * adjudication. An unchanged seq (ordinary streaming snapshots, mount) or an
579
+ * unarmed state never scrolls.
580
+ */
581
+ function shouldRevealSentPrompt(o) {
582
+ return o.armed && o.nextSeq != null && o.nextSeq !== o.prevSeq;
583
+ }
584
+ /**
318
585
  * Index of the conversation-turn dot the nav rail should highlight, from live
319
586
  * scroll geometry. The rail is a position indicator, so the decision is pure
320
587
  * math over measured anchor tops (document order) plus the distance to the
@@ -576,8 +843,8 @@ window.__ModuleLoader__.load({
576
843
  }
577
844
  function AnswerCard(props) {
578
845
  const { t, width, wait, onClose } = props;
579
- const isQuestion = wait.kind === "question";
580
- const questions = isQuestion ? wait.payload && wait.payload.questions ? wait.payload.questions : [] : [];
846
+ const isQuestion = !(wait.kind === "approval");
847
+ const questions = isQuestion && Array.isArray(wait.questions) ? wait.questions : [];
581
848
  const [drafts, setDrafts] = (0, react.useState)({});
582
849
  const [busy, setBusy] = (0, react.useState)(false);
583
850
  const [error, setError] = (0, react.useState)(null);
@@ -617,17 +884,18 @@ window.__ModuleLoader__.load({
617
884
  }
618
885
  };
619
886
  });
620
- const respond = async (result) => {
887
+ const respond = async (p) => {
621
888
  setBusy(true);
622
889
  setError(null);
623
890
  try {
624
- const receipt = await wait.respond(result);
891
+ const receipt = await p;
625
892
  if (receipt && receipt.accepted === false) {
626
893
  setError(t("answer.rejected") + (receipt.reason ? `: ${receipt.reason}` : ""));
627
894
  setBusy(false);
628
895
  }
629
896
  } catch (err) {
630
- setError(err instanceof Error ? err.message : String(err));
897
+ const rej = err;
898
+ setError(rej && rej.rejected ? t("answer.rejected") + (rej.reason ? `: ${rej.reason}` : "") : err instanceof Error ? err.message : String(err));
631
899
  setBusy(false);
632
900
  }
633
901
  };
@@ -636,22 +904,9 @@ window.__ModuleLoader__.load({
636
904
  setError(t("answer.incomplete"));
637
905
  return;
638
906
  }
639
- respond({
640
- ok: true,
641
- value: {
642
- sessionId: wait.sessionId,
643
- answer: encodeAnswer(questions, drafts)
644
- }
645
- });
907
+ respond(wait.answer(encodeAnswer(questions, drafts)));
646
908
  };
647
- const decide = (outcome) => respond({
648
- ok: true,
649
- value: {
650
- sessionId: wait.sessionId,
651
- approvalId: wait.payload.approvalId,
652
- outcome
653
- }
654
- });
909
+ const decide = (outcome) => respond(wait.answer(outcome));
655
910
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
656
911
  className: "fm-card" + (isQuestion ? "" : " fm-card-approval"),
657
912
  style: { maxWidth: Math.min(width, 748) },
@@ -738,12 +993,12 @@ window.__ModuleLoader__.load({
738
993
  }, q.id);
739
994
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
740
995
  className: "fm-card-q",
741
- children: [wait.payload && wait.payload.reason ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
996
+ children: [wait.reason ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
742
997
  className: "fm-card-q-text",
743
- children: wait.payload.reason
744
- }) : null, wait.payload && wait.payload.toolName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
998
+ children: wait.reason
999
+ }) : null, wait.toolName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
745
1000
  className: "fm-card-detail",
746
- children: wait.payload.toolName
1001
+ children: wait.toolName
747
1002
  }) : null]
748
1003
  })
749
1004
  }),
@@ -884,47 +1139,560 @@ window.__ModuleLoader__.load({
884
1139
  });
885
1140
  }
886
1141
  //#endregion
887
- //#region src/client/FocusView.tsx
888
- let focusOn = false;
889
- let pendingAnchorKey = null;
890
- let pendingAutoSeq = null;
891
- let donePing = 0;
892
- const focusListeners = [];
893
- const notify = () => {
894
- for (const l of focusListeners) l();
1142
+ //#region src/client/styles.ts
1143
+ /** Package-owned stylesheet text (injected by the client apply, cleaned up on unload). */
1144
+ const FOCUS_CSS = `
1145
+ .fm-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);display:flex;flex-direction:column;pointer-events:auto;font-family:var(--dsw-font-family,sans-serif);outline:none}
1146
+ .fm-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));flex:0 0 auto}
1147
+ .fm-title{font-size:calc(14px + var(--dsh-content-font-delta,0px));font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1148
+ .fm-body-wrap{position:relative;flex:1 1 auto;display:flex;min-height:0}
1149
+ .fm-body{flex:1 1 auto;min-width:0;overflow-y:auto;padding:28px 28px 110px}
1150
+ .fm-inner{max-width:760px;margin:0 auto}
1151
+ .fm-msg{margin:0}
1152
+ .fm-user-msg{margin:0}
1153
+ .fm-inner>*{margin:var(--dsh-chat-flow-gap,16px) 0 0}
1154
+ .fm-inner>*:first-child{margin-top:0}
1155
+ .fm-user{background:var(--dsw-specific-bubble,#eaf2ff);color:var(--dsw-alias-label-primary,#1a1a1a);font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px));white-space:pre-wrap;word-break:break-word;border-radius:22px;padding:10px 16px;max-width:min(calc(var(--fm-content-width,748px) * .702),82%);margin-left:auto}
1156
+ .fm-steering{opacity:.85}
1157
+ .fm-assistant{word-break:break-word;font:var(--dsw-font-markdown-base,400 var(--dsh-content-font-size,14px)/calc(24px + var(--dsh-content-font-delta,0px)) var(--dsw-font-family,sans-serif))}
1158
+ .fm-image{max-width:100%;border-radius:8px;margin:8px 0}
1159
+ /* Classic process fold (normal mode, and the expanded work-part rows in
1160
+ compact): centered, muted, secondary typography. */
1161
+ .fm-hidden{text-align:center;color:var(--dsw-alias-label-secondary,#888);font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));padding:2px 0;user-select:none}
1162
+ /* Turn process disclosure (compact 对话显示) — replica of the official
1163
+ TurnProcessNodeView row: 33px button, bottom hairline, chevron that
1164
+ rotates from -90deg to 0 when open, 8px breathing room when closed. */
1165
+ .fm-process-root{box-sizing:border-box;border:none;border-bottom:.5px solid var(--dsw-alias-border-l2);width:100%;min-width:0;height:33px;color:var(--dsw-alias-label-secondary);cursor:pointer;text-align:left;background:0 0;align-items:center;padding:0 0 8px;display:flex;font:inherit}
1166
+ .fm-process-root:not([data-open]){margin-bottom:8px}
1167
+ .fm-process-label{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:14px;line-height:24px;overflow:hidden}
1168
+ .fm-process-chevron{width:16px;height:16px;color:var(--dsw-alias-label-tertiary);flex:none;margin-left:6px;transition:transform .1s;transform:rotate(-90deg)}
1169
+ .fm-process-root[data-open] .fm-process-chevron{transform:rotate(0)}
1170
+ @media (prefers-reduced-motion:reduce){.fm-process-chevron{transition:none}}
1171
+ .fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));white-space:pre-wrap}
1172
+ /* "正在工作" live line — the shimmer is a 1:1 replica of the official
1173
+ generating indicator in dsh-client-ui-conversation (the "Deep Diving"
1174
+ turn-status): brand-blue text (--dsw-static-deepseek-500) with a light
1175
+ highlight (--dsw-static-deepseek-200) sweeping through — gradient stops
1176
+ 0/40/50/60/100 on a 250% tile, background-position 100%→0, 1.8s linear,
1177
+ font --dsw-font-s-strong-14 (500 14px/22px). The official class name
1178
+ (Md3f7G_turnStatus) is a per-build CSS-modules hash, so reusing it would
1179
+ break silently on dsh updates — the declarations are copied verbatim onto
1180
+ our own class using the same theme tokens instead. The flanking short
1181
+ dashes are part of the same shimmer span, so the sweep covers dashes and
1182
+ label as one unit. Fallbacks: no background-clip:text → secondary gray
1183
+ label; prefers-reduced-motion → animation off, label stays solid
1184
+ deepseek-500 (the official base color). */
1185
+ .fm-running{text-align:center;user-select:none;animation:fm-up .18s ease}
1186
+ .fm-running-text{color:var(--dsw-alias-label-secondary,#888);font:500 var(--dsh-content-font-size,14px)/calc(22px + var(--dsh-content-font-delta,0px)) var(--dsw-font-family,sans-serif);white-space:nowrap}
1187
+ /* Dashes: separate spans with fixed margins instead of space characters — in
1188
+ CJK/Latin mixed runs the browser itemizes the two U+0020 spaces into
1189
+ different fonts (one Latin-narrow, one Han-wide), and the Han font's en
1190
+ dash glyph is left-biased, which made the two gaps visually unequal. The
1191
+ forced Latin-first stack renders a symmetric en dash; margins are exact. */
1192
+ .fm-running-dash{font-family:system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;margin:0 8px}
1193
+ @supports ((-webkit-background-clip:text) or (background-clip:text)){
1194
+ .fm-running-text{background:linear-gradient(90deg,var(--dsw-static-deepseek-500,#4176e6) 0%,var(--dsw-static-deepseek-500,#4176e6) 40%,var(--dsw-static-deepseek-200,#d3e2ff) 50%,var(--dsw-static-deepseek-500,#4176e6) 60%,var(--dsw-static-deepseek-500,#4176e6) 100%);color:#0000;-webkit-text-fill-color:transparent;background-position:100% 0;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;animation:fm-sweep 1.8s linear infinite}
1195
+ }
1196
+ @keyframes fm-sweep{to{background-position:0 0}}
1197
+ @media (prefers-reduced-motion:reduce){.fm-running{animation:none}.fm-running-text{background:0 0;color:var(--dsw-static-deepseek-500,#4176e6);-webkit-text-fill-color:currentColor;animation:none}}
1198
+ .fm-empty{color:var(--dsw-alias-label-secondary,#888);padding:48px 16px;text-align:center}
1199
+ .fm-reply-toast{position:absolute;left:50%;transform:translateX(-50%);bottom:84px;z-index:2;display:flex;align-items:center;gap:10px;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:100px;padding:8px 8px 8px 16px;box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));font-size:var(--dsh-content-font-size-secondary,13px)}
1200
+ .fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
1201
+ .fm-reply-toast-text{white-space:nowrap}
1202
+ /* ---- bottom dock: bar / pill / jump-to-bottom / answer card ----
1203
+ Visual language mirrors the official composer cards token-for-token
1204
+ (ui-conversation InputBar/QuestionComposer, ui-user-questions
1205
+ PlanReviewPanel): --dsw-specific-input-major surfaces, 20–22px radii,
1206
+ --dsw-shadow-lv2, the 34px round info-fill primary button, interactive-
1207
+ hover option tinting. */
1208
+ .fm-dock{position:absolute;left:50%;transform:translateX(-50%);bottom:18px;z-index:3;width:calc(100% - 96px);display:flex;justify-content:center;pointer-events:none}
1209
+ .fm-dock>*{pointer-events:auto;animation:fm-up .18s ease}
1210
+ .fm-bar{box-sizing:border-box;width:100%;background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));border-radius:22px;padding-top:10px;display:flex;flex-direction:column;font-size:calc(var(--dsh-content-font-size,14px) + 2px);line-height:calc(24px + var(--dsh-content-font-delta,0px))}
1211
+ .fm-bar-row{display:flex;align-items:flex-end;gap:12px;min-width:0;padding:2px 8px 6px}
1212
+ /* flex-end anchors the send button to the row's bottom edge, so a multi-line
1213
+ draft grows the textarea upward and the button's distance to the bar's
1214
+ bottom-right corner never moves. */
1215
+ .fm-bar-text{flex:1 1 auto;min-height:28px;max-height:120px;resize:none;color:inherit;caret-color:var(--dsw-alias-state-business-primary,var(--dsw-static-deepseek-500,#4176e6));background:0 0;border:none;outline:none;font:inherit;font-size:inherit;line-height:inherit;padding:4px 12px 0 16px}
1216
+ .fm-bar-text::placeholder{color:var(--dsw-alias-label-caption,#999);user-select:none}
1217
+ .fm-bar-note{margin:4px 12px 0;border-radius:8px;padding:4px 8px;font-size:12px;line-height:18px;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));color:var(--dsw-alias-label-secondary,#888)}
1218
+ .fm-bar-note-error{color:var(--dsw-alias-state-error-primary,#d23);word-break:break-word}
1219
+ .fm-bar-queue{flex:0 0 auto;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#888);background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));border-radius:999px;padding:2px 10px;margin-bottom:7px;white-space:nowrap}
1220
+ .fm-bar-send{width:34px;height:34px;display:grid;place-items:center;flex:none;cursor:pointer;border:none;border-radius:999px;background:var(--dsw-alias-button-info-fill,#4176e6);color:#fff;transform:translateY(-2px);transition:background-color .1s;padding:0}
1221
+ .fm-bar-send:hover:not(:disabled){background:var(--dsw-alias-button-info-hover,#3560c4)}
1222
+ .fm-bar-send:disabled{opacity:.4;cursor:default}
1223
+ .fm-pill{position:relative;display:grid;place-items:center;width:38px;height:38px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-secondary,#666);cursor:pointer;box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));transition:color .12s;padding:0}
1224
+ .fm-pill:hover{color:var(--dsw-alias-label-primary,#1a1a1a)}
1225
+ .fm-pill-dot{position:absolute;top:4px;right:4px;width:7px;height:7px;border-radius:999px;background:var(--dsw-alias-state-business-primary,var(--dsw-static-deepseek-500,#4176e6))}
1226
+ .fm-card{box-sizing:border-box;width:100%;background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));border-radius:20px;display:flex;flex-direction:column;max-height:min(60vh,520px);overflow:hidden;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
1227
+ .fm-card *{box-sizing:border-box}
1228
+ .fm-card-approval{border-color:var(--dsw-alias-state-warn-secondary,#e6a23c)}
1229
+ .fm-card-head{flex:0 0 auto;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;padding:20px 16px 0 24px}
1230
+ .fm-card-approval .fm-card-head{background:var(--dsw-alias-state-warn-tertiary,rgba(230,162,60,.12));color:var(--dsw-alias-state-warn-primary,#9a6b12);align-items:center;padding:10px 16px}
1231
+ .fm-card-heading{min-width:0}
1232
+ .fm-card-eyebrow{color:var(--dsw-alias-label-tertiary,#999);margin:0 0 5px;font-size:11px;line-height:16px}
1233
+ .fm-card-title{margin:0;font-size:16px;font-weight:500;line-height:22px}
1234
+ .fm-card-strip-title{font-size:13px;line-height:18px;flex:1;min-width:0}
1235
+ .fm-card-dot{background:var(--dsw-alias-state-warn-primary,#9a6b12);border-radius:50%;width:8px;height:8px;flex:none}
1236
+ .fm-card-close{width:24px;height:24px;display:grid;place-items:center;flex:none;cursor:pointer;border:none;border-radius:999px;background:0 0;color:var(--dsw-alias-label-tertiary,#999);padding:0}
1237
+ .fm-card-close:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));color:var(--dsw-alias-label-primary,#1a1a1a)}
1238
+ .fm-card-approval .fm-card-close,.fm-card-approval .fm-card-close:hover:not(:disabled){color:inherit}
1239
+ .fm-card-body{overscroll-behavior:contain;flex:1 1 auto;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding-bottom:8px;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px))}
1240
+ .fm-card-q{display:flex;flex-direction:column}
1241
+ .fm-card-q-text{margin:0 2px 8px;padding:0 22px;white-space:pre-wrap;word-break:break-word;font-size:calc(var(--dsh-content-font-size,14px) + 2px);font-weight:500;line-height:calc(22px + var(--dsh-content-font-delta,0px))}
1242
+ .fm-card-detail{color:var(--dsw-alias-label-secondary,#888);margin:0 2px 8px;padding:0 22px;font-size:var(--dsh-content-font-size,14px);line-height:calc(22px + var(--dsh-content-font-delta,0px))}
1243
+ .fm-card-opts{display:flex;flex-direction:column;gap:1px;margin:8px 0 0;padding:4px 12px}
1244
+ .fm-opt{width:100%;min-height:40px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:12px;flex-shrink:0;display:flex;align-items:flex-start;gap:8px;padding:8px 12px 8px 8px;transition:background-color .12s,border-color .12s;font:inherit}
1245
+ .fm-opt:hover:not(:disabled),.fm-opt-on{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}
1246
+ .fm-opt-on{border-color:var(--dsw-alias-border-l2,rgba(0,0,0,.18))}
1247
+ .fm-opt-num{background:var(--dsw-alias-bg-overlay,#fff);width:20px;height:20px;color:var(--dsw-alias-label-secondary,#888);border-radius:6px;flex:0 0 20px;display:grid;place-items:center;margin-top:2px;font-size:12px;font-weight:500;line-height:18px}
1248
+ .fm-opt-check{flex:0 0 20px;width:20px;height:20px;margin-top:2px;display:grid;place-items:center;position:relative;color:var(--dsw-alias-label-primary-foreground,#fff)}
1249
+ .fm-opt-check::before{content:'';border:1px solid var(--dsw-alias-border-l4,rgba(0,0,0,.25));border-radius:4px;width:14px;height:14px;grid-area:1/1;transition:background-color .12s,border-color .12s}
1250
+ .fm-opt-check-on::before{border-color:var(--dsw-alias-label-primary,#1a1a1a);background:var(--dsw-alias-label-primary,#1a1a1a)}
1251
+ .fm-opt-check>*{grid-area:1/1}
1252
+ .fm-opt-copy{flex:1;min-width:0}
1253
+ .fm-opt-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 6px}
1254
+ .fm-opt-label{font-size:var(--dsh-content-font-size,14px);font-weight:500;line-height:calc(24px + var(--dsh-content-font-delta,0px));word-break:break-word}
1255
+ .fm-opt-badge{background:var(--dsw-specific-sidebar-nav-item-active-accent,rgba(65,118,230,.12));color:var(--dsw-alias-button-info-fill,#4176e6);border-radius:6px;padding:0 4px;font-size:11px;font-weight:600;line-height:18px}
1256
+ .fm-opt-desc{color:var(--dsw-alias-label-tertiary,#999);font-size:var(--dsh-content-font-size,14px);line-height:calc(24px + var(--dsh-content-font-delta,0px));word-break:break-word}
1257
+ .fm-card-custom{margin:0 12px;border:1px solid #0000;border-radius:12px;flex-shrink:0;display:flex;align-items:center;min-height:40px;padding:8px 12px;transition:background-color .12s,border-color .12s}
1258
+ .fm-card-custom:hover,.fm-card-custom:focus-within{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}
1259
+ .fm-card-custom:focus-within{border-color:var(--dsw-alias-border-l2,rgba(0,0,0,.18))}
1260
+ .fm-card-field{min-width:0;width:100%;border:none;outline:none;background:0 0;color:inherit;font:inherit;font-size:var(--dsh-content-font-size,14px);line-height:calc(24px + var(--dsh-content-font-delta,0px));padding:0}
1261
+ .fm-card-field::placeholder{color:var(--dsw-alias-label-caption,#999)}
1262
+ .fm-card-foot{flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 16px 12px}
1263
+ .fm-card-err{flex:1 1 auto;min-height:16px;color:var(--dsw-alias-state-error-primary,#d23);font-size:11px;line-height:16px;word-break:break-word}
1264
+ .fm-card-actions{display:flex;align-items:center;gap:8px;flex:none}
1265
+ @keyframes fm-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
1266
+ .fm-plugin-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
1267
+ .fm-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed)}
1268
+ .fm-plugin-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
1269
+ .fm-plugin-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
1270
+ .fm-plugin-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
1271
+ .fm-plugin-card-headtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
1272
+ .fm-plugin-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
1273
+ .fm-plugin-card-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
1274
+ .fm-plugin-card-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
1275
+ .fm-plugin-card-chevron-open{transform:rotate(180deg)}
1276
+ .fm-plugin-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}
1277
+ .fm-plugin-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
1278
+ .fm-plugin-field+.fm-plugin-field{border-top:1px solid var(--dsw-alias-border-l2)}
1279
+ .fm-plugin-field-head{align-items:center;gap:8px;display:flex}
1280
+ .fm-plugin-field-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
1281
+ .fm-plugin-field-value{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
1282
+ .fm-plugin-field-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
1283
+ .fm-plugin-check{align-items:center;gap:8px;display:flex;cursor:pointer;user-select:none}
1284
+ .fm-plugin-check input[type="checkbox"]{width:16px;height:16px;margin:0;cursor:pointer;accent-color:var(--dsw-static-deepseek-500,#4176e6)}
1285
+ .fm-plugin-check-label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}
1286
+ .fm-plugin-range{width:100%;max-width:280px;cursor:pointer;accent-color:var(--dsw-static-deepseek-500,#4176e6)}
1287
+ /* DSH-better-sidebar compatibility: its panel host (top-right toggle cluster +
1288
+ right/bottom panels) is appended to document.body at z-index 40/45, above
1289
+ shell.overlay's z-20 layer, so this overlay cannot cover it. Hide the host
1290
+ while focus is on, and neutralize the #root layout push it applies when its
1291
+ panels are open. Selectors mirror better-sidebar's own layout.css exactly. */
1292
+ body[data-fm-focus] [data-dsh-panel-host]{display:none!important}
1293
+ body[data-fm-focus] #root{margin-right:0!important;width:100%!important}
1294
+ body[data-fm-focus] #root [data-dsh-frame] > [data-pane="conversation"],
1295
+ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!important}
1296
+ /* First-run onboarding (settings.onboarding step) — content inside the official
1297
+ Modal primitive; the primitive owns the mask/dialog chrome, these style our body. */
1298
+ .fm-onboard{width:min(600px,100%);padding:0}
1299
+ .fm-onboard-content{box-sizing:border-box;flex-direction:column;max-height:calc(100vh - 48px);padding:28px;display:flex;overflow-y:auto}
1300
+ .fm-onboard-title{color:var(--dsw-alias-label-primary);outline:none;margin:0;font-size:20px;font-weight:500;line-height:28px}
1301
+ .fm-onboard-intro{color:var(--dsw-alias-label-secondary);margin:8px 0 0;font-size:14px;line-height:22px}
1302
+ .fm-onboard-subtitle{color:var(--dsw-alias-label-primary);margin:20px 0 8px;font-size:14px;font-weight:600;line-height:22px}
1303
+ .fm-onboard-features{margin:0;padding:0 0 0 18px;list-style:disc;display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
1304
+ .fm-onboard-note{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:18px}
1305
+ .fm-onboard-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}
1306
+ @media (width<=560px){.fm-onboard-content{padding:24px}}
1307
+ `;
1308
+ const injectedStyles = /* @__PURE__ */ new Map();
1309
+ /** Inject a named package stylesheet once and return a dispose function. The
1310
+ * map keeps duplicates out across mount/unmount cycles; the dispose removes
1311
+ * the element so plugin unload (or the component's effect cleanup) never
1312
+ * leaks <style> nodes. */
1313
+ function injectStyle(id, css) {
1314
+ if (typeof document === "undefined") return () => {};
1315
+ let el = injectedStyles.get(id);
1316
+ if (!el) {
1317
+ el = document.createElement("style");
1318
+ el.setAttribute("data-plugin", "dsh-focus-overlay");
1319
+ el.setAttribute("data-fm-style", id);
1320
+ el.textContent = css;
1321
+ document.head.appendChild(el);
1322
+ injectedStyles.set(id, el);
1323
+ }
1324
+ const node = el;
1325
+ return () => {
1326
+ node.remove();
1327
+ injectedStyles.delete(id);
1328
+ };
1329
+ }
1330
+ //#endregion
1331
+ //#region src/client/TurnRail.tsx
1332
+ /**
1333
+ * TurnRail — a 1:1 replica of the official dsh-client-ui-chat TurnNavigator
1334
+ * (the conversation view's right-side turn rail), rebuilt for the focus
1335
+ * overlay because the official component is internal to the chat view module
1336
+ * and not reachable through any runtime module id. Markup, geometry, styling,
1337
+ * and motion mirror the official `TurnNavigator.module.css` + `TurnNavigator.js`:
1338
+ *
1339
+ * - fixed pitch between marks (TURN_SPACING_PX), rail insets, 24px scroll
1340
+ * fade bands via mask-image gradients at each scrollable end;
1341
+ * - the frame is vertically centered over the reading band, its height
1342
+ * clamped to `min(natural, band - 64px, 420px)` like the official frame;
1343
+ * - a hover/focus preview card (1-line prompt + 3-line response) that maps
1344
+ * the pointer's Y to the nearest mark — including while the inner scroller
1345
+ * has scrolled — and clamps itself inside the rail band;
1346
+ * - the active mark keeps itself in view while the pointer is elsewhere;
1347
+ * - enter/pulse keyframes and the prefers-reduced-motion opt-out.
1348
+ *
1349
+ * Differences imposed by the overlay context: every mark is "loaded" (the
1350
+ * overlay pages the full history in on open, so there are no unloaded marks
1351
+ * to render), and navigation scrolls the overlay's own scrollport instead of
1352
+ * the conversation view's DOM anchor.
1353
+ */
1354
+ /** Fixed pitch between neighbouring marks; overflow scrolls inside the frame. */
1355
+ const TURN_SPACING_PX = 10;
1356
+ /** Rail padding above the first mark and below the last one, per end. */
1357
+ const RAIL_INSET_PX = 6;
1358
+ /** Fade band the mask reserves at a scrollable end. */
1359
+ const FADE_PX = 24;
1360
+ const RAIL_CSS = `
1361
+ .fm-rail-slot{position:absolute;inset:0;pointer-events:none;z-index:2}
1362
+ .fm-rail{--turn-rail-band:calc(var(--dsh-conversation-viewport-height,100dvh) - var(--dsh-composer-height,152px));--turn-preview-height:100px;top:calc(var(--turn-rail-band) / 2);right:12px;width:28px;height:min(var(--turn-natural-height),max(0px,calc(var(--turn-rail-band) - 64px)),420px);cursor:pointer;pointer-events:auto;transition:height .22s cubic-bezier(.2,.8,.2,1);position:absolute;transform:translateY(-50%)}
1363
+ .fm-rail-scroller{overscroll-behavior:contain;scrollbar-width:none;position:absolute;inset:0;overflow-y:auto}
1364
+ .fm-rail-scroller::-webkit-scrollbar{display:none}
1365
+ .fm-rail-fade-top{mask-image:linear-gradient(#0000 0,#000 24px 100%)}
1366
+ .fm-rail-fade-bottom{mask-image:linear-gradient(#000 0 calc(100% - 24px),#0000 100%)}
1367
+ .fm-rail-fade-top.fm-rail-fade-bottom{mask-image:linear-gradient(#0000 0,#000 24px calc(100% - 24px),#0000 100%)}
1368
+ .fm-rail-marks{height:var(--turn-natural-height);position:relative}
1369
+ .fm-rail-mark-position{top:calc(var(--turn-natural-position) + var(--turn-rail-inset));height:10px;transition:top .22s cubic-bezier(.2,.8,.2,1);animation:.15s ease-out fm-turn-mark-enter;position:absolute;left:0;right:0;transform:translateY(-50%)}
1370
+ .fm-rail-mark{cursor:pointer;pointer-events:none;background:0 0;border:0;border-radius:8px;width:20px;padding:0;position:absolute;inset:0 0 0 auto}
1371
+ .fm-rail-mark:before{background:var(--dsw-alias-border-l4);content:"";border-radius:2px;width:12px;height:2px;transition:width .14s,background-color .14s;position:absolute;top:50%;right:0;transform:translateY(-50%)}
1372
+ .fm-rail-mark-preview:before{background:var(--dsw-alias-label-tertiary);width:18px}
1373
+ .fm-rail-mark-active:before{background:var(--dsw-alias-label-primary);width:20px}
1374
+ .fm-rail-mark:focus-visible:before{background:var(--dsw-alias-state-business-primary);width:20px}
1375
+ .fm-rail-mark:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
1376
+ .fm-rail-preview{top:clamp(0px,calc(var(--turn-natural-position) + var(--turn-rail-inset) - var(--turn-scroll-top,0px) - var(--turn-preview-height) / 2),calc(100% - var(--turn-preview-height)));box-sizing:border-box;width:min(300px,100cqw - 120px);max-height:var(--turn-preview-height);color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-1);box-shadow:var(--dsw-elevation-panel,0 4px 16px rgba(0,0,0,.12));pointer-events:none;border:0;border-radius:10px;padding:10px 12px;transition:top .14s cubic-bezier(.2,.8,.2,1);animation:.12s ease-out fm-turn-preview-enter;position:absolute;right:calc(100% + 10px);overflow:hidden}
1377
+ .fm-rail-preview-prompt,.fm-rail-preview-response{-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}
1378
+ .fm-rail-preview-prompt{font:var(--dsw-font-xs-strong-13,600 13px/18px var(--dsw-font-family,sans-serif));-webkit-line-clamp:1}
1379
+ .fm-rail-preview-response{color:var(--dsw-alias-label-caption);font:var(--dsw-font-xxs-12,400 12px/18px var(--dsw-font-family,sans-serif));-webkit-line-clamp:3;margin-top:4px}
1380
+ @keyframes fm-turn-mark-enter{0%{opacity:0}to{opacity:1}}
1381
+ @keyframes fm-turn-preview-enter{0%{opacity:0;transform:translate(4px)}to{opacity:1;transform:translate(0)}}
1382
+ @media (prefers-reduced-motion:reduce){.fm-rail,.fm-rail-scroller,.fm-rail-mark-position,.fm-rail-mark:before,.fm-rail-preview{scroll-behavior:auto;transition:none;animation:none}}
1383
+ `;
1384
+ function itemPosition(index) {
1385
+ return { "--turn-natural-position": `${index * TURN_SPACING_PX}px` };
1386
+ }
1387
+ function frameStyle(count, scrollTop) {
1388
+ return {
1389
+ "--turn-natural-height": `${(count - 1) * TURN_SPACING_PX + 2 * RAIL_INSET_PX}px`,
1390
+ "--turn-rail-inset": `${RAIL_INSET_PX}px`,
1391
+ "--turn-scroll-top": `${scrollTop}px`
1392
+ };
1393
+ }
1394
+ function itemAtPointer(items, frame, scrollTop, clientY) {
1395
+ const offset = clientY - frame.getBoundingClientRect().top + scrollTop - RAIL_INSET_PX;
1396
+ return items[Math.max(0, Math.min(items.length - 1, Math.round(offset / TURN_SPACING_PX)))];
1397
+ }
1398
+ const RAIL_AT_REST = {
1399
+ top: 0,
1400
+ canScrollUp: false,
1401
+ canScrollDown: false
895
1402
  };
896
- const focusStore = {
897
- get: () => focusOn,
898
- set: (v) => {
899
- focusOn = !!v;
900
- notify();
901
- },
902
- subscribe: (l) => {
903
- focusListeners.push(l);
1403
+ function railScrollState(scroller) {
1404
+ const top = scroller.scrollTop;
1405
+ return {
1406
+ top,
1407
+ canScrollUp: top > 1,
1408
+ canScrollDown: top < scroller.scrollHeight - scroller.clientHeight - 1
1409
+ };
1410
+ }
1411
+ function sameRailScrollState(left, right) {
1412
+ return left.top === right.top && left.canScrollUp === right.canScrollUp && left.canScrollDown === right.canScrollDown;
1413
+ }
1414
+ function TurnRail(props) {
1415
+ const { items, activeTurn, onNavigate, label, jumpLabel, turnLabel } = props;
1416
+ const [previewTurn, setPreviewTurn] = (0, react.useState)(null);
1417
+ const [scrollState, setScrollState] = (0, react.useState)(RAIL_AT_REST);
1418
+ const scrollerRef = (0, react.useRef)(null);
1419
+ const pointerInsideRef = (0, react.useRef)(false);
1420
+ const previewId = (0, react.useId)();
1421
+ (0, react.useEffect)(() => injectStyle("fm-turn-rail", RAIL_CSS), []);
1422
+ const syncScrollState = () => {
1423
+ const scroller = scrollerRef.current;
1424
+ if (scroller === null) return;
1425
+ const next = railScrollState(scroller);
1426
+ setScrollState((current) => sameRailScrollState(current, next) ? current : next);
1427
+ };
1428
+ (0, react.useEffect)(() => {
1429
+ const scroller = scrollerRef.current;
1430
+ if (scroller === null || typeof ResizeObserver === "undefined") return;
1431
+ const observer = new ResizeObserver(syncScrollState);
1432
+ observer.observe(scroller);
904
1433
  return () => {
905
- const i = focusListeners.indexOf(l);
906
- if (i >= 0) focusListeners.splice(i, 1);
1434
+ observer.disconnect();
907
1435
  };
908
- },
909
- captureChatAnchor: () => {
910
- pendingAnchorKey = null;
911
- try {
912
- const scrollport = document.querySelector("[data-conversation-scroll]");
913
- if (!scrollport) return;
914
- const vp = scrollport.getBoundingClientRect();
915
- const rows = scrollport.querySelectorAll("[data-chat-flow-kind=\"user\"], [data-chat-flow-kind=\"steering\"]");
916
- for (const row of rows) if (row.getBoundingClientRect().bottom > vp.top + 8) {
917
- pendingAnchorKey = row.dataset.chatAnchorKey || null;
918
- return;
919
- }
920
- } catch {}
921
- },
922
- consumeAnchorKey: () => {
923
- const k = pendingAnchorKey;
924
- pendingAnchorKey = null;
925
- return k;
926
- },
927
- setAutoAnchor: (seq) => {
1436
+ }, []);
1437
+ (0, react.useEffect)(() => {
1438
+ syncScrollState();
1439
+ }, [items.length]);
1440
+ (0, react.useEffect)(() => {
1441
+ const scroller = scrollerRef.current;
1442
+ const index = items.findIndex((item) => item.turn === activeTurn);
1443
+ if (scroller === null || index < 0 || pointerInsideRef.current) return;
1444
+ const markTop = index * TURN_SPACING_PX + RAIL_INSET_PX;
1445
+ const viewTop = scroller.scrollTop;
1446
+ const viewHeight = scroller.clientHeight;
1447
+ if (viewHeight <= 0 || markTop >= viewTop + FADE_PX && markTop <= viewTop + viewHeight - FADE_PX) return;
1448
+ const target = Math.max(0, markTop - viewHeight / 2);
1449
+ const reduced = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
1450
+ if (typeof scroller.scrollTo === "function") scroller.scrollTo({
1451
+ top: target,
1452
+ behavior: reduced ? "auto" : "smooth"
1453
+ });
1454
+ else scroller.scrollTop = target;
1455
+ syncScrollState();
1456
+ }, [activeTurn, items]);
1457
+ if (items.length < 2) return null;
1458
+ const previewIndex = items.findIndex((item) => item.turn === previewTurn);
1459
+ const preview = previewIndex < 0 ? void 0 : items[previewIndex];
1460
+ const previewPosition = previewIndex < 0 ? void 0 : itemPosition(previewIndex);
1461
+ const previewAtPointer = (event) => {
1462
+ const scrollTop = scrollerRef.current ? scrollerRef.current.scrollTop : 0;
1463
+ const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY);
1464
+ setPreviewTurn(item ? item.turn : null);
1465
+ };
1466
+ const navigateAtPointer = (event) => {
1467
+ const scrollTop = scrollerRef.current ? scrollerRef.current.scrollTop : 0;
1468
+ const item = itemAtPointer(items, event.currentTarget, scrollTop, event.clientY);
1469
+ if (item !== void 0) onNavigate(item);
1470
+ };
1471
+ const fadeClasses = ["fm-rail-scroller"];
1472
+ if (scrollState.canScrollUp) fadeClasses.push("fm-rail-fade-top");
1473
+ if (scrollState.canScrollDown) fadeClasses.push("fm-rail-fade-bottom");
1474
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1475
+ className: "fm-rail-slot",
1476
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("nav", {
1477
+ className: "fm-rail",
1478
+ style: frameStyle(items.length, scrollState.top),
1479
+ "aria-label": label,
1480
+ onClick: navigateAtPointer,
1481
+ onPointerMove: previewAtPointer,
1482
+ onPointerEnter: () => {
1483
+ pointerInsideRef.current = true;
1484
+ },
1485
+ onPointerLeave: () => {
1486
+ pointerInsideRef.current = false;
1487
+ setPreviewTurn(null);
1488
+ },
1489
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1490
+ ref: scrollerRef,
1491
+ className: fadeClasses.join(" "),
1492
+ onScroll: () => {
1493
+ syncScrollState();
1494
+ },
1495
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1496
+ className: "fm-rail-marks",
1497
+ children: items.map((item, index) => {
1498
+ const active = item.turn === activeTurn;
1499
+ const showingPreview = item.turn === previewTurn;
1500
+ const classes = ["fm-rail-mark"];
1501
+ if (active) classes.push("fm-rail-mark-active");
1502
+ else if (showingPreview) classes.push("fm-rail-mark-preview");
1503
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1504
+ className: "fm-rail-mark-position",
1505
+ style: itemPosition(index),
1506
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1507
+ type: "button",
1508
+ className: classes.join(" "),
1509
+ "aria-label": jumpLabel(item.turn),
1510
+ "aria-current": active ? "true" : void 0,
1511
+ "aria-describedby": showingPreview ? previewId : void 0,
1512
+ onClick: (event) => {
1513
+ event.stopPropagation();
1514
+ onNavigate(item);
1515
+ },
1516
+ onFocus: () => {
1517
+ setPreviewTurn(item.turn);
1518
+ },
1519
+ onBlur: () => {
1520
+ setPreviewTurn(null);
1521
+ }
1522
+ })
1523
+ }, item.turn);
1524
+ })
1525
+ })
1526
+ }), preview !== void 0 && previewPosition !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1527
+ id: previewId,
1528
+ role: "tooltip",
1529
+ className: "fm-rail-preview",
1530
+ style: previewPosition,
1531
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1532
+ className: "fm-rail-preview-prompt",
1533
+ children: preview.prompt || turnLabel(preview.turn)
1534
+ }), preview.response !== "" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1535
+ className: "fm-rail-preview-response",
1536
+ children: preview.response
1537
+ }) : null]
1538
+ }) : null]
1539
+ })
1540
+ });
1541
+ }
1542
+ //#endregion
1543
+ //#region src/client/WidthHandle.tsx
1544
+ /**
1545
+ * WidthHandle — a 1:1 replica of the official dsh-client-ui-conversation
1546
+ * transcript width handle (the drag strip beside the content column), rebuilt
1547
+ * for the focus overlay because the official component is internal to the
1548
+ * conversation module and not reachable through any runtime module id.
1549
+ *
1550
+ * Interaction model mirrors the official `WidthHandle` exactly:
1551
+ * - pointer capture on the strip; both sides write the ONE centered width, so
1552
+ * outward travel widens by 2× the pointer distance;
1553
+ * - rAF-throttled drag updates (live resize while the pointer moves);
1554
+ * - pointermove publishes the pointer's Y as a CSS variable so the glow
1555
+ * indicator rides it (48px falloff gradient, 3px core line);
1556
+ * - commit on release (only when the pointer actually moved), cancel on
1557
+ * pointer cancel / lost capture.
1558
+ *
1559
+ * Clamping constants mirror the official `resolveContentWidth`: a 640px floor
1560
+ * and a 176px edge budget per viewport (88px per side keeps the handles
1561
+ * placeable — a wider column would push its own handles off-screen).
1562
+ */
1563
+ const HANDLE_CSS = `
1564
+ .fm-width-handles{position:absolute;top:0;bottom:0;left:0;right:0;pointer-events:none}
1565
+ .fm-width-handle{z-index:8;width:min(40px,calc((100% - var(--fm-content-width,748px)) / 2 - 24px - 24px));cursor:col-resize;pointer-events:auto;position:absolute;top:0;bottom:0}
1566
+ .fm-width-handle[data-side=left]{right:calc(50% + var(--fm-content-width,748px) / 2 + 24px)}
1567
+ .fm-width-handle[data-side=right]{left:calc(50% + var(--fm-content-width,748px) / 2 + 24px)}
1568
+ .fm-width-handle:after{content:"";background:linear-gradient(to bottom,transparent calc(var(--dsh-width-handle-pointer-y,50%) - 52px),var(--dsw-alias-scrollbar-hover-l1,rgba(0,0,0,.18)) calc(var(--dsh-width-handle-pointer-y,50%) - 12px),var(--dsw-alias-scrollbar-hover-l1,rgba(0,0,0,.18)) calc(var(--dsh-width-handle-pointer-y,50%) + 12px),transparent calc(var(--dsh-width-handle-pointer-y,50%) + 52px));opacity:0;pointer-events:none;border-radius:3px;width:3px;position:absolute;top:0;bottom:0}
1569
+ .fm-width-handle[data-side=left]:after{right:16px}
1570
+ .fm-width-handle[data-side=right]:after{left:16px}
1571
+ .fm-width-handle:hover:after,.fm-width-handle[data-dragging]:after{opacity:1}
1572
+ @media (prefers-reduced-motion:reduce){.fm-width-handle:after{transition:none}}
1573
+ `;
1574
+ function WidthHandle(props) {
1575
+ const [dragging, setDragging] = (0, react.useState)(false);
1576
+ const base = (0, react.useRef)(0);
1577
+ const origin = (0, react.useRef)(0);
1578
+ const latest = (0, react.useRef)(0);
1579
+ const frame = (0, react.useRef)(null);
1580
+ const callbacks = (0, react.useRef)(props);
1581
+ callbacks.current = props;
1582
+ const outwardWidth = () => {
1583
+ const dx = latest.current - origin.current;
1584
+ const outward = callbacks.current.side === "right" ? dx : -dx;
1585
+ return base.current + outward * 2;
1586
+ };
1587
+ const cancelFrame = () => {
1588
+ if (frame.current !== null) {
1589
+ cancelAnimationFrame(frame.current);
1590
+ frame.current = null;
1591
+ }
1592
+ };
1593
+ const onPointerDown = (0, react.useCallback)((e) => {
1594
+ e.preventDefault();
1595
+ e.currentTarget.setPointerCapture(e.pointerId);
1596
+ origin.current = e.clientX;
1597
+ latest.current = e.clientX;
1598
+ base.current = callbacks.current.onStart();
1599
+ setDragging(true);
1600
+ }, []);
1601
+ const onPointerMove = (0, react.useCallback)((e) => {
1602
+ const box = e.currentTarget.getBoundingClientRect();
1603
+ e.currentTarget.style.setProperty("--dsh-width-handle-pointer-y", `${e.clientY - box.top}px`);
1604
+ if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
1605
+ latest.current = e.clientX;
1606
+ frame.current ??= requestAnimationFrame(() => {
1607
+ frame.current = null;
1608
+ callbacks.current.onDrag(outwardWidth());
1609
+ });
1610
+ }, []);
1611
+ const onPointerUp = (0, react.useCallback)((e) => {
1612
+ if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
1613
+ e.currentTarget.releasePointerCapture(e.pointerId);
1614
+ cancelFrame();
1615
+ latest.current = e.clientX;
1616
+ if (latest.current !== origin.current) callbacks.current.onCommit(outwardWidth());
1617
+ setDragging(false);
1618
+ callbacks.current.onEnd();
1619
+ }, []);
1620
+ const onPointerCancel = (0, react.useCallback)(() => {
1621
+ cancelFrame();
1622
+ setDragging(false);
1623
+ callbacks.current.onEnd();
1624
+ }, []);
1625
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1626
+ className: "fm-width-handle",
1627
+ "data-side": props.side,
1628
+ "data-dragging": dragging || void 0,
1629
+ style: { "--fm-content-width": `${props.width}px` },
1630
+ onPointerDown,
1631
+ onPointerMove,
1632
+ onPointerUp,
1633
+ onPointerCancel,
1634
+ onLostPointerCapture: onPointerCancel
1635
+ });
1636
+ }
1637
+ function WidthHandles(props) {
1638
+ (0, react.useEffect)(() => injectStyle("fm-width-handle", HANDLE_CSS), []);
1639
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1640
+ className: "fm-width-handles",
1641
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidthHandle, {
1642
+ ...props,
1643
+ side: "left"
1644
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidthHandle, {
1645
+ ...props,
1646
+ side: "right"
1647
+ })]
1648
+ });
1649
+ }
1650
+ //#endregion
1651
+ //#region src/client/FocusView.tsx
1652
+ let focusOn = false;
1653
+ let pendingAnchorKey = null;
1654
+ let pendingAutoSeq = null;
1655
+ let donePing = 0;
1656
+ const focusListeners = [];
1657
+ const notify = () => {
1658
+ for (const l of focusListeners) try {
1659
+ l();
1660
+ } catch (err) {
1661
+ console.error("[dsh-focus-overlay] focus listener failed", err);
1662
+ }
1663
+ };
1664
+ const focusStore = {
1665
+ get: () => focusOn,
1666
+ set: (v) => {
1667
+ focusOn = !!v;
1668
+ notify();
1669
+ },
1670
+ subscribe: (l) => {
1671
+ focusListeners.push(l);
1672
+ return () => {
1673
+ const i = focusListeners.indexOf(l);
1674
+ if (i >= 0) focusListeners.splice(i, 1);
1675
+ };
1676
+ },
1677
+ captureChatAnchor: () => {
1678
+ pendingAnchorKey = null;
1679
+ try {
1680
+ const scrollport = document.querySelector("[data-conversation-scroll]");
1681
+ if (!scrollport) return;
1682
+ const vp = scrollport.getBoundingClientRect();
1683
+ const rows = scrollport.querySelectorAll("[data-chat-flow-kind=\"user\"], [data-chat-flow-kind=\"steering\"]");
1684
+ for (const row of rows) if (row.getBoundingClientRect().bottom > vp.top + 8) {
1685
+ pendingAnchorKey = row.dataset.chatAnchorKey || null;
1686
+ return;
1687
+ }
1688
+ } catch {}
1689
+ },
1690
+ consumeAnchorKey: () => {
1691
+ const k = pendingAnchorKey;
1692
+ pendingAnchorKey = null;
1693
+ return k;
1694
+ },
1695
+ setAutoAnchor: (seq) => {
928
1696
  pendingAutoSeq = seq;
929
1697
  },
930
1698
  consumeAutoSeq: () => {
@@ -974,6 +1742,90 @@ window.__ModuleLoader__.load({
974
1742
  }, [sessions, sessionId]);
975
1743
  return snap;
976
1744
  }
1745
+ /** The session's pending user interaction (question / plan review / approval),
1746
+ * read from the uiSession pending-interactions map — dsh 0.1.2 moved the old
1747
+ * snapshot `pending` array into this root-level observable. When the service
1748
+ * is absent (older dsh), falls back to the session snapshot's flat `pending`
1749
+ * array through the `legacyPendingOf` adapter. Returns null when this session
1750
+ * has nothing pending. */
1751
+ function usePendingInteraction(uiSession, sessionId, snap) {
1752
+ const source = uiSession && uiSession.pendingInteractions;
1753
+ const read = () => {
1754
+ try {
1755
+ return source ? source.getSnapshot() : null;
1756
+ } catch {
1757
+ return null;
1758
+ }
1759
+ };
1760
+ const [map, setMap] = (0, react.useState)(read);
1761
+ (0, react.useEffect)(() => {
1762
+ if (!source) {
1763
+ setMap(null);
1764
+ return;
1765
+ }
1766
+ setMap(source.getSnapshot());
1767
+ try {
1768
+ return source.subscribe(() => setMap(source.getSnapshot()));
1769
+ } catch {
1770
+ return;
1771
+ }
1772
+ }, [source]);
1773
+ return pendingInteractionOf(map, sessionId) || legacyPendingOf(snap);
1774
+ }
1775
+ /** The session's Chat view snapshot (dsh 0.1.2 `ChatSnapshot`) from the
1776
+ * uiConversation assembly — the first subscriber activates the chat target,
1777
+ * so this hook must stay subscribed for the view to materialize its legacy
1778
+ * slice. Null when the service is absent (older dsh). */
1779
+ function useChatView(uiConversation, sessionId) {
1780
+ const [chat, setChat] = (0, react.useState)(null);
1781
+ (0, react.useEffect)(() => {
1782
+ if (!uiConversation || sessionId == null) {
1783
+ setChat(null);
1784
+ return;
1785
+ }
1786
+ let unsub = null;
1787
+ try {
1788
+ const source = uiConversation.binding(sessionId).target("chat");
1789
+ setChat(source.getSnapshot() || null);
1790
+ unsub = source.subscribe(() => setChat(source.getSnapshot() || null));
1791
+ } catch {
1792
+ setChat(null);
1793
+ }
1794
+ return () => {
1795
+ if (unsub) unsub();
1796
+ };
1797
+ }, [uiConversation, sessionId]);
1798
+ return chat;
1799
+ }
1800
+ /** The official "对话显示" preference (`ui-chat.transcriptView`, values
1801
+ * normal | compact), read reactively from the settings scope the official
1802
+ * TranscriptViewPolicy binds. Defaults to compact — the official default —
1803
+ * when the scope or the section has not arrived yet. */
1804
+ function useTranscriptViewMode(settingsScope) {
1805
+ const source = settingsScope;
1806
+ const read = () => {
1807
+ try {
1808
+ const section = source ? source.getSnapshot().value : null;
1809
+ return (section ? section.transcriptView : null) === "normal" ? "normal" : "compact";
1810
+ } catch {
1811
+ return "compact";
1812
+ }
1813
+ };
1814
+ const [mode, setMode] = (0, react.useState)(read);
1815
+ (0, react.useEffect)(() => {
1816
+ if (!source) {
1817
+ setMode("compact");
1818
+ return;
1819
+ }
1820
+ setMode(read());
1821
+ try {
1822
+ return source.subscribe(() => setMode(read()));
1823
+ } catch {
1824
+ return;
1825
+ }
1826
+ }, [source]);
1827
+ return mode;
1828
+ }
977
1829
  function SessionImage({ attachment, loadImage }) {
978
1830
  const [src, setSrc] = (0, react.useState)(null);
979
1831
  (0, react.useEffect)(() => {
@@ -992,13 +1844,28 @@ window.__ModuleLoader__.load({
992
1844
  alt: ""
993
1845
  });
994
1846
  }
995
- function AssistantItem({ blocks, loadImage, fileMentions }) {
1847
+ /** Labels the official MarkdownText requires for its code-block copy buttons
1848
+ * and footnotes section — mirrors the official `markdownLabels(t)` shape.
1849
+ * Without it the renderer throws `Cannot read properties of undefined
1850
+ * (reading 'code')` on the first fenced code block. */
1851
+ function markdownLabels(t) {
1852
+ return {
1853
+ code: {
1854
+ copyLabel: t("markdown.code.copy"),
1855
+ copiedLabel: t("markdown.code.copied")
1856
+ },
1857
+ footnotes: t("markdown.footnotes")
1858
+ };
1859
+ }
1860
+ function AssistantItem(props) {
1861
+ const { blocks, loadImage, fileMentions, labels } = props;
996
1862
  const text = (blocks || []).filter((b) => b && b.kind === "text").map((b) => b.text).join("");
997
1863
  const images = (blocks || []).filter((b) => b && b.kind === "image");
998
1864
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
999
1865
  className: "fm-msg fm-assistant",
1000
1866
  children: [text ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
1001
1867
  text,
1868
+ labels,
1002
1869
  fileMentions
1003
1870
  }) : null, images.map((b, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionImage, {
1004
1871
  attachment: b.attachment,
@@ -1007,21 +1874,102 @@ window.__ModuleLoader__.load({
1007
1874
  });
1008
1875
  }
1009
1876
  function FocusContent(props) {
1010
- const { useSessions, sessions, workspaces, conversation, chatFileMentions, t } = props;
1877
+ const { useSessions, sessions, workspaces, conversation, chatFileMentions, uiSession, uiConversation, settingsScope, t } = props;
1011
1878
  const prefs = usePrefs();
1012
1879
  const listState = useSessions((s) => s);
1013
1880
  const currentId = listState ? listState.current : void 0;
1014
1881
  const snap = useSessionSnapshot(sessions, currentId);
1015
- const items = (0, react.useMemo)(() => snap ? buildItems(snap.nodes || [], snap.runningCalls, t) : [], [snap, t]);
1882
+ const chatView = useChatView(uiConversation, currentId);
1883
+ const transcriptView = useTranscriptViewMode(settingsScope);
1884
+ const legacy = (0, react.useMemo)(() => legacySliceOf(chatView, snap), [chatView, snap]);
1885
+ const stable = legacy.partial == null;
1886
+ const mdLabels = (0, react.useMemo)(() => markdownLabels(t), [t]);
1887
+ const items = (0, react.useMemo)(() => snap ? buildItems(legacy.nodes, legacy.runningCalls, t, transcriptView, stable) : [], [
1888
+ snap,
1889
+ legacy,
1890
+ t,
1891
+ transcriptView,
1892
+ stable
1893
+ ]);
1894
+ const [expanded, setExpanded] = (0, react.useState)(() => /* @__PURE__ */ new Set());
1895
+ (0, react.useEffect)(() => {
1896
+ setExpanded(/* @__PURE__ */ new Set());
1897
+ }, [transcriptView]);
1898
+ const wait = usePendingInteraction(uiSession, currentId, snap);
1899
+ const itemsRef = (0, react.useRef)(items);
1900
+ itemsRef.current = items;
1901
+ const snapRef = (0, react.useRef)(null);
1902
+ snapRef.current = snap;
1903
+ (0, react.useEffect)(() => {
1904
+ let alive = true;
1905
+ const b = sessions && currentId != null ? sessions.binding(currentId) : null;
1906
+ const face = b && b.session;
1907
+ if (!face || typeof face.loadOlder !== "function") return;
1908
+ const load = async () => {
1909
+ let pages = 0;
1910
+ while (alive && pages < 500) {
1911
+ const s = snapRef.current;
1912
+ if (!s || !s.hasMore || s.openState !== "open") break;
1913
+ if (s.loadingOlder) {
1914
+ await new Promise((r) => setTimeout(r, 60));
1915
+ continue;
1916
+ }
1917
+ try {
1918
+ await face.loadOlder();
1919
+ } catch {
1920
+ break;
1921
+ }
1922
+ pages++;
1923
+ await new Promise((r) => setTimeout(r, 0));
1924
+ }
1925
+ };
1926
+ load();
1927
+ return () => {
1928
+ alive = false;
1929
+ };
1930
+ }, [sessions, currentId]);
1931
+ const rail = (0, react.useMemo)(() => {
1932
+ if (!chatView || !chatView.navigation || !chatView.navigation.items) return [];
1933
+ const out = [];
1934
+ const used = /* @__PURE__ */ new Set();
1935
+ let navItems = [];
1936
+ try {
1937
+ navItems = chatView.navigation.items() || [];
1938
+ } catch {
1939
+ navItems = [];
1940
+ }
1941
+ for (const it of navItems) {
1942
+ if (!it) continue;
1943
+ let seq = null;
1944
+ try {
1945
+ const node = chatView.nodes && chatView.nodes.get ? chatView.nodes.get(it.anchorKey) : null;
1946
+ if (node && typeof node.anchorSeq === "number") seq = node.anchorSeq;
1947
+ } catch {}
1948
+ let idx = seq != null ? findSeqIndex(items, seq) : -1;
1949
+ if (idx < 0 && seq != null && Number.isFinite(seq)) for (let i = items.length - 1; i >= 0; i--) {
1950
+ const row = items[i];
1951
+ if ((row.kind === "user" || row.kind === "steering") && row.seq <= seq) {
1952
+ idx = i;
1953
+ break;
1954
+ }
1955
+ }
1956
+ if (idx < 0 || used.has(idx)) continue;
1957
+ used.add(idx);
1958
+ out.push({
1959
+ turn: it.turn,
1960
+ prompt: (it.prompt || "").replace(/\s+/g, " ").trim(),
1961
+ response: (it.response || "").replace(/\s+/g, " ").trim(),
1962
+ idx
1963
+ });
1964
+ }
1965
+ return out;
1966
+ }, [chatView, items]);
1016
1967
  const navKeys = [];
1017
1968
  const navPreviews = {};
1018
- for (let i = 0; i < items.length; i++) {
1019
- const it = items[i];
1020
- if (it.kind === "user" || it.kind === "steering") {
1021
- const k = "fm-" + i;
1022
- navKeys.push(k);
1023
- navPreviews[k] = it.text.replace(/\s+/g, " ").trim().slice(0, 80);
1024
- }
1969
+ for (const r of rail) {
1970
+ const k = "fm-" + r.idx;
1971
+ navKeys.push(k);
1972
+ navPreviews[k] = r.prompt;
1025
1973
  }
1026
1974
  const [activeKey, setActiveKey] = (0, react.useState)(navKeys.length ? navKeys[navKeys.length - 1] : null);
1027
1975
  const [zone, setZone] = (0, react.useState)(true);
@@ -1033,6 +1981,8 @@ window.__ModuleLoader__.load({
1033
1981
  const [localDraft, setLocalDraft] = (0, react.useState)("");
1034
1982
  const taRef = (0, react.useRef)(null);
1035
1983
  const focusBarOnce = (0, react.useRef)(false);
1984
+ const revealArmed = (0, react.useRef)(false);
1985
+ const seenPromptSeq = (0, react.useRef)(null);
1036
1986
  const computeActive = () => {
1037
1987
  if (!bodyEl) return null;
1038
1988
  const bodyTop = bodyEl.getBoundingClientRect().top;
@@ -1051,7 +2001,8 @@ window.__ModuleLoader__.load({
1051
2001
  if (rafId.current !== null) return;
1052
2002
  rafId.current = requestAnimationFrame(() => {
1053
2003
  rafId.current = null;
1054
- setActiveKey(computeActive());
2004
+ const c = computeActive();
2005
+ if (c !== null) setActiveKey(c);
1055
2006
  setZone((prev) => bottomZoneAfter(prev, bottomDistance()));
1056
2007
  });
1057
2008
  };
@@ -1074,47 +2025,98 @@ window.__ModuleLoader__.load({
1074
2025
  behavior: "smooth"
1075
2026
  });
1076
2027
  };
2028
+ const entryRef = (0, react.useRef)(void 0);
2029
+ const positionedRef = (0, react.useRef)(false);
2030
+ const positionDeadlineRef = (0, react.useRef)(0);
2031
+ const correctionRef = (0, react.useRef)(null);
1077
2032
  (0, react.useEffect)(() => {
2033
+ if (entryRef.current !== void 0) return;
2034
+ positionDeadlineRef.current = Date.now() + 6e3;
1078
2035
  const autoSeq = focusStore.consumeAutoSeq();
1079
2036
  if (autoSeq != null) {
1080
- const idx = findSeqIndex(items, autoSeq);
1081
- if (idx >= 0) {
1082
- scrollToKey("fm-" + idx, false);
1083
- setActiveKey("fm-" + idx);
1084
- } else if (bodyEl) {
1085
- bodyEl.scrollTo({
1086
- top: bodyEl.scrollHeight,
1087
- behavior: "auto"
1088
- });
1089
- setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
1090
- }
2037
+ entryRef.current = {
2038
+ kind: "auto",
2039
+ seq: autoSeq
2040
+ };
1091
2041
  return;
1092
2042
  }
1093
2043
  const anchorKey = focusStore.consumeAnchorKey();
1094
- const seq = resolveAnchorSeq(snap && snap.chat, anchorKey);
1095
- const idx = seq != null ? findSeqIndex(items, seq) : -1;
1096
- if (prefs.scroll === "preserve" && idx >= 0) {
1097
- scrollToKey("fm-" + idx, false);
1098
- setActiveKey("fm-" + idx);
1099
- } else if (prefs.scroll !== "preserve") {
1100
- const lastIdx = lastUserIndex(items);
1101
- if (lastIdx >= 0) {
1102
- scrollToKey("fm-" + lastIdx, false);
1103
- setActiveKey("fm-" + lastIdx);
1104
- } else if (bodyEl) {
1105
- bodyEl.scrollTo({
1106
- top: bodyEl.scrollHeight,
1107
- behavior: "auto"
1108
- });
1109
- setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
1110
- }
1111
- } else if (bodyEl) {
1112
- bodyEl.scrollTo({
1113
- top: bodyEl.scrollHeight,
1114
- behavior: "auto"
1115
- });
1116
- setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
2044
+ if (prefs.scroll === "preserve" && anchorKey) {
2045
+ entryRef.current = {
2046
+ kind: "anchor",
2047
+ key: anchorKey
2048
+ };
2049
+ return;
1117
2050
  }
2051
+ entryRef.current = { kind: "lastUser" };
2052
+ }, []);
2053
+ (0, react.useEffect)(() => {
2054
+ if (positionedRef.current || entryRef.current === void 0) return;
2055
+ const entry = entryRef.current;
2056
+ let seq = null;
2057
+ if (entry.kind === "auto") seq = entry.seq;
2058
+ else if (entry.kind === "anchor") {
2059
+ let nodes = chatView && chatView.nodes;
2060
+ if (!nodes && uiConversation && currentId != null) try {
2061
+ nodes = uiConversation.binding(currentId).target("chat").getSnapshot()?.nodes;
2062
+ } catch {}
2063
+ seq = resolveAnchorSeq(nodes, entry.key);
2064
+ }
2065
+ const decision = decideOpenScroll({
2066
+ kind: entry.kind,
2067
+ seq,
2068
+ items,
2069
+ hasMore: !!(snap && snap.hasMore),
2070
+ deadlinePassed: Date.now() >= positionDeadlineRef.current
2071
+ });
2072
+ if (decision.action === "wait") return;
2073
+ if (decision.action === "scroll") {
2074
+ scrollToKey("fm-" + decision.index, false);
2075
+ setActiveKey("fm-" + decision.index);
2076
+ const el = anchors["fm-" + decision.index];
2077
+ if (decision.seq != null && el && bodyEl) correctionRef.current = {
2078
+ seq: decision.seq,
2079
+ top: el.getBoundingClientRect().top - bodyEl.getBoundingClientRect().top + bodyEl.scrollTop
2080
+ };
2081
+ positionedRef.current = true;
2082
+ return;
2083
+ }
2084
+ if (bodyEl) bodyEl.scrollTo({
2085
+ top: bodyEl.scrollHeight,
2086
+ behavior: "auto"
2087
+ });
2088
+ setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
2089
+ positionedRef.current = true;
2090
+ }, [
2091
+ items,
2092
+ snap,
2093
+ chatView
2094
+ ]);
2095
+ (0, react.useEffect)(() => {
2096
+ const c = correctionRef.current;
2097
+ if (!c) return;
2098
+ let tries = 0;
2099
+ const timer = setInterval(() => {
2100
+ tries++;
2101
+ const items = itemsRef.current;
2102
+ const idx = findSeqIndex(items, c.seq);
2103
+ const el = idx >= 0 ? anchors["fm-" + idx] : void 0;
2104
+ if (!el || !bodyEl) {
2105
+ if (tries > 40) {
2106
+ clearInterval(timer);
2107
+ correctionRef.current = null;
2108
+ }
2109
+ return;
2110
+ }
2111
+ const delta = el.getBoundingClientRect().top - bodyEl.getBoundingClientRect().top + bodyEl.scrollTop - c.top;
2112
+ if (Math.abs(delta) <= 2 || tries > 40) {
2113
+ clearInterval(timer);
2114
+ correctionRef.current = null;
2115
+ return;
2116
+ }
2117
+ bodyEl.scrollTop += delta;
2118
+ }, 150);
2119
+ return () => clearInterval(timer);
1118
2120
  }, []);
1119
2121
  (0, react.useEffect)(() => {
1120
2122
  setZone(bodyEl ? bottomZoneAfter(false, bottomDistance()) : true);
@@ -1122,13 +2124,38 @@ window.__ModuleLoader__.load({
1122
2124
  (0, react.useEffect)(() => {
1123
2125
  const el = bodyEl;
1124
2126
  if (!el || typeof ResizeObserver === "undefined") return;
1125
- const ro = new ResizeObserver(() => scheduleRef.current());
2127
+ setViewportW(el.clientWidth);
2128
+ const ro = new ResizeObserver(() => {
2129
+ setViewportW(el.clientWidth);
2130
+ scheduleRef.current();
2131
+ });
1126
2132
  ro.observe(el);
1127
2133
  return () => ro.disconnect();
1128
2134
  }, []);
1129
2135
  (0, react.useEffect)(() => {
1130
2136
  scheduleRef.current();
1131
2137
  }, [snap, prefs.width]);
2138
+ (0, react.useEffect)(() => {
2139
+ const seq = lastPromptSeq(items);
2140
+ if (seq != null && shouldRevealSentPrompt({
2141
+ armed: revealArmed.current,
2142
+ prevSeq: seenPromptSeq.current,
2143
+ nextSeq: seq
2144
+ })) {
2145
+ revealArmed.current = false;
2146
+ const idx = findSeqIndex(items, seq);
2147
+ const el = idx >= 0 ? anchors["fm-" + idx] : void 0;
2148
+ if (el && bodyEl) {
2149
+ const limit = bodyEl.getBoundingClientRect().bottom - 110;
2150
+ const hidden = el.getBoundingClientRect().bottom - limit;
2151
+ if (hidden > 0) bodyEl.scrollBy({
2152
+ top: hidden,
2153
+ behavior: "smooth"
2154
+ });
2155
+ }
2156
+ }
2157
+ seenPromptSeq.current = seq;
2158
+ }, [items]);
1132
2159
  (0, react.useEffect)(() => () => {
1133
2160
  if (rafId.current !== null) {
1134
2161
  cancelAnimationFrame(rafId.current);
@@ -1162,9 +2189,22 @@ window.__ModuleLoader__.load({
1162
2189
  engaged
1163
2190
  ]);
1164
2191
  const loadImage = (0, react.useCallback)((attachment) => {
1165
- if (!conversation || !currentId) return Promise.reject(/* @__PURE__ */ new Error("dsh-focus-overlay: conversation service unavailable"));
1166
- return conversation.resolveImage(currentId, attachment);
1167
- }, [conversation, currentId]);
2192
+ const modern = uiConversation && currentId ? (async () => {
2193
+ const url = await uiConversation.imageUrl(currentId, attachment);
2194
+ if (url) return url;
2195
+ throw new Error("no url");
2196
+ })() : null;
2197
+ if (modern) return modern.catch(() => legacyResolve(attachment));
2198
+ return legacyResolve(attachment);
2199
+ }, [
2200
+ uiConversation,
2201
+ conversation,
2202
+ currentId
2203
+ ]);
2204
+ function legacyResolve(attachment) {
2205
+ if (conversation && typeof conversation.resolveImage === "function") return conversation.resolveImage(currentId, attachment);
2206
+ return Promise.reject(/* @__PURE__ */ new Error("dsh-focus-overlay: image resolver unavailable"));
2207
+ }
1168
2208
  const fileMentionsFor = (0, react.useCallback)((node) => {
1169
2209
  if (!chatFileMentions) return void 0;
1170
2210
  try {
@@ -1187,9 +2227,7 @@ window.__ModuleLoader__.load({
1187
2227
  const inputState = useInputState(inputFace);
1188
2228
  const draftValue = inputFace ? inputState && typeof inputState.draft === "string" ? inputState.draft : "" : localDraft;
1189
2229
  const draftEmpty = draftValue.trim() === "";
1190
- const pendingList = snap && snap.pending ? snap.pending : [];
1191
- const pendingCount = pendingList.length;
1192
- const wait = pendingCount > 0 ? pendingList[0] : null;
2230
+ const pending = !!wait;
1193
2231
  const onDraftChange = (v) => {
1194
2232
  if (inputFace) try {
1195
2233
  inputFace.setDraft(v);
@@ -1200,6 +2238,7 @@ window.__ModuleLoader__.load({
1200
2238
  const send = () => {
1201
2239
  const text = draftValue;
1202
2240
  if (text.trim() === "") return;
2241
+ if (zone) revealArmed.current = true;
1203
2242
  if (inputFace) try {
1204
2243
  inputFace.submit("queue");
1205
2244
  return;
@@ -1232,7 +2271,7 @@ window.__ModuleLoader__.load({
1232
2271
  const promptErr = snap && snap.promptError && snap.promptError.op === "send" ? snap.promptError.error : null;
1233
2272
  const errorLine = promptErr ? promptErr.message || promptErr.code || "send failed" : null;
1234
2273
  const form = bottomForm({
1235
- pending: pendingCount > 0,
2274
+ pending,
1236
2275
  cardOpen,
1237
2276
  draftEmpty,
1238
2277
  inZone: zone,
@@ -1240,14 +2279,14 @@ window.__ModuleLoader__.load({
1240
2279
  engaged
1241
2280
  });
1242
2281
  (0, react.useEffect)(() => {
1243
- if (pendingCount === 0) {
2282
+ if (!pending) {
1244
2283
  if (cardOpen) setCardOpen(false);
1245
2284
  return;
1246
2285
  }
1247
2286
  focusBarOnce.current = false;
1248
2287
  setDockFocused(false);
1249
2288
  setEngaged(false);
1250
- }, [pendingCount, cardOpen]);
2289
+ }, [pending, cardOpen]);
1251
2290
  (0, react.useEffect)(() => {
1252
2291
  if (form === "bar" && focusBarOnce.current) {
1253
2292
  focusBarOnce.current = false;
@@ -1262,6 +2301,22 @@ window.__ModuleLoader__.load({
1262
2301
  const row = listState.byId[currentId];
1263
2302
  if (row && row.displayTitle) title = row.displayTitle;
1264
2303
  }
2304
+ const [dragWidth, setDragWidth] = (0, react.useState)(null);
2305
+ const [viewportW, setViewportW] = (0, react.useState)(0);
2306
+ const width = dragWidth ?? prefs.width;
2307
+ const clampWidth = (0, react.useCallback)((w) => {
2308
+ const vw = bodyEl ? bodyEl.clientWidth : viewportW;
2309
+ const max = Math.max(640, vw - 176);
2310
+ return Math.round(Math.min(Math.max(w, 640), max));
2311
+ }, [viewportW]);
2312
+ const onWidthStart = (0, react.useCallback)(() => clampWidth(prefs.width), [clampWidth, prefs.width]);
2313
+ const onWidthDrag = (0, react.useCallback)((w) => setDragWidth(clampWidth(w)), [clampWidth]);
2314
+ const onWidthCommit = (0, react.useCallback)((w) => {
2315
+ const clamped = clampWidth(w);
2316
+ setDragWidth(null);
2317
+ if (clamped !== prefs.width) prefsStore.update({ width: clamped });
2318
+ }, [clampWidth, prefs.width]);
2319
+ const onWidthEnd = (0, react.useCallback)(() => setDragWidth(null), []);
1265
2320
  let body;
1266
2321
  if (!currentId) body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1267
2322
  className: "fm-empty",
@@ -1272,57 +2327,86 @@ window.__ModuleLoader__.load({
1272
2327
  children: t("loading")
1273
2328
  });
1274
2329
  else {
1275
- const partial = snap.partial;
2330
+ const partial = legacy.partial;
1276
2331
  const partialBlocks = partial ? partial.blocks : [];
1277
2332
  const running = !!snap.running;
1278
2333
  const kids = [];
1279
- for (let i = 0; i < items.length; i++) {
1280
- const it = items[i];
1281
- const key = "fm-" + i;
1282
- if (it.kind === "user") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2334
+ if (snap.hasMore) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2335
+ className: "fm-hidden",
2336
+ children: t("loading")
2337
+ }, "fm-history"));
2338
+ const renderItem = (it, key, registerAnchor) => {
2339
+ if (it.kind === "user") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1283
2340
  className: "fm-msg fm-user-msg",
1284
2341
  ref: (el) => {
1285
- if (el) anchors[key] = el;
2342
+ if (registerAnchor) if (el) anchors[key] = el;
1286
2343
  else delete anchors[key];
1287
2344
  },
1288
2345
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1289
2346
  className: "fm-user",
1290
2347
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MessageText, { text: it.text })
1291
2348
  })
1292
- }, key));
1293
- else if (it.kind === "steering") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2349
+ }, key);
2350
+ if (it.kind === "steering") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1294
2351
  className: "fm-msg fm-user-msg",
1295
2352
  ref: (el) => {
1296
- if (el) anchors[key] = el;
2353
+ if (registerAnchor) if (el) anchors[key] = el;
1297
2354
  else delete anchors[key];
1298
2355
  },
1299
2356
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1300
2357
  className: "fm-user fm-steering",
1301
2358
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MessageText, { text: it.text })
1302
2359
  })
1303
- }, key));
1304
- else if (it.kind === "assistant") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AssistantItem, {
2360
+ }, key);
2361
+ if (it.kind === "assistant") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AssistantItem, {
1305
2362
  blocks: it.blocks,
1306
2363
  loadImage,
1307
- fileMentions: fileMentionsFor(it)
1308
- }, key));
1309
- else if (it.kind === "hidden") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2364
+ fileMentions: fileMentionsFor(it),
2365
+ labels: mdLabels
2366
+ }, key);
2367
+ if (it.kind === "error") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2368
+ className: "fm-msg fm-error",
2369
+ children: it.text
2370
+ }, key);
2371
+ if (it.kind === "hidden") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1310
2372
  className: "fm-hidden",
1311
2373
  children: [
1312
2374
  "· ",
1313
2375
  it.text,
1314
2376
  " ·"
1315
2377
  ]
1316
- }, key));
1317
- else if (it.kind === "error") kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1318
- className: "fm-msg fm-error",
1319
- children: it.text
1320
- }, key));
2378
+ }, key);
2379
+ if (it.kind === "turnProcess") {
2380
+ const open = !it.collapsed || expanded.has(it.seq);
2381
+ const toggle = () => setExpanded((prev) => {
2382
+ const next = new Set(prev);
2383
+ if (next.has(it.seq)) next.delete(it.seq);
2384
+ else next.add(it.seq);
2385
+ return next;
2386
+ });
2387
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [it.collapsed ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2388
+ type: "button",
2389
+ className: "fm-process-root",
2390
+ "data-open": open || void 0,
2391
+ "aria-expanded": open,
2392
+ onClick: toggle,
2393
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2394
+ className: "fm-process-label",
2395
+ children: processLabel(it.counts, t)
2396
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { className: "fm-process-chevron" })]
2397
+ }) : null, open ? it.workItems.map((wi, wi2) => renderItem(wi, key + "-w" + wi2, false)) : null] }, key);
2398
+ }
2399
+ return null;
2400
+ };
2401
+ for (let i = 0; i < items.length; i++) {
2402
+ const rendered = renderItem(items[i], "fm-" + i, true);
2403
+ if (rendered) kids.push(rendered);
1321
2404
  }
1322
2405
  if (partialBlocks.length) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AssistantItem, {
1323
2406
  blocks: partialBlocks,
1324
2407
  loadImage,
1325
- fileMentions: void 0
2408
+ fileMentions: void 0,
2409
+ labels: mdLabels
1326
2410
  }, "fm-partial"));
1327
2411
  if (running) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1328
2412
  className: "fm-running",
@@ -1346,29 +2430,24 @@ window.__ModuleLoader__.load({
1346
2430
  }, "fm-running"));
1347
2431
  body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1348
2432
  className: "fm-inner",
1349
- style: { maxWidth: prefs.width },
2433
+ style: {
2434
+ maxWidth: width,
2435
+ "--fm-content-width": `${width}px`
2436
+ },
1350
2437
  children: kids
1351
2438
  });
1352
2439
  }
1353
2440
  const activeIndex = activeKey ? navKeys.indexOf(activeKey) : -1;
1354
- const navHeight = (navKeys.length - 1) * 14 + 22;
1355
- const dotTop = (i) => i * 14 + 11 + (i < activeIndex ? -7 : i > activeIndex ? 7 : 0);
1356
- const nav = prefs.navbar && navKeys.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1357
- className: "fm-nav",
1358
- style: { height: navHeight },
1359
- children: navKeys.map((k, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1360
- type: "button",
1361
- "aria-label": navPreviews[k],
1362
- className: "fm-nav-dot" + (activeKey === k ? " fm-nav-dot-active" : ""),
1363
- style: { top: dotTop(i) },
1364
- onClick: () => scrollToKey(k, true),
1365
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-nav-dot-core" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1366
- className: "fm-nav-tip",
1367
- children: navPreviews[k]
1368
- })]
1369
- }, k))
2441
+ const activeTurn = rail.length ? activeIndex >= 0 && rail[activeIndex] ? rail[activeIndex].turn : rail[rail.length - 1].turn : null;
2442
+ const nav = prefs.navbar && rail.length >= 2 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TurnRail, {
2443
+ items: rail,
2444
+ activeTurn,
2445
+ onNavigate: (item) => scrollToKey("fm-" + item.idx, true),
2446
+ label: t("nav.label"),
2447
+ jumpLabel: (turn) => t("nav.jump", { n: turn }),
2448
+ turnLabel: (turn) => t("nav.turn", { n: turn })
1370
2449
  }) : null;
1371
- const waiting = pendingCount > 0 && !cardOpen;
2450
+ const waiting = pending && !cardOpen;
1372
2451
  const donePing = useDonePing();
1373
2452
  const [showDoneNotice, setShowDoneNotice] = (0, react.useState)(false);
1374
2453
  (0, react.useEffect)(() => {
@@ -1436,13 +2515,22 @@ window.__ModuleLoader__.load({
1436
2515
  children: t("exit")
1437
2516
  })]
1438
2517
  }),
1439
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1440
- className: "fm-body",
1441
- ref: (el) => {
1442
- bodyEl = el;
1443
- },
1444
- onScroll: scheduleRecompute,
1445
- children: body
2518
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2519
+ className: "fm-body-wrap",
2520
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2521
+ className: "fm-body",
2522
+ ref: (el) => {
2523
+ bodyEl = el;
2524
+ },
2525
+ onScroll: scheduleRecompute,
2526
+ children: body
2527
+ }), currentId && snap !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WidthHandles, {
2528
+ width,
2529
+ onStart: onWidthStart,
2530
+ onDrag: onWidthDrag,
2531
+ onCommit: onWidthCommit,
2532
+ onEnd: onWidthEnd
2533
+ }) : null]
1446
2534
  }),
1447
2535
  nav,
1448
2536
  currentId && snap !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
@@ -1469,9 +2557,37 @@ window.__ModuleLoader__.load({
1469
2557
  ]
1470
2558
  });
1471
2559
  }
2560
+ /** Error boundary around the overlay content: a render crash would otherwise
2561
+ * unmount the slot subtree and every later entry would crash again — the
2562
+ * toggle would look permanently dead. The boundary logs, renders nothing for
2563
+ * the failed pass, and re-arms on the next open (keyed by open count). */
2564
+ var FocusErrorBoundary = class extends react.Component {
2565
+ state = { error: null };
2566
+ static getDerivedStateFromError(error) {
2567
+ return { error };
2568
+ }
2569
+ componentDidUpdate(prev) {
2570
+ if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null });
2571
+ }
2572
+ render() {
2573
+ if (this.state.error) {
2574
+ console.error("[dsh-focus-overlay] overlay render failed; it will retry on the next open", this.state.error);
2575
+ return null;
2576
+ }
2577
+ return this.props.children;
2578
+ }
2579
+ };
1472
2580
  function FocusOverlay(props) {
1473
- if (!useFocus()) return null;
1474
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusContent, { ...props });
2581
+ const on = useFocus();
2582
+ const opens = (0, react.useRef)(0);
2583
+ if (!on) {
2584
+ opens.current++;
2585
+ return null;
2586
+ }
2587
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusErrorBoundary, {
2588
+ resetKey: opens.current,
2589
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusContent, { ...props })
2590
+ });
1475
2591
  }
1476
2592
  function FocusToggle({ t }) {
1477
2593
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
@@ -1555,34 +2671,6 @@ window.__ModuleLoader__.load({
1555
2671
  className: "fm-plugin-field-hint",
1556
2672
  children: t("settings.navbar.hint")
1557
2673
  })]
1558
- }),
1559
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1560
- className: "fm-plugin-field",
1561
- children: [
1562
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1563
- className: "fm-plugin-field-head",
1564
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1565
- className: "fm-plugin-field-label",
1566
- children: t("settings.width")
1567
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1568
- className: "fm-plugin-field-value",
1569
- children: [prefs.width, "px"]
1570
- })]
1571
- }),
1572
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
1573
- type: "range",
1574
- className: "fm-plugin-range",
1575
- min: 480,
1576
- max: 1200,
1577
- step: 40,
1578
- value: prefs.width,
1579
- onChange: (e) => prefsStore.update({ width: Number(e.target.value) })
1580
- }),
1581
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
1582
- className: "fm-plugin-field-hint",
1583
- children: t("settings.width.hint")
1584
- })
1585
- ]
1586
2674
  })
1587
2675
  ] });
1588
2676
  }
@@ -1704,169 +2792,6 @@ window.__ModuleLoader__.load({
1704
2792
  })
1705
2793
  });
1706
2794
  }
1707
- //#endregion
1708
- //#region src/client/styles.ts
1709
- /** Package-owned stylesheet text (injected by the client apply, cleaned up on unload). */
1710
- const FOCUS_CSS = `
1711
- .fm-overlay{position:fixed;inset:0;z-index:2147483000;background:var(--dsw-alias-bg-base,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);display:flex;flex-direction:column;pointer-events:auto;font-family:var(--dsw-font-family,sans-serif);outline:none}
1712
- .fm-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));flex:0 0 auto}
1713
- .fm-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1714
- .fm-body{flex:1 1 auto;overflow-y:auto;padding:28px 28px 110px}
1715
- .fm-inner{max-width:760px;margin:0 auto}
1716
- .fm-msg{margin:0 0 24px}
1717
- .fm-user-msg{margin-bottom:44px}
1718
- .fm-user{background:var(--dsw-specific-bubble,#eaf2ff);color:var(--dsw-alias-label-primary,#1a1a1a);border-radius:14px;padding:10px 14px;word-break:break-word;max-width:85%;margin-left:auto}
1719
- .fm-steering{opacity:.85}
1720
- .fm-assistant{word-break:break-word}
1721
- .fm-image{max-width:100%;border-radius:8px;margin:8px 0}
1722
- .fm-hidden{text-align:center;color:var(--dsw-alias-label-secondary,#888);font-size:12px;line-height:1.6;margin:6px 0 18px;user-select:none}
1723
- .fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
1724
- /* "正在工作" live line — the shimmer is a 1:1 replica of the official
1725
- generating indicator in dsh-client-ui-conversation (the "Deep Diving"
1726
- turn-status): brand-blue text (--dsw-static-deepseek-500) with a light
1727
- highlight (--dsw-static-deepseek-200) sweeping through — gradient stops
1728
- 0/40/50/60/100 on a 250% tile, background-position 100%→0, 1.8s linear,
1729
- font --dsw-font-s-strong-14 (500 14px/22px). The official class name
1730
- (Md3f7G_turnStatus) is a per-build CSS-modules hash, so reusing it would
1731
- break silently on dsh updates — the declarations are copied verbatim onto
1732
- our own class using the same theme tokens instead. The flanking short
1733
- dashes are part of the same shimmer span, so the sweep covers dashes and
1734
- label as one unit. Fallbacks: no background-clip:text → secondary gray
1735
- label; prefers-reduced-motion → animation off, label stays solid
1736
- deepseek-500 (the official base color). */
1737
- .fm-running{text-align:center;margin:10px 0 20px;user-select:none;animation:fm-up .18s ease}
1738
- .fm-running-text{color:var(--dsw-alias-label-secondary,#888);font:var(--dsw-font-s-strong-14,500 14px/22px var(--dsw-font-family,sans-serif));white-space:nowrap}
1739
- /* Dashes: separate spans with fixed margins instead of space characters — in
1740
- CJK/Latin mixed runs the browser itemizes the two U+0020 spaces into
1741
- different fonts (one Latin-narrow, one Han-wide), and the Han font's en
1742
- dash glyph is left-biased, which made the two gaps visually unequal. The
1743
- forced Latin-first stack renders a symmetric en dash; margins are exact. */
1744
- .fm-running-dash{font-family:system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;margin:0 8px}
1745
- @supports ((-webkit-background-clip:text) or (background-clip:text)){
1746
- .fm-running-text{background:linear-gradient(90deg,var(--dsw-static-deepseek-500,#4176e6) 0%,var(--dsw-static-deepseek-500,#4176e6) 40%,var(--dsw-static-deepseek-200,#d3e2ff) 50%,var(--dsw-static-deepseek-500,#4176e6) 60%,var(--dsw-static-deepseek-500,#4176e6) 100%);color:#0000;-webkit-text-fill-color:transparent;background-position:100% 0;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;animation:fm-sweep 1.8s linear infinite}
1747
- }
1748
- @keyframes fm-sweep{to{background-position:0 0}}
1749
- @media (prefers-reduced-motion:reduce){.fm-running{animation:none}.fm-running-text{background:0 0;color:var(--dsw-static-deepseek-500,#4176e6);-webkit-text-fill-color:currentColor;animation:none}}
1750
- .fm-empty{color:var(--dsw-alias-label-secondary,#888);padding:48px 16px;text-align:center}
1751
- .fm-nav{position:absolute;right:20px;top:50%;transform:translateY(-50%);display:block;width:16px;z-index:2;pointer-events:auto;transition:transform .18s ease}
1752
- .fm-nav:hover{transform:translateY(-50%) translateX(-6px)}
1753
- .fm-nav-dot{position:absolute;left:50%;transform:translate(-50%,-50%);cursor:pointer;background:transparent;border:0;padding:0;width:16px;height:auto;display:flex;align-items:center;justify-content:center;transition:top .22s ease}
1754
- .fm-nav-dot::after{content:'';position:absolute;inset:-5px -8px}
1755
- .fm-nav-dot-core{display:block;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-label-secondary,#999);opacity:.45;transition:opacity .15s ease,background .15s ease,height .15s ease,transform .18s ease}
1756
- .fm-nav:hover .fm-nav-dot:not(.fm-nav-dot-active) .fm-nav-dot-core{transform:scale(1.15);opacity:.9}
1757
- .fm-nav-dot:not(.fm-nav-dot-active):hover .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);opacity:1;transform:scale(1.5)}
1758
- .fm-nav-dot-active .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);height:22px;opacity:1}
1759
- .fm-nav-tip{position:absolute;right:calc(100% + 8px);top:50%;transform:translateY(-50%);display:none;width:260px;box-sizing:border-box;white-space:normal;overflow-wrap:break-word;word-break:break-word;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:8px;padding:8px 10px;font-size:12px;line-height:1.5;box-shadow:0 4px 16px rgba(0,0,0,.12);pointer-events:none;text-align:left}
1760
- .fm-nav-dot:hover .fm-nav-tip{display:block}
1761
- .fm-reply-toast{position:absolute;left:50%;transform:translateX(-50%);bottom:84px;z-index:2;display:flex;align-items:center;gap:10px;background:var(--dsw-alias-bg-overlay,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.12));border-radius:100px;padding:8px 8px 8px 16px;box-shadow:0 4px 16px rgba(0,0,0,.12);font-size:13px}
1762
- .fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
1763
- .fm-reply-toast-text{white-space:nowrap}
1764
- /* ---- bottom dock: bar / pill / jump-to-bottom / answer card ----
1765
- Visual language mirrors the official composer cards token-for-token
1766
- (ui-conversation InputBar/QuestionComposer, ui-user-questions
1767
- PlanReviewPanel): --dsw-specific-input-major surfaces, 20–22px radii,
1768
- --dsw-shadow-lv2, the 34px round info-fill primary button, interactive-
1769
- hover option tinting. */
1770
- .fm-dock{position:absolute;left:50%;transform:translateX(-50%);bottom:18px;z-index:3;width:calc(100% - 96px);display:flex;justify-content:center;pointer-events:none}
1771
- .fm-dock>*{pointer-events:auto;animation:fm-up .18s ease}
1772
- .fm-bar{box-sizing:border-box;width:100%;background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));border-radius:22px;padding-top:10px;display:flex;flex-direction:column;font-size:16px;line-height:24px}
1773
- .fm-bar-row{display:flex;align-items:flex-end;gap:12px;min-width:0;padding:2px 8px 6px}
1774
- /* flex-end anchors the send button to the row's bottom edge, so a multi-line
1775
- draft grows the textarea upward and the button's distance to the bar's
1776
- bottom-right corner never moves. */
1777
- .fm-bar-text{flex:1 1 auto;min-height:28px;max-height:120px;resize:none;color:inherit;caret-color:var(--dsw-alias-state-business-primary,var(--dsw-static-deepseek-500,#4176e6));background:0 0;border:none;outline:none;font:inherit;font-size:inherit;line-height:inherit;padding:4px 12px 0 16px}
1778
- .fm-bar-text::placeholder{color:var(--dsw-alias-label-caption,#999);user-select:none}
1779
- .fm-bar-note{margin:4px 12px 0;border-radius:8px;padding:4px 8px;font-size:12px;line-height:18px;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));color:var(--dsw-alias-label-secondary,#888)}
1780
- .fm-bar-note-error{color:var(--dsw-alias-state-error-primary,#d23);word-break:break-word}
1781
- .fm-bar-queue{flex:0 0 auto;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,#888);background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.05));border-radius:999px;padding:2px 10px;margin-bottom:7px;white-space:nowrap}
1782
- .fm-bar-send{width:34px;height:34px;display:grid;place-items:center;flex:none;cursor:pointer;border:none;border-radius:999px;background:var(--dsw-alias-button-info-fill,#4176e6);color:#fff;transform:translateY(-2px);transition:background-color .1s;padding:0}
1783
- .fm-bar-send:hover:not(:disabled){background:var(--dsw-alias-button-info-hover,#3560c4)}
1784
- .fm-bar-send:disabled{opacity:.4;cursor:default}
1785
- .fm-pill{position:relative;display:grid;place-items:center;width:38px;height:38px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-secondary,#666);cursor:pointer;box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));transition:color .12s;padding:0}
1786
- .fm-pill:hover{color:var(--dsw-alias-label-primary,#1a1a1a)}
1787
- .fm-pill-dot{position:absolute;top:4px;right:4px;width:7px;height:7px;border-radius:999px;background:var(--dsw-alias-state-business-primary,var(--dsw-static-deepseek-500,#4176e6))}
1788
- .fm-card{box-sizing:border-box;width:100%;background:var(--dsw-specific-input-major,#fff);color:var(--dsw-alias-label-primary,#1a1a1a);border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));box-shadow:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));border-radius:20px;display:flex;flex-direction:column;max-height:min(60vh,520px);overflow:hidden;--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2)}
1789
- .fm-card *{box-sizing:border-box}
1790
- .fm-card-approval{border-color:var(--dsw-alias-state-warn-secondary,#e6a23c)}
1791
- .fm-card-head{flex:0 0 auto;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;padding:20px 16px 0 24px}
1792
- .fm-card-approval .fm-card-head{background:var(--dsw-alias-state-warn-tertiary,rgba(230,162,60,.12));color:var(--dsw-alias-state-warn-primary,#9a6b12);align-items:center;padding:10px 16px}
1793
- .fm-card-heading{min-width:0}
1794
- .fm-card-eyebrow{color:var(--dsw-alias-label-tertiary,#999);margin:0 0 5px;font-size:11px;line-height:16px}
1795
- .fm-card-title{margin:0;font-size:16px;font-weight:500;line-height:22px}
1796
- .fm-card-strip-title{font-size:13px;line-height:18px;flex:1;min-width:0}
1797
- .fm-card-dot{background:var(--dsw-alias-state-warn-primary,#9a6b12);border-radius:50%;width:8px;height:8px;flex:none}
1798
- .fm-card-close{width:24px;height:24px;display:grid;place-items:center;flex:none;cursor:pointer;border:none;border-radius:999px;background:0 0;color:var(--dsw-alias-label-tertiary,#999);padding:0}
1799
- .fm-card-close:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));color:var(--dsw-alias-label-primary,#1a1a1a)}
1800
- .fm-card-approval .fm-card-close,.fm-card-approval .fm-card-close:hover:not(:disabled){color:inherit}
1801
- .fm-card-body{overscroll-behavior:contain;flex:1 1 auto;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding-bottom:8px;font-size:14px;line-height:22px}
1802
- .fm-card-q{display:flex;flex-direction:column}
1803
- .fm-card-q-text{margin:0 2px 8px;padding:0 22px;white-space:pre-wrap;word-break:break-word;font-size:16px;font-weight:500;line-height:22px}
1804
- .fm-card-detail{color:var(--dsw-alias-label-secondary,#888);margin:0 2px 8px;padding:0 22px;font-size:14px;line-height:22px}
1805
- .fm-card-opts{display:flex;flex-direction:column;gap:1px;margin:8px 0 0;padding:4px 12px}
1806
- .fm-opt{width:100%;min-height:40px;color:inherit;text-align:left;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:12px;flex-shrink:0;display:flex;align-items:flex-start;gap:8px;padding:8px 12px 8px 8px;transition:background-color .12s,border-color .12s;font:inherit}
1807
- .fm-opt:hover:not(:disabled),.fm-opt-on{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}
1808
- .fm-opt-on{border-color:var(--dsw-alias-border-l2,rgba(0,0,0,.18))}
1809
- .fm-opt-num{background:var(--dsw-alias-bg-overlay,#fff);width:20px;height:20px;color:var(--dsw-alias-label-secondary,#888);border-radius:6px;flex:0 0 20px;display:grid;place-items:center;margin-top:2px;font-size:12px;font-weight:500;line-height:18px}
1810
- .fm-opt-check{flex:0 0 20px;width:20px;height:20px;margin-top:2px;display:grid;place-items:center;position:relative;color:var(--dsw-alias-label-primary-foreground,#fff)}
1811
- .fm-opt-check::before{content:'';border:1px solid var(--dsw-alias-border-l4,rgba(0,0,0,.25));border-radius:4px;width:14px;height:14px;grid-area:1/1;transition:background-color .12s,border-color .12s}
1812
- .fm-opt-check-on::before{border-color:var(--dsw-alias-label-primary,#1a1a1a);background:var(--dsw-alias-label-primary,#1a1a1a)}
1813
- .fm-opt-check>*{grid-area:1/1}
1814
- .fm-opt-copy{flex:1;min-width:0}
1815
- .fm-opt-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 6px}
1816
- .fm-opt-label{font-size:14px;font-weight:500;line-height:24px;word-break:break-word}
1817
- .fm-opt-badge{background:var(--dsw-specific-sidebar-nav-item-active-accent,rgba(65,118,230,.12));color:var(--dsw-alias-button-info-fill,#4176e6);border-radius:6px;padding:0 4px;font-size:11px;font-weight:600;line-height:18px}
1818
- .fm-opt-desc{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:24px;word-break:break-word}
1819
- .fm-card-custom{margin:0 12px;border:1px solid #0000;border-radius:12px;flex-shrink:0;display:flex;align-items:center;min-height:40px;padding:8px 12px;transition:background-color .12s,border-color .12s}
1820
- .fm-card-custom:hover,.fm-card-custom:focus-within{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}
1821
- .fm-card-custom:focus-within{border-color:var(--dsw-alias-border-l2,rgba(0,0,0,.18))}
1822
- .fm-card-field{min-width:0;width:100%;border:none;outline:none;background:0 0;color:inherit;font:inherit;font-size:14px;line-height:24px;padding:0}
1823
- .fm-card-field::placeholder{color:var(--dsw-alias-label-caption,#999)}
1824
- .fm-card-foot{flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 16px 12px}
1825
- .fm-card-err{flex:1 1 auto;min-height:16px;color:var(--dsw-alias-state-error-primary,#d23);font-size:11px;line-height:16px;word-break:break-word}
1826
- .fm-card-actions{display:flex;align-items:center;gap:8px;flex:none}
1827
- @keyframes fm-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
1828
- .fm-plugin-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
1829
- .fm-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed)}
1830
- .fm-plugin-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
1831
- .fm-plugin-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
1832
- .fm-plugin-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
1833
- .fm-plugin-card-headtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
1834
- .fm-plugin-card-name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
1835
- .fm-plugin-card-desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
1836
- .fm-plugin-card-chevron{color:var(--dsw-alias-label-tertiary);flex:none;transition:transform .16s}
1837
- .fm-plugin-card-chevron-open{transform:rotate(180deg)}
1838
- .fm-plugin-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:12px}
1839
- .fm-plugin-field{flex-direction:column;gap:6px;padding:12px 0;display:flex}
1840
- .fm-plugin-field+.fm-plugin-field{border-top:1px solid var(--dsw-alias-border-l2)}
1841
- .fm-plugin-field-head{align-items:center;gap:8px;display:flex}
1842
- .fm-plugin-field-label{min-width:0;color:var(--dsw-alias-label-primary);flex:1;font-size:13px;font-weight:500;line-height:1.5}
1843
- .fm-plugin-field-value{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.5}
1844
- .fm-plugin-field-hint{color:var(--dsw-alias-label-tertiary);margin:0;font-size:12px;line-height:1.5}
1845
- .fm-plugin-check{align-items:center;gap:8px;display:flex;cursor:pointer;user-select:none}
1846
- .fm-plugin-check input[type="checkbox"]{width:16px;height:16px;margin:0;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#4176e6)}
1847
- .fm-plugin-check-label{color:var(--dsw-alias-label-primary);font-size:13px;line-height:1.5}
1848
- .fm-plugin-range{width:100%;max-width:280px;cursor:pointer;accent-color:var(--dsw-alias-brand-primary,#4176e6)}
1849
- /* DSH-better-sidebar compatibility: its panel host (top-right toggle cluster +
1850
- right/bottom panels) is appended to document.body at z-index 40/45, above
1851
- shell.overlay's z-20 layer, so this overlay cannot cover it. Hide the host
1852
- while focus is on, and neutralize the #root layout push it applies when its
1853
- panels are open. Selectors mirror better-sidebar's own layout.css exactly. */
1854
- body[data-fm-focus] [data-dsh-panel-host]{display:none!important}
1855
- body[data-fm-focus] #root{margin-right:0!important;width:100%!important}
1856
- body[data-fm-focus] #root [data-dsh-frame] > [data-pane="conversation"],
1857
- body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!important}
1858
- /* First-run onboarding (settings.onboarding step) — content inside the official
1859
- Modal primitive; the primitive owns the mask/dialog chrome, these style our body. */
1860
- .fm-onboard{width:min(600px,100%);padding:0}
1861
- .fm-onboard-content{box-sizing:border-box;flex-direction:column;max-height:calc(100vh - 48px);padding:28px;display:flex;overflow-y:auto}
1862
- .fm-onboard-title{color:var(--dsw-alias-label-primary);outline:none;margin:0;font-size:20px;font-weight:500;line-height:28px}
1863
- .fm-onboard-intro{color:var(--dsw-alias-label-secondary);margin:8px 0 0;font-size:14px;line-height:22px}
1864
- .fm-onboard-subtitle{color:var(--dsw-alias-label-primary);margin:20px 0 8px;font-size:14px;font-weight:600;line-height:22px}
1865
- .fm-onboard-features{margin:0;padding:0 0 0 18px;list-style:disc;display:flex;flex-direction:column;gap:6px;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}
1866
- .fm-onboard-note{color:var(--dsw-alias-label-tertiary);margin:12px 0 0;font-size:12px;line-height:18px}
1867
- .fm-onboard-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:20px}
1868
- @media (width<=560px){.fm-onboard-content{padding:24px}}
1869
- `;
1870
2795
  //#endregion
1871
2796
  //#region src/client/locales.ts
1872
2797
  /** i18n dictionaries for the `focus` namespace. */
@@ -1875,6 +2800,9 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1875
2800
  "toggle.title": "进入专注模式",
1876
2801
  "exit": "退出专注 (Esc)",
1877
2802
  "backToLatest": "回到最新",
2803
+ "nav.label": "回合导航",
2804
+ "nav.jump": "跳转到第 {n} 回合",
2805
+ "nav.turn": "第 {n} 回合",
1878
2806
  "settings.label": "专注模式",
1879
2807
  "settings.description": "全屏阅读遮罩的偏好设置",
1880
2808
  "settings.expand": "展开",
@@ -1887,8 +2815,12 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1887
2815
  "settings.autoFocus.hint": "AI 正常完成回复后自动进入并定位到你的提问",
1888
2816
  "settings.hotkey": "按 F 键进入专注模式",
1889
2817
  "settings.hotkey.hint": "在任意界面按下 F 立即进入专注模式;在输入框内打字不会触发",
1890
- "settings.width": "文字区宽度",
1891
- "settings.width.hint": "专注模式正文的阅读列宽(480–1200px)",
2818
+ "process.toolCalls": "{n} 次工具调用",
2819
+ "process.messages": "{n} 条消息",
2820
+ "process.subagents": "{n} 个 subagent",
2821
+ "process.thought": "已思考",
2822
+ "process.separator": " · ",
2823
+ "process.toolRow": "已完成",
1892
2824
  "empty": "暂无打开的会话",
1893
2825
  "loading": "正在读取会话…",
1894
2826
  "running": "正在工作",
@@ -1908,6 +2840,9 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1908
2840
  "answer.reject": "拒绝",
1909
2841
  "composer.placeholder": "输入回复…",
1910
2842
  "composer.send": "发送",
2843
+ "markdown.code.copy": "复制代码",
2844
+ "markdown.code.copied": "已复制",
2845
+ "markdown.footnotes": "脚注",
1911
2846
  "composer.expand": "展开输入框",
1912
2847
  "composer.queued": "已排队 {n}",
1913
2848
  "composer.occurrences": "草稿中包含引用 / 命令,在此编辑可能使其失效,建议退出专注模式编辑",
@@ -1944,6 +2879,9 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1944
2879
  "toggle.title": "Enter focus mode",
1945
2880
  "exit": "Exit focus (Esc)",
1946
2881
  "backToLatest": "Back to latest",
2882
+ "nav.label": "Turn navigation",
2883
+ "nav.jump": "Jump to turn {n}",
2884
+ "nav.turn": "Turn {n}",
1947
2885
  "settings.label": "Focus Mode",
1948
2886
  "settings.description": "Preferences for the full-screen reading overlay",
1949
2887
  "settings.expand": "Expand",
@@ -1956,8 +2894,12 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1956
2894
  "settings.autoFocus.hint": "Auto-open at your question after a normal reply",
1957
2895
  "settings.hotkey": "Press F to enter focus mode",
1958
2896
  "settings.hotkey.hint": "Press F anywhere to enter focus mode instantly; typing in an input never triggers it",
1959
- "settings.width": "Text width",
1960
- "settings.width.hint": "Reading column width (480–1200px)",
2897
+ "process.toolCalls": "{n} tool calls",
2898
+ "process.messages": "{n} messages",
2899
+ "process.subagents": "{n} subagents",
2900
+ "process.thought": "Thought for a while",
2901
+ "process.separator": " · ",
2902
+ "process.toolRow": "completed",
1961
2903
  "empty": "No open session",
1962
2904
  "loading": "Reading session…",
1963
2905
  "running": "Working",
@@ -1977,6 +2919,9 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1977
2919
  "answer.reject": "Deny",
1978
2920
  "composer.placeholder": "Type a reply…",
1979
2921
  "composer.send": "Send",
2922
+ "markdown.code.copy": "Copy code",
2923
+ "markdown.code.copied": "Copied",
2924
+ "markdown.footnotes": "Footnotes",
1980
2925
  "composer.expand": "Expand input",
1981
2926
  "composer.queued": "Queued {n}",
1982
2927
  "composer.occurrences": "Draft contains references / commands — editing here may break them; edit outside focus mode",
@@ -2014,11 +2959,18 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2014
2959
  * Focus Mode, browser half: a full-screen reading overlay in `shell.overlay`
2015
2960
  * (additive, never replaces shipped UI), a "专注" button in the session header
2016
2961
  * action row, and a collapsible plugin card in the Plugins settings tab
2017
- * (`settings.plugin.item`). Rendering reuses the official
2018
- * `@deepseek-ai/dsh-client-ui-primitives` Markdown/MessageText/Tooltip/Button
2019
- * components and the conversation service's image resolver.
2962
+ * (`settings.plugin.item`). Rendering reuses the official client UI primitives
2963
+ * module (`@deepseek-ai/dsh-client-ui-primitives` runtime id) — Markdown /
2964
+ * MessageText / Tooltip / Button — plus the conversation service's composer
2965
+ * registry and the uiSession pending-interaction service (dsh 0.1.2).
2020
2966
  */
2021
2967
  const NS = "focus";
2968
+ let warnedLegacy = false;
2969
+ function warnLegacyOnce() {
2970
+ if (warnedLegacy) return;
2971
+ warnedLegacy = true;
2972
+ console.warn("[dsh-focus-overlay] dsh 0.1.2+ ui services not found — running in legacy compatibility mode");
2973
+ }
2022
2974
  //#endregion
2023
2975
  module.exports = {
2024
2976
  name: "dsh-focus-overlay-client",
@@ -2026,7 +2978,10 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2026
2978
  "slots",
2027
2979
  "sessions",
2028
2980
  "locale",
2029
- "workspaces"
2981
+ "workspaces",
2982
+ "uiSession",
2983
+ "uiConversation",
2984
+ "settingsScope"
2030
2985
  ],
2031
2986
  apply(ctx) {
2032
2987
  const style = document.createElement("style");
@@ -2059,16 +3014,50 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2059
3014
  const workspaces = ctx.workspaces;
2060
3015
  const conversation = ctx.get("conversation");
2061
3016
  const chatFileMentions = ctx.get("chatFileMentions");
3017
+ const uiSession = ctx.get("uiSession");
3018
+ const uiConversation = ctx.get("uiConversation");
3019
+ if (!uiSession || !uiConversation) warnLegacyOnce();
3020
+ let settingsScope = null;
3021
+ try {
3022
+ const binder = ctx.get("settingsScope");
3023
+ settingsScope = binder && typeof binder.bind === "function" ? binder.bind({ namespace: "ui-chat" }) : null;
3024
+ } catch {
3025
+ settingsScope = null;
3026
+ }
2062
3027
  ctx.effect(() => {
2063
3028
  let currentId = void 0;
2064
3029
  let unsubSession = null;
3030
+ let unsubChat = null;
3031
+ let chatSlice = null;
2065
3032
  let prevRunning = false;
2066
3033
  let pendingSettle = false;
3034
+ const judge = () => {
3035
+ if (!pendingSettle) return;
3036
+ const snap = chatSlice;
3037
+ const outcome = detectSettledCompletion({
3038
+ partial: snap ? snap.partial : null,
3039
+ nodes: snap ? snap.nodes : [],
3040
+ runningCalls: snap && snap.runningCalls ? snap.runningCalls : []
3041
+ });
3042
+ if (outcome.settled) {
3043
+ pendingSettle = false;
3044
+ if (outcome.completed && prefsStore.get().autoFocus) if (focusStore.get()) focusStore.notifyDone();
3045
+ else {
3046
+ focusStore.setAutoAnchor(outcome.anchorSeq);
3047
+ focusStore.set(true);
3048
+ }
3049
+ }
3050
+ };
2067
3051
  const watch = (id) => {
2068
3052
  if (unsubSession) {
2069
3053
  unsubSession();
2070
3054
  unsubSession = null;
2071
3055
  }
3056
+ if (unsubChat) {
3057
+ unsubChat();
3058
+ unsubChat = null;
3059
+ }
3060
+ chatSlice = null;
2072
3061
  prevRunning = false;
2073
3062
  pendingSettle = false;
2074
3063
  if (id == null) return;
@@ -2081,21 +3070,21 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2081
3070
  const running = !!snap.running;
2082
3071
  if (prevRunning && !running) pendingSettle = true;
2083
3072
  if (running) pendingSettle = false;
2084
- if (pendingSettle && !running) {
2085
- const outcome = detectSettledCompletion(snap);
2086
- if (outcome.settled) {
2087
- pendingSettle = false;
2088
- if (outcome.completed && prefsStore.get().autoFocus) if (focusStore.get()) focusStore.notifyDone();
2089
- else {
2090
- focusStore.setAutoAnchor(outcome.anchorSeq);
2091
- focusStore.set(true);
2092
- }
2093
- }
2094
- }
3073
+ judge();
2095
3074
  prevRunning = running;
2096
3075
  };
2097
3076
  onSnap();
2098
3077
  unsubSession = face.subscribe(onSnap);
3078
+ if (uiConversation) try {
3079
+ const chatSource = uiConversation.binding(id).target("chat");
3080
+ const onChat = () => {
3081
+ chatSlice = legacySliceOf(chatSource.getSnapshot(), face.getSnapshot());
3082
+ judge();
3083
+ };
3084
+ onChat();
3085
+ unsubChat = chatSource.subscribe(onChat);
3086
+ } catch {}
3087
+ if (!unsubChat) chatSlice = legacySliceOf(null, face.getSnapshot());
2099
3088
  };
2100
3089
  const onList = () => {
2101
3090
  const ls = sessions.list.getSnapshot();
@@ -2110,6 +3099,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2110
3099
  return () => {
2111
3100
  if (unsubList) unsubList();
2112
3101
  if (unsubSession) unsubSession();
3102
+ if (unsubChat) unsubChat();
2113
3103
  };
2114
3104
  });
2115
3105
  ctx.effect(() => {
@@ -2139,6 +3129,9 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2139
3129
  workspaces,
2140
3130
  conversation,
2141
3131
  chatFileMentions,
3132
+ uiSession,
3133
+ uiConversation,
3134
+ settingsScope,
2142
3135
  t
2143
3136
  })));
2144
3137
  ctx.slots.inject("conversation.session.header.actions", () => ctx.slots.register({