dsh-focus-overlay 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -22,6 +22,7 @@
22
22
  - **Hide the header and composer** — a thin top bar remains, the input area is tucked away
23
23
  - **Fold tool calls** — an assistant turn collapses into a one-line summary
24
24
  - **Quick navigation** — a right-side node navbar with hover preview and click-to-jump
25
+ - **In-focus composer** — a compact input dock appears at the live edge and shares the main composer's draft; click away and it folds into a dot, one click back; AI questions and approvals expand into an in-place answer card, so you never leave focus to reply
25
26
  - **Auto-focus & reminders** — after a reply completes, optionally auto-enter focus at your question; while in focus, get a toast for a new reply or when the AI is waiting on you
26
27
  - **Hotkey F** — press `F` anywhere to enter focus mode instantly (toggleable in settings); typing in an input never triggers it
27
28
 
@@ -56,13 +57,14 @@ Once in focus mode, an assistant turn no longer shows every step — it folds in
56
57
  | Tool-call folding | Tool calls / commands / context injections fold into a categorized summary line (commands / edits / searches / reads / directory listings / subagents / todos / goals / workflows / skills / questions / plans / background jobs) |
57
58
  | Precise scroll preservation | Opens at the message you're reading in the chat (aligned by `seq`), not from the top |
58
59
  | Right-side node navbar | One dot per user message; active pill follows scroll, hover preview, click to jump, auto-hidden under 2 messages |
59
- | Back to latest | A centered "↓ back to latest" floating button appears when you scroll away from the bottom |
60
+ | Back to latest | A centered "↓" floating button when you scroll away from the bottom with no draft |
61
+ | In-focus composer | The compact input bar appears at the live edge and shares the main composer's draft — `Enter` sends (queued while the AI runs); with a draft, clicking the conversation folds it into a blue-dot pill, one click to expand |
60
62
  | File mentions | Wires `chatFileMentions`, turning inline-code tokens that name real files into clickable links |
61
63
  | i18n | Chinese / English copy, follows the UI language |
62
64
  | Plugin configuration card | A collapsible card under "Settings → Plugins → Plugin configuration": navbar toggle, open-position strategy, text-area width — persisted to `localStorage` |
63
65
  | Auto-enter focus | After a reply **completes normally**, auto-open focus at your question; abnormal endings (stop / error / max-tokens / interrupt) never fire |
64
66
  | F hotkey | Press `F` anywhere to enter focus mode instantly (default on, toggleable in settings); never fires while typing in an input, auto-repeat ignored |
65
- | Reply / waiting reminders | In focus, a "New reply ready + View" toast on completion; an "AI is waiting for your reply + Reply" toast on a question/approval, auto-cleared once answered |
67
+ | Reply / waiting reminders | In focus, a "New reply ready + View" toast on completion; a question/approval raises "AI is waiting for your reply + Answer", expanding an in-place answer card (options / free text / allow / deny) — auto-cleared once answered, never leaving focus |
66
68
  | DSH-better-sidebar compatibility | While in focus, auto-hide its top-right panel toggle buttons and any open right/bottom panels (and release the squeezed layout); restored on exit |
67
69
 
68
70
  ## Install
@@ -108,8 +110,9 @@ Restart `dsh web` after install.
108
110
 
109
111
  1. Open a session and click the **"Focus"** button in the session header action row (or press `F` — toggleable in settings).
110
112
  2. You enter the full-screen focus view: a thin top bar (session title + exit) and the conversation only, with tool steps folded into summary lines.
111
- 3. Hover the right-side dots to preview, click to jump; a centered "↓ back to latest" button appears when you're not at the bottom.
112
- 4. Press `Esc` or click "Exit focus" to return the original UI is untouched.
113
+ 3. Hover the right-side dots to preview, click to jump; the compact input bar appears when you reach the very bottom (a "↓" button shows when you scroll away with no draft).
114
+ 4. Reply right in the bar: `Enter` sends, `Shift+Enter` newlines (queued while the AI runs), drafts shared with the main composer; clicking the conversation folds it into a blue-dot pill. AI questions/approvals raise an "Answer" card for in-place options, free text, or approvals.
115
+ 5. `Esc` peels back layer by layer (answer card → input bar → focus mode); or click "Exit focus" to return — the original UI is untouched.
113
116
 
114
117
  > **Compatibility note**: if [DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) is also installed, entering focus mode auto-hides its two top-right panel toggle buttons and any open right/bottom panels (releasing the squeezed layout); they're restored as they were on exit, without touching that plugin's layout state.
115
118
 
package/README.md CHANGED
@@ -54,13 +54,14 @@
54
54
  | 工具调用折叠 | 工具调用 / 命令 / 上下文注入按类型计数折叠成摘要行(命令/编辑/搜索/读取/列目录/子代理/待办/目标/工作流/技能/提问/计划/后台任务) |
55
55
  | 精确保留位置 | 进入专注时定位到聊天视图中正在阅读的消息(按 `seq` 对齐),而非从头开始 |
56
56
  | 右侧节点导航条 | 每个 user 消息一个圆点,激活药丸跟随滚动、悬停预览、点击平滑跳转,少于 2 条自动隐藏 |
57
- | 回到最新 | 离开底部时显示居中的「↓ 回到最新」悬浮按钮 |
57
+ | 回到最新 | 无草稿且离开底部时,底部居中显示「↓」悬浮按钮 |
58
+ | 专注内输入条 | 滚到最底部自动展开精简输入框,与主输入框共享同一份草稿;`Enter` 发送(AI 忙时自动排队);有草稿时点击对话区自动收起为带蓝点的圆钮,一键展开续写 |
58
59
  | 文件提及 | 接 `chatFileMentions`,内联代码命中真实文件时变成可点链接 |
59
60
  | i18n | 中 / 英文案,跟随界面语言 |
60
61
  | 插件配置卡片 | 在「设置 → 插件 → 插件配置」中的可折叠卡片,含导航条开关、打开定位策略、文字区宽度,持久化到 `localStorage` |
61
62
  | 自动进入专注 | 回复**正常完成**后自动打开专注并定位到本轮你的提问;异常结束(停止 / 报错 / 超 token / 打断)不触发 |
62
63
  | F 键快捷键 | 任意界面按 `F` 立即进入专注(默认开,可在设置中关闭);输入框内打字不触发,长按自动忽略 |
63
- | 回复 / 等待提醒 | 专注中回复完成弹「新回复已生成 + 查看」;AI 提问 / 审批等待时弹「AI 正在等待你的回复 + 去回复」,回答完自动消失 |
64
+ | 回复 / 等待提醒 | 专注中回复完成弹「新回复已生成 + 查看」;AI 提问 / 审批等待时弹「AI 正在等待你的回复 + 去回答」,原地展开答题卡(选项 / 自由文本 / 允许 / 拒绝),作答后自动消失——全程无需退出专注 |
64
65
  | 兼容 DSH-better-sidebar | 进入专注时自动隐藏其右上角的收放面板按钮与已开启的右侧/底部面板,并释放被挤压的布局;退出专注后按原状态还原 |
65
66
 
66
67
  ## 安装
@@ -106,8 +107,9 @@ dsh web
106
107
 
107
108
  1. 打开任意会话,点击标题栏操作区的 **「专注」** 按钮(或直接按 `F` 键,可在设置中关闭该快捷键)。
108
109
  2. 进入全屏专注视图:顶部只有一条极窄栏(会话标题 + 退出),正文只显示你与 AI 的对话,工具步骤折叠为摘要行。
109
- 3. 右侧导航圆点可悬停预览、点击跳转;离开底部时右下出现居中的「↓ 回到最新」。
110
- 4. `Esc` 或点「退出专注」返回原界面,原位置/状态保持不变。
110
+ 3. 右侧导航圆点可悬停预览、点击跳转;滚到最底部时底部居中自动展开精简输入条(无草稿离开底部则显示「↓」按钮)。
111
+ 4. 在输入条里直接回复:`Enter` 发送、`Shift+Enter` 换行(AI 忙时自动排队),草稿与主输入框互通;点击对话区域自动收起为带蓝点的圆钮。AI 提问 / 审批时弹「去回答」卡片,就地进行选项 / 文本 / 审批作答。
112
+ 5. 按 `Esc` 逐层退出(答题卡 → 输入条 → 专注模式),或点「退出专注」返回原界面,原位置/状态保持不变。
111
113
 
112
114
  > **兼容提示**:若同时安装了 [DSH-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar),进入专注模式会自动隐藏其右上角的两个收放面板按钮以及已开启的右侧/底部面板(并释放被挤压的布局);退出专注后按原状态恢复,不会改动该插件的面板布局。
113
115
 
package/lib/client.js CHANGED
@@ -258,14 +258,6 @@ window.__ModuleLoader__.load({
258
258
  anchorSeq: lastUserSeq(nodes)
259
259
  };
260
260
  }
261
- /** True while the session has a pending interaction — the AI is blocked waiting
262
- * for the user (to answer a question or approve an action). It stays true for
263
- * the whole wait (`running` remains true throughout) and clears as soon as the
264
- * user answers, so it is the live signal for "questions still unanswered". */
265
- function hasPendingInteraction(snap) {
266
- const pending = snap && snap.pending;
267
- return Array.isArray(pending) && pending.length > 0;
268
- }
269
261
  /** True once the persisted onboarding flag matches the current intro version.
270
262
  * Kept as a pure comparison so the "already seen?" decision is testable and
271
263
  * can be versioned: bump the version to re-show the intro after a major change. */
@@ -286,6 +278,146 @@ window.__ModuleLoader__.load({
286
278
  const k = typeof e.key === "string" ? e.key : "";
287
279
  return k === "f" || k === "F";
288
280
  }
281
+ /**
282
+ * Decide the bottom dock's form from the live facts. Priority order mirrors the
283
+ * agreed design:
284
+ *
285
+ * 1. pending (the AI is blocked waiting) owns the region — the answer card when
286
+ * the user opened it (the waiting toast morphs in place), otherwise the
287
+ * waiting toast itself. A plain prompt cannot answer `ask_user_question`, so
288
+ * while pending the input bar is suppressed entirely (the official main view
289
+ * likewise lets the question UI take over the composer seat).
290
+ * 2. the input bar while the user is IN it: focused, or the one-render handoff
291
+ * from a pill click (`engaged`, focus not landed yet). With a draft this is
292
+ * the only thing that keeps the bar up — clicking the conversation area
293
+ * blurs the textarea and the bar folds itself into the pill (draft safe,
294
+ * blue dot, one click back). There is no manual hide.
295
+ * 3. also the bar, as ambient chrome, while empty and reading at the live
296
+ * edge — a slim placeholder offering the composer.
297
+ * 4. otherwise: a draft (unfocused) shows the pill; no draft and scrolled away
298
+ * shows the jump-to-bottom button.
299
+ */
300
+ function bottomForm(o) {
301
+ if (o.pending) return o.cardOpen ? "card" : "toast";
302
+ if (o.engaged || o.focused || o.draftEmpty && o.inZone) return "bar";
303
+ return o.draftEmpty ? "tobottom" : "pill";
304
+ }
305
+ /**
306
+ * Bottom-zone membership with hysteresis: entering requires the viewport edge
307
+ * within {@link BOTTOM_ENTER_PX} of the scroll end, leaving requires drifting
308
+ * past {@link BOTTOM_EXIT_PX}. Between the two thresholds the previous verdict
309
+ * stands, so slow scrolling near the end (or streaming content quietly growing
310
+ * the scrollHeight under a still reader) never flaps the dock between forms.
311
+ */
312
+ function bottomZoneAfter(prev, distance) {
313
+ if (distance <= 48) return true;
314
+ if (distance >= 160) return false;
315
+ return prev;
316
+ }
317
+ /**
318
+ * Index of the conversation-turn dot the nav rail should highlight, from live
319
+ * scroll geometry. The rail is a position indicator, so the decision is pure
320
+ * math over measured anchor tops (document order) plus the distance to the
321
+ * scroll end:
322
+ *
323
+ * 1. Bottom force: within {@link BOTTOM_ENTER_PX} of the scroll end the last
324
+ * turn owns the highlight, whatever its height. Without this rule a short
325
+ * final turn could never light its own dot — with less than a viewport of
326
+ * content below its anchor, that anchor physically cannot reach the top
327
+ * threshold even pinned at the very bottom, so the highlight would stick
328
+ * to an earlier turn forever.
329
+ * 2. Geometric scan: the last anchor whose top sits at/above the
330
+ * {@link NAV_TOP_PX} line — "which turn is at the top of the viewport".
331
+ * Non-finite entries (a stale anchor slot after the item list shifted) are
332
+ * skipped without stopping the scan, matching the old ref-null behavior.
333
+ * 3. Top clamp: every real anchor still below the line means the reader is
334
+ * above the first message's threshold (scrollTop ≈ 0 with leading
335
+ * padding), and the first turn owns the position.
336
+ *
337
+ * A bottom-forced index may land on a stale slot; the caller maps it back to
338
+ * the nearest existing anchor.
339
+ */
340
+ function activeNavIndex(tops, bottomDistance, topPx = 96, bottomPx = 48) {
341
+ if (!tops.length) return -1;
342
+ if (bottomDistance <= bottomPx) return tops.length - 1;
343
+ let cur = -1;
344
+ for (let i = 0; i < tops.length; i++) {
345
+ const top = tops[i];
346
+ if (!Number.isFinite(top)) continue;
347
+ if (top <= topPx) cur = i;
348
+ else break;
349
+ }
350
+ return cur >= 0 ? cur : 0;
351
+ }
352
+ /** A question is answered when at least one option is selected or the custom
353
+ * text is non-blank — the same completeness rule the official question
354
+ * composer applies before enabling submit. */
355
+ function questionAnswered(d) {
356
+ if (!d) return false;
357
+ if (d.selected.length > 0) return true;
358
+ return d.custom.trim() !== "";
359
+ }
360
+ /** The whole batch must be answered: one `ask()` is one answer covering every
361
+ * question, never split per question. An empty question list is never
362
+ * complete (nothing to send — submit stays disabled); malformed null entries
363
+ * are skipped, matching encodeAnswer, so one bad row cannot deadlock the
364
+ * answerable majority. */
365
+ function allAnswered(questions, drafts) {
366
+ if (!questions || questions.length === 0) return false;
367
+ for (const q of questions) {
368
+ if (!q) continue;
369
+ if (!questionAnswered(drafts[q.id])) return false;
370
+ }
371
+ return true;
372
+ }
373
+ /**
374
+ * Encode the complete answer batch in the wire shape `question/respond`
375
+ * expects (`AskUserQuestionAnswer`). Mirrors the official encoder's
376
+ * exclusivity rule: a single-select question carries EITHER the selected
377
+ * option OR the custom text (`selected` is cleared when custom is present —
378
+ * never both, which would be a contradictory answer); multi-select may carry
379
+ * both. Option labels go back verbatim — the harness's recommendation suffix
380
+ * is part of the label the asker must see echoed — and custom text is sent
381
+ * trimmed, only when present.
382
+ */
383
+ function encodeAnswer(questions, drafts) {
384
+ const answers = [];
385
+ for (const q of questions || []) {
386
+ if (!q) continue;
387
+ const d = drafts[q.id] || {
388
+ selected: [],
389
+ custom: ""
390
+ };
391
+ const custom = d.custom.trim();
392
+ const selected = custom === "" || q.multiSelect ? [...d.selected] : [];
393
+ const item = {
394
+ id: q.id,
395
+ selected
396
+ };
397
+ if (custom) item.custom = custom;
398
+ answers.push(item);
399
+ }
400
+ return { answers };
401
+ }
402
+ /** Split the harness's recommendation marker off an option label for display
403
+ * (the marker becomes a badge); the full label stays what the wire echoes
404
+ * back. Lenient like the official parser: ASCII or full-width parentheses,
405
+ * "recommended" in any case, or the Chinese 推荐. */
406
+ function parseRecommendedLabel(label) {
407
+ if (typeof label !== "string") return {
408
+ label,
409
+ recommended: false
410
+ };
411
+ const rest = label.replace(/\s*[((]\s*(?:recommended|推荐)\s*[))]\s*$/i, "");
412
+ if (rest === label || rest === "") return {
413
+ label,
414
+ recommended: false
415
+ };
416
+ return {
417
+ label: rest,
418
+ recommended: true
419
+ };
420
+ }
289
421
  //#endregion
290
422
  //#region src/client/settings.ts
291
423
  const KEY = "dsh-focus-overlay:prefs";
@@ -350,6 +482,408 @@ window.__ModuleLoader__.load({
350
482
  }
351
483
  };
352
484
  //#endregion
485
+ //#region src/client/Composer.tsx
486
+ /**
487
+ * The focus-mode bottom dock: the compact composer region that replaces the
488
+ * old lone "jump to bottom" button at the bottom center of the overlay.
489
+ *
490
+ * Forms (mutually exclusive, decided by the pure `bottomForm` selector):
491
+ * - `bar` the compact input bar — the shared official draft, Enter sends
492
+ * - `pill` the collapsed affordance after a manual hide (blue dot = draft)
493
+ * - `tobottom` the classic jump-to-bottom button
494
+ * - `card` the pending answer card (options / free text / approve-deny),
495
+ * opened in place from the waiting toast
496
+ * (`toast` is decided here too, but rendered by the overlay's toast element.)
497
+ *
498
+ * The bar shares the MAIN composer's draft through the official per-session
499
+ * input machine (`conversation.input.for(binding.ctx)`): setDraft / submit are
500
+ * the same public action face every session-scope component gets, so whatever
501
+ * the user typed before entering focus mode is already here, and whatever they
502
+ * type here is still in the main composer after leaving — one draft, both
503
+ * views, persisted by the host like always. When the input service is absent
504
+ * (older dsh), the bar degrades to a plugin-local draft sent through the
505
+ * session face's `prompt` verb — the same wire call the official default sink
506
+ * makes.
507
+ */
508
+ /** Resolve the per-session input facade for a session's agent scope. Returns
509
+ * null when the conversation service (or its input registry) is absent so the
510
+ * caller can fall back to a plugin-local draft. Pure resolution, memoized per
511
+ * binding — the registry hands back the same stable facade every time. */
512
+ function useInputFace(conversation, actx) {
513
+ return (0, react.useMemo)(() => {
514
+ try {
515
+ if (conversation && conversation.input && actx) return conversation.input.for(actx);
516
+ } catch {}
517
+ return null;
518
+ }, [conversation, actx]);
519
+ }
520
+ /** Subscribe to the input machine's published state (draft, queue, reference
521
+ * occurrences). Mirrors the overlay's session-snapshot hook: read once, then
522
+ * re-read on every store flush. */
523
+ function useInputState(input) {
524
+ const [snap, setSnap] = (0, react.useState)(() => {
525
+ try {
526
+ return input && input.state ? input.state.getSnapshot() : null;
527
+ } catch {
528
+ return null;
529
+ }
530
+ });
531
+ (0, react.useEffect)(() => {
532
+ if (!input || !input.state) {
533
+ setSnap(null);
534
+ return;
535
+ }
536
+ try {
537
+ setSnap(input.state.getSnapshot());
538
+ return input.state.subscribe(() => setSnap(input.state.getSnapshot()));
539
+ } catch {
540
+ return;
541
+ }
542
+ }, [input]);
543
+ return snap;
544
+ }
545
+ function OptionRow(props) {
546
+ const { t, index, label, description, multi, on, onToggle } = props;
547
+ const parsed = parseRecommendedLabel(label);
548
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
549
+ type: "button",
550
+ className: "fm-opt" + (on ? " fm-opt-on" : ""),
551
+ onClick: onToggle,
552
+ "aria-pressed": on,
553
+ children: [multi ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
554
+ className: "fm-opt-check" + (on ? " fm-opt-check-on" : ""),
555
+ children: on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline14, {}) : null
556
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
557
+ className: "fm-opt-num",
558
+ children: index + 1
559
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
560
+ className: "fm-opt-copy",
561
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
562
+ className: "fm-opt-line",
563
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
564
+ className: "fm-opt-label",
565
+ children: parsed.label
566
+ }), parsed.recommended ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
567
+ className: "fm-opt-badge",
568
+ children: t("answer.recommended")
569
+ }) : null]
570
+ }), description ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
571
+ className: "fm-opt-desc",
572
+ children: description
573
+ }) : null]
574
+ })]
575
+ });
576
+ }
577
+ function AnswerCard(props) {
578
+ const { t, width, wait, onClose } = props;
579
+ const isQuestion = wait.kind === "question";
580
+ const questions = isQuestion ? wait.payload && wait.payload.questions ? wait.payload.questions : [] : [];
581
+ const [drafts, setDrafts] = (0, react.useState)({});
582
+ const [busy, setBusy] = (0, react.useState)(false);
583
+ const [error, setError] = (0, react.useState)(null);
584
+ const patch = (id, next) => setDrafts((prev) => {
585
+ const cur = prev[id] || {
586
+ selected: [],
587
+ custom: ""
588
+ };
589
+ return {
590
+ ...prev,
591
+ [id]: {
592
+ ...cur,
593
+ ...next
594
+ }
595
+ };
596
+ });
597
+ const toggle = (qid, multi, label) => setDrafts((prev) => {
598
+ const cur = prev[qid] || {
599
+ selected: [],
600
+ custom: ""
601
+ };
602
+ if (multi) {
603
+ const selected = cur.selected.includes(label) ? cur.selected.filter((l) => l !== label) : [...cur.selected, label];
604
+ return {
605
+ ...prev,
606
+ [qid]: {
607
+ ...cur,
608
+ selected
609
+ }
610
+ };
611
+ }
612
+ return {
613
+ ...prev,
614
+ [qid]: {
615
+ selected: [label],
616
+ custom: ""
617
+ }
618
+ };
619
+ });
620
+ const respond = async (result) => {
621
+ setBusy(true);
622
+ setError(null);
623
+ try {
624
+ const receipt = await wait.respond(result);
625
+ if (receipt && receipt.accepted === false) {
626
+ setError(t("answer.rejected") + (receipt.reason ? `: ${receipt.reason}` : ""));
627
+ setBusy(false);
628
+ }
629
+ } catch (err) {
630
+ setError(err instanceof Error ? err.message : String(err));
631
+ setBusy(false);
632
+ }
633
+ };
634
+ const submitAnswers = () => {
635
+ if (!allAnswered(questions, drafts)) {
636
+ setError(t("answer.incomplete"));
637
+ return;
638
+ }
639
+ respond({
640
+ ok: true,
641
+ value: {
642
+ sessionId: wait.sessionId,
643
+ answer: encodeAnswer(questions, drafts)
644
+ }
645
+ });
646
+ };
647
+ const decide = (outcome) => respond({
648
+ ok: true,
649
+ value: {
650
+ sessionId: wait.sessionId,
651
+ approvalId: wait.payload.approvalId,
652
+ outcome
653
+ }
654
+ });
655
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
656
+ className: "fm-card" + (isQuestion ? "" : " fm-card-approval"),
657
+ style: { maxWidth: Math.min(width, 748) },
658
+ children: [
659
+ isQuestion ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
660
+ className: "fm-card-head",
661
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
662
+ className: "fm-card-heading",
663
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
664
+ className: "fm-card-eyebrow",
665
+ children: t("reply.waiting")
666
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
667
+ className: "fm-card-title",
668
+ children: t("answer.title")
669
+ })]
670
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
671
+ type: "button",
672
+ className: "fm-card-close",
673
+ title: t("answer.close"),
674
+ "aria-label": t("answer.close"),
675
+ onClick: onClose,
676
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {})
677
+ })]
678
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
679
+ className: "fm-card-head",
680
+ children: [
681
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-card-dot" }),
682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
683
+ className: "fm-card-strip-title",
684
+ children: t("answer.approvalTitle")
685
+ }),
686
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
687
+ type: "button",
688
+ className: "fm-card-close",
689
+ title: t("answer.close"),
690
+ "aria-label": t("answer.close"),
691
+ onClick: onClose,
692
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {})
693
+ })
694
+ ]
695
+ }),
696
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
697
+ className: "fm-card-body",
698
+ children: isQuestion ? questions.map((q, i) => {
699
+ const d = drafts[q.id] || {
700
+ selected: [],
701
+ custom: ""
702
+ };
703
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
704
+ className: "fm-card-q",
705
+ children: [
706
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
707
+ className: "fm-card-q-text",
708
+ children: [questions.length > 1 ? `${i + 1}. ` : "", q.question]
709
+ }),
710
+ q.detail ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
711
+ className: "fm-card-detail",
712
+ children: q.detail
713
+ }) : null,
714
+ q.options && q.options.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
715
+ className: "fm-card-opts",
716
+ role: q.multiSelect ? "group" : "radiogroup",
717
+ children: q.options.map((o, oi) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionRow, {
718
+ t,
719
+ index: oi,
720
+ label: o.label,
721
+ description: o.description,
722
+ multi: !!q.multiSelect,
723
+ on: d.selected.includes(o.label),
724
+ onToggle: () => toggle(q.id, !!q.multiSelect, o.label)
725
+ }, o.label))
726
+ }) : null,
727
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
728
+ className: "fm-card-custom",
729
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
730
+ className: "fm-card-field",
731
+ type: "text",
732
+ value: d.custom,
733
+ placeholder: t("answer.custom"),
734
+ onChange: (e) => patch(q.id, { custom: e.target.value })
735
+ })
736
+ })
737
+ ]
738
+ }, q.id);
739
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
740
+ className: "fm-card-q",
741
+ children: [wait.payload && wait.payload.reason ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
742
+ className: "fm-card-q-text",
743
+ children: wait.payload.reason
744
+ }) : null, wait.payload && wait.payload.toolName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
745
+ className: "fm-card-detail",
746
+ children: wait.payload.toolName
747
+ }) : null]
748
+ })
749
+ }),
750
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
751
+ className: "fm-card-foot",
752
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
753
+ className: "fm-card-err",
754
+ children: error
755
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
756
+ className: "fm-card-actions",
757
+ children: isQuestion ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
758
+ variant: "primary",
759
+ size: "sm",
760
+ disabled: busy,
761
+ onClick: submitAnswers,
762
+ children: t("answer.submit")
763
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
764
+ variant: "primary",
765
+ size: "sm",
766
+ disabled: busy,
767
+ onClick: () => decide("allowed-once"),
768
+ children: t("answer.allow")
769
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
770
+ variant: "outline",
771
+ size: "sm",
772
+ disabled: busy,
773
+ onClick: () => decide("rejected"),
774
+ children: t("answer.reject")
775
+ })] })
776
+ })]
777
+ })
778
+ ]
779
+ });
780
+ }
781
+ function InputBar(props) {
782
+ const { t, width, value, queueCount, occCount, errorLine, textareaRef, onFocusChange, onChange, onSend } = props;
783
+ const empty = value.trim() === "";
784
+ const onKeyDown = (e) => {
785
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && e.nativeEvent.keyCode !== 229) {
786
+ e.preventDefault();
787
+ if (!empty) onSend();
788
+ }
789
+ };
790
+ const autoGrow = (el) => {
791
+ el.style.height = "auto";
792
+ el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
793
+ };
794
+ (0, react.useEffect)(() => {
795
+ if (textareaRef.current) autoGrow(textareaRef.current);
796
+ }, [value, textareaRef]);
797
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
798
+ className: "fm-bar",
799
+ style: { maxWidth: Math.min(width, 680) },
800
+ children: [
801
+ errorLine ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
802
+ className: "fm-bar-note fm-bar-note-error",
803
+ children: errorLine
804
+ }) : null,
805
+ occCount > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
806
+ className: "fm-bar-note",
807
+ children: t("composer.occurrences")
808
+ }) : null,
809
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
810
+ className: "fm-bar-row",
811
+ children: [
812
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
813
+ ref: textareaRef,
814
+ className: "fm-bar-text",
815
+ rows: 1,
816
+ value,
817
+ placeholder: t("composer.placeholder"),
818
+ onChange: (e) => {
819
+ onChange(e.target.value);
820
+ autoGrow(e.target);
821
+ },
822
+ onKeyDown,
823
+ onFocus: () => onFocusChange(true),
824
+ onBlur: () => onFocusChange(false)
825
+ }),
826
+ queueCount > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
827
+ className: "fm-bar-queue",
828
+ children: t("composer.queued", { n: queueCount })
829
+ }) : null,
830
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
831
+ type: "button",
832
+ className: "fm-bar-send",
833
+ title: t("composer.send"),
834
+ "aria-label": t("composer.send"),
835
+ disabled: empty,
836
+ onMouseDown: (e) => e.preventDefault(),
837
+ onClick: onSend,
838
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSendOutline14, {})
839
+ })
840
+ ]
841
+ })
842
+ ]
843
+ });
844
+ }
845
+ function FocusBottomDock(props) {
846
+ const { t, width, form, wait, onCardClose, draft, queueCount, occCount, errorLine, textareaRef, onFocusChange, onDraftChange, onSend, onExpand, onJumpBottom } = props;
847
+ if (form === "toast") return null;
848
+ if (form === "card") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnswerCard, {
849
+ t,
850
+ width,
851
+ wait,
852
+ onClose: onCardClose
853
+ });
854
+ if (form === "bar") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InputBar, {
855
+ t,
856
+ width,
857
+ value: draft,
858
+ queueCount,
859
+ occCount,
860
+ errorLine,
861
+ textareaRef,
862
+ onFocusChange,
863
+ onChange: onDraftChange,
864
+ onSend
865
+ });
866
+ if (form === "pill") {
867
+ const hasDraft = draft.trim() !== "";
868
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
869
+ type: "button",
870
+ className: "fm-pill",
871
+ title: t("composer.expand"),
872
+ "aria-label": t("composer.expand"),
873
+ onClick: onExpand,
874
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconEditOutline16, {}), hasDraft ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-pill-dot" }) : null]
875
+ });
876
+ }
877
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
878
+ type: "button",
879
+ className: "fm-pill",
880
+ title: t("backToLatest"),
881
+ "aria-label": t("backToLatest"),
882
+ onClick: onJumpBottom,
883
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 16 })
884
+ });
885
+ }
886
+ //#endregion
353
887
  //#region src/client/FocusView.tsx
354
888
  let focusOn = false;
355
889
  let pendingAnchorKey = null;
@@ -490,32 +1024,41 @@ window.__ModuleLoader__.load({
490
1024
  }
491
1025
  }
492
1026
  const [activeKey, setActiveKey] = (0, react.useState)(navKeys.length ? navKeys[navKeys.length - 1] : null);
493
- const [atBottom, setAtBottom] = (0, react.useState)(true);
1027
+ const [zone, setZone] = (0, react.useState)(true);
494
1028
  const rafId = (0, react.useRef)(null);
495
1029
  const overlayRef = (0, react.useRef)(null);
1030
+ const [cardOpen, setCardOpen] = (0, react.useState)(false);
1031
+ const [dockFocused, setDockFocused] = (0, react.useState)(false);
1032
+ const [engaged, setEngaged] = (0, react.useState)(false);
1033
+ const [localDraft, setLocalDraft] = (0, react.useState)("");
1034
+ const taRef = (0, react.useRef)(null);
1035
+ const focusBarOnce = (0, react.useRef)(false);
496
1036
  const computeActive = () => {
497
1037
  if (!bodyEl) return null;
498
- let cur = null;
499
- for (const k of navKeys) {
1038
+ const bodyTop = bodyEl.getBoundingClientRect().top;
1039
+ const i = activeNavIndex(navKeys.map((k) => {
500
1040
  const el = anchors[k];
501
- if (!el) continue;
502
- if (el.getBoundingClientRect().top - bodyEl.getBoundingClientRect().top <= 96) cur = k;
503
- else break;
504
- }
505
- return cur;
1041
+ return el ? el.getBoundingClientRect().top - bodyTop : Number.POSITIVE_INFINITY;
1042
+ }), bottomDistance());
1043
+ for (let j = i; j >= 0; j--) if (anchors[navKeys[j]]) return navKeys[j];
1044
+ return null;
506
1045
  };
507
- const isNearBottom = () => {
508
- if (!bodyEl) return true;
509
- return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 48;
1046
+ const bottomDistance = () => {
1047
+ if (!bodyEl) return Number.POSITIVE_INFINITY;
1048
+ return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight;
510
1049
  };
511
- const handleScroll = () => {
1050
+ const scheduleRecompute = () => {
512
1051
  if (rafId.current !== null) return;
513
1052
  rafId.current = requestAnimationFrame(() => {
514
1053
  rafId.current = null;
515
1054
  setActiveKey(computeActive());
516
- setAtBottom(isNearBottom());
1055
+ setZone((prev) => bottomZoneAfter(prev, bottomDistance()));
517
1056
  });
518
1057
  };
1058
+ const scheduleRef = (0, react.useRef)(scheduleRecompute);
1059
+ (0, react.useEffect)(() => {
1060
+ scheduleRef.current = scheduleRecompute;
1061
+ });
519
1062
  const scrollToKey = (key, smooth) => {
520
1063
  const el = anchors[key];
521
1064
  if (!el || !bodyEl) return;
@@ -573,18 +1116,51 @@ window.__ModuleLoader__.load({
573
1116
  setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
574
1117
  }
575
1118
  }, []);
1119
+ (0, react.useEffect)(() => {
1120
+ setZone(bodyEl ? bottomZoneAfter(false, bottomDistance()) : true);
1121
+ }, []);
1122
+ (0, react.useEffect)(() => {
1123
+ const el = bodyEl;
1124
+ if (!el || typeof ResizeObserver === "undefined") return;
1125
+ const ro = new ResizeObserver(() => scheduleRef.current());
1126
+ ro.observe(el);
1127
+ return () => ro.disconnect();
1128
+ }, []);
1129
+ (0, react.useEffect)(() => {
1130
+ scheduleRef.current();
1131
+ }, [snap, prefs.width]);
1132
+ (0, react.useEffect)(() => () => {
1133
+ if (rafId.current !== null) {
1134
+ cancelAnimationFrame(rafId.current);
1135
+ rafId.current = null;
1136
+ }
1137
+ }, []);
576
1138
  (0, react.useEffect)(() => {
577
1139
  overlayRef.current?.focus();
1140
+ }, []);
1141
+ (0, react.useEffect)(() => {
578
1142
  const onKey = (e) => {
579
- if (e.key === "Escape") {
580
- e.preventDefault();
581
- e.stopPropagation();
582
- focusStore.set(false);
1143
+ if (e.key !== "Escape") return;
1144
+ if (e.isComposing || e.keyCode === 229) return;
1145
+ e.preventDefault();
1146
+ e.stopPropagation();
1147
+ if (cardOpen) {
1148
+ setCardOpen(false);
1149
+ return;
1150
+ }
1151
+ if (dockFocused || engaged) {
1152
+ hideBar();
1153
+ return;
583
1154
  }
1155
+ focusStore.set(false);
584
1156
  };
585
1157
  window.addEventListener("keydown", onKey, true);
586
1158
  return () => window.removeEventListener("keydown", onKey, true);
587
- }, []);
1159
+ }, [
1160
+ cardOpen,
1161
+ dockFocused,
1162
+ engaged
1163
+ ]);
588
1164
  const loadImage = (0, react.useCallback)((attachment) => {
589
1165
  if (!conversation || !currentId) return Promise.reject(/* @__PURE__ */ new Error("dsh-focus-overlay: conversation service unavailable"));
590
1166
  return conversation.resolveImage(currentId, attachment);
@@ -606,6 +1182,81 @@ window.__ModuleLoader__.load({
606
1182
  return;
607
1183
  }
608
1184
  }, [chatFileMentions, workspaces]);
1185
+ const binding = (0, react.useMemo)(() => sessions && currentId != null ? sessions.binding(currentId) : void 0, [sessions, currentId]);
1186
+ const inputFace = useInputFace(conversation, binding && binding.ctx);
1187
+ const inputState = useInputState(inputFace);
1188
+ const draftValue = inputFace ? inputState && typeof inputState.draft === "string" ? inputState.draft : "" : localDraft;
1189
+ 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;
1193
+ const onDraftChange = (v) => {
1194
+ if (inputFace) try {
1195
+ inputFace.setDraft(v);
1196
+ return;
1197
+ } catch {}
1198
+ setLocalDraft(v);
1199
+ };
1200
+ const send = () => {
1201
+ const text = draftValue;
1202
+ if (text.trim() === "") return;
1203
+ if (inputFace) try {
1204
+ inputFace.submit("queue");
1205
+ return;
1206
+ } catch {}
1207
+ const face = binding && binding.session;
1208
+ if (face) face.prompt([{
1209
+ type: "text",
1210
+ text
1211
+ }], "queue").then((r) => {
1212
+ if (r && r.ok) setLocalDraft("");
1213
+ }).catch(() => {});
1214
+ };
1215
+ const hideBar = () => {
1216
+ try {
1217
+ taRef.current && taRef.current.blur();
1218
+ } catch {}
1219
+ setEngaged(false);
1220
+ setDockFocused(false);
1221
+ };
1222
+ const expandBar = () => {
1223
+ focusBarOnce.current = true;
1224
+ setEngaged(true);
1225
+ };
1226
+ const onDockFocusChange = (f) => {
1227
+ setDockFocused(f);
1228
+ if (!f) setEngaged(false);
1229
+ };
1230
+ const queueCount = snap && snap.queue ? snap.queue.length : 0;
1231
+ const occCount = inputState && inputState.occurrences ? inputState.occurrences.length : 0;
1232
+ const promptErr = snap && snap.promptError && snap.promptError.op === "send" ? snap.promptError.error : null;
1233
+ const errorLine = promptErr ? promptErr.message || promptErr.code || "send failed" : null;
1234
+ const form = bottomForm({
1235
+ pending: pendingCount > 0,
1236
+ cardOpen,
1237
+ draftEmpty,
1238
+ inZone: zone,
1239
+ focused: dockFocused,
1240
+ engaged
1241
+ });
1242
+ (0, react.useEffect)(() => {
1243
+ if (pendingCount === 0) {
1244
+ if (cardOpen) setCardOpen(false);
1245
+ return;
1246
+ }
1247
+ focusBarOnce.current = false;
1248
+ setDockFocused(false);
1249
+ setEngaged(false);
1250
+ }, [pendingCount, cardOpen]);
1251
+ (0, react.useEffect)(() => {
1252
+ if (form === "bar" && focusBarOnce.current) {
1253
+ focusBarOnce.current = false;
1254
+ try {
1255
+ taRef.current && taRef.current.focus();
1256
+ } catch {}
1257
+ setEngaged(false);
1258
+ }
1259
+ }, [form]);
609
1260
  let title = "Focus Mode";
610
1261
  if (currentId && listState && listState.byId && listState.byId[currentId]) {
611
1262
  const row = listState.byId[currentId];
@@ -675,7 +1326,23 @@ window.__ModuleLoader__.load({
675
1326
  }, "fm-partial"));
676
1327
  if (running) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
677
1328
  className: "fm-running",
678
- children: t("running")
1329
+ role: "status",
1330
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1331
+ className: "fm-running-text",
1332
+ children: [
1333
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1334
+ className: "fm-running-dash",
1335
+ "aria-hidden": "true",
1336
+ children: "–"
1337
+ }),
1338
+ t("running"),
1339
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
1340
+ className: "fm-running-dash",
1341
+ "aria-hidden": "true",
1342
+ children: "–"
1343
+ })
1344
+ ]
1345
+ })
679
1346
  }, "fm-running"));
680
1347
  body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
681
1348
  className: "fm-inner",
@@ -701,18 +1368,7 @@ window.__ModuleLoader__.load({
701
1368
  })]
702
1369
  }, k))
703
1370
  }) : null;
704
- const toBottom = !atBottom ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
705
- className: "fm-tobottom-wrap",
706
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
707
- variant: "outline",
708
- size: "sm",
709
- className: "fm-tobottom",
710
- title: t("backToLatest"),
711
- onClick: scrollToBottom,
712
- children: "↓"
713
- })
714
- }) : null;
715
- const waiting = hasPendingInteraction(snap);
1371
+ const waiting = pendingCount > 0 && !cardOpen;
716
1372
  const donePing = useDonePing();
717
1373
  const [showDoneNotice, setShowDoneNotice] = (0, react.useState)(false);
718
1374
  (0, react.useEffect)(() => {
@@ -740,8 +1396,8 @@ window.__ModuleLoader__.load({
740
1396
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
741
1397
  variant: "primary",
742
1398
  size: "sm",
743
- onClick: () => focusStore.set(false),
744
- children: t("reply.respond")
1399
+ onClick: () => setCardOpen(true),
1400
+ children: t("answer.open")
745
1401
  })
746
1402
  ]
747
1403
  }) : showDoneNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -785,11 +1441,30 @@ window.__ModuleLoader__.load({
785
1441
  ref: (el) => {
786
1442
  bodyEl = el;
787
1443
  },
788
- onScroll: handleScroll,
1444
+ onScroll: scheduleRecompute,
789
1445
  children: body
790
1446
  }),
791
1447
  nav,
792
- toBottom,
1448
+ currentId && snap !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1449
+ className: "fm-dock",
1450
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusBottomDock, {
1451
+ t,
1452
+ width: prefs.width,
1453
+ form,
1454
+ wait,
1455
+ onCardClose: () => setCardOpen(false),
1456
+ draft: draftValue,
1457
+ queueCount,
1458
+ occCount,
1459
+ errorLine,
1460
+ textareaRef: taRef,
1461
+ onFocusChange: onDockFocusChange,
1462
+ onDraftChange,
1463
+ onSend: send,
1464
+ onExpand: expandBar,
1465
+ onJumpBottom: scrollToBottom
1466
+ })
1467
+ }) : null,
793
1468
  replyNotice
794
1469
  ]
795
1470
  });
@@ -1036,7 +1711,7 @@ window.__ModuleLoader__.load({
1036
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}
1037
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}
1038
1713
  .fm-title{font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1039
- .fm-body{flex:1 1 auto;overflow-y:auto;padding:28px 28px 64px}
1714
+ .fm-body{flex:1 1 auto;overflow-y:auto;padding:28px 28px 110px}
1040
1715
  .fm-inner{max-width:760px;margin:0 auto}
1041
1716
  .fm-msg{margin:0 0 24px}
1042
1717
  .fm-user-msg{margin-bottom:44px}
@@ -1046,7 +1721,32 @@ window.__ModuleLoader__.load({
1046
1721
  .fm-image{max-width:100%;border-radius:8px;margin:8px 0}
1047
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}
1048
1723
  .fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
1049
- .fm-running{color:var(--dsw-alias-label-secondary,#888);font-size:13px;margin:6px 0 16px}
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}}
1050
1750
  .fm-empty{color:var(--dsw-alias-label-secondary,#888);padding:48px 16px;text-align:center}
1051
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}
1052
1752
  .fm-nav:hover{transform:translateY(-50%) translateX(-6px)}
@@ -1058,11 +1758,73 @@ window.__ModuleLoader__.load({
1058
1758
  .fm-nav-dot-active .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);height:22px;opacity:1}
1059
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}
1060
1760
  .fm-nav-dot:hover .fm-nav-tip{display:block}
1061
- .fm-tobottom-wrap{position:absolute;left:50%;transform:translateX(-50%);bottom:18px;z-index:2;pointer-events:none}
1062
- .fm-tobottom{pointer-events:auto;border-radius:100px;min-width:34px;height:34px;padding:0 10px}
1063
- .fm-reply-toast{position:absolute;left:50%;transform:translateX(-50%);bottom:64px;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}
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}
1064
1762
  .fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
1065
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}}
1066
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}
1067
1829
  .fm-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed)}
1068
1830
  .fm-plugin-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
@@ -1129,11 +1891,26 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1129
1891
  "settings.width.hint": "专注模式正文的阅读列宽(480–1200px)",
1130
1892
  "empty": "暂无打开的会话",
1131
1893
  "loading": "正在读取会话…",
1132
- "running": "· 正在工作",
1894
+ "running": "正在工作",
1133
1895
  "reply.ready": "新回复已生成",
1134
1896
  "reply.view": "查看",
1135
1897
  "reply.waiting": "AI 正在等待你的回复",
1136
- "reply.respond": "去回复",
1898
+ "answer.open": "去回答",
1899
+ "answer.title": "等待你的回答",
1900
+ "answer.approvalTitle": "请求审批",
1901
+ "answer.submit": "提交回答",
1902
+ "answer.close": "收起",
1903
+ "answer.custom": "其他答案(可选)",
1904
+ "answer.recommended": "推荐",
1905
+ "answer.incomplete": "请先回答所有问题",
1906
+ "answer.rejected": "提交被拒绝",
1907
+ "answer.allow": "允许",
1908
+ "answer.reject": "拒绝",
1909
+ "composer.placeholder": "输入回复…",
1910
+ "composer.send": "发送",
1911
+ "composer.expand": "展开输入框",
1912
+ "composer.queued": "已排队 {n}",
1913
+ "composer.occurrences": "草稿中包含引用 / 命令,在此编辑可能使其失效,建议退出专注模式编辑",
1137
1914
  "sum.context": "载入了 {n} 项上下文",
1138
1915
  "sum.commands": "运行了 {n} 个命令",
1139
1916
  "sum.edits": "编辑了 {n} 个文件",
@@ -1183,11 +1960,26 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1183
1960
  "settings.width.hint": "Reading column width (480–1200px)",
1184
1961
  "empty": "No open session",
1185
1962
  "loading": "Reading session…",
1186
- "running": "· Working",
1963
+ "running": "Working",
1187
1964
  "reply.ready": "New reply ready",
1188
1965
  "reply.view": "View",
1189
1966
  "reply.waiting": "AI is waiting for your reply",
1190
- "reply.respond": "Reply",
1967
+ "answer.open": "Answer",
1968
+ "answer.title": "Waiting for your answer",
1969
+ "answer.approvalTitle": "Approval requested",
1970
+ "answer.submit": "Submit answer",
1971
+ "answer.close": "Collapse",
1972
+ "answer.custom": "Other (optional)",
1973
+ "answer.recommended": "Recommended",
1974
+ "answer.incomplete": "Answer every question first",
1975
+ "answer.rejected": "Submission rejected",
1976
+ "answer.allow": "Allow",
1977
+ "answer.reject": "Deny",
1978
+ "composer.placeholder": "Type a reply…",
1979
+ "composer.send": "Send",
1980
+ "composer.expand": "Expand input",
1981
+ "composer.queued": "Queued {n}",
1982
+ "composer.occurrences": "Draft contains references / commands — editing here may break them; edit outside focus mode",
1191
1983
  "sum.context": "loaded {n} context items",
1192
1984
  "sum.commands": "ran {n} commands",
1193
1985
  "sum.edits": "edited {n} files",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-focus-overlay",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Focus Mode for the dsh web GUI: a full-screen reading overlay that hides chrome and folds the AI tool-call flow into summaries, reusing the official Markdown / image primitives.",
5
5
  "type": "module",
6
6
  "main": "lib/index.mjs",