dsh-focus-overlay 0.8.1 → 0.9.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,111 @@ 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
+ /** A question is answered when at least one option is selected or the custom
318
+ * text is non-blank — the same completeness rule the official question
319
+ * composer applies before enabling submit. */
320
+ function questionAnswered(d) {
321
+ if (!d) return false;
322
+ if (d.selected.length > 0) return true;
323
+ return d.custom.trim() !== "";
324
+ }
325
+ /** The whole batch must be answered: one `ask()` is one answer covering every
326
+ * question, never split per question. An empty question list is never
327
+ * complete (nothing to send — submit stays disabled); malformed null entries
328
+ * are skipped, matching encodeAnswer, so one bad row cannot deadlock the
329
+ * answerable majority. */
330
+ function allAnswered(questions, drafts) {
331
+ if (!questions || questions.length === 0) return false;
332
+ for (const q of questions) {
333
+ if (!q) continue;
334
+ if (!questionAnswered(drafts[q.id])) return false;
335
+ }
336
+ return true;
337
+ }
338
+ /**
339
+ * Encode the complete answer batch in the wire shape `question/respond`
340
+ * expects (`AskUserQuestionAnswer`). Mirrors the official encoder's
341
+ * exclusivity rule: a single-select question carries EITHER the selected
342
+ * option OR the custom text (`selected` is cleared when custom is present —
343
+ * never both, which would be a contradictory answer); multi-select may carry
344
+ * both. Option labels go back verbatim — the harness's recommendation suffix
345
+ * is part of the label the asker must see echoed — and custom text is sent
346
+ * trimmed, only when present.
347
+ */
348
+ function encodeAnswer(questions, drafts) {
349
+ const answers = [];
350
+ for (const q of questions || []) {
351
+ if (!q) continue;
352
+ const d = drafts[q.id] || {
353
+ selected: [],
354
+ custom: ""
355
+ };
356
+ const custom = d.custom.trim();
357
+ const selected = custom === "" || q.multiSelect ? [...d.selected] : [];
358
+ const item = {
359
+ id: q.id,
360
+ selected
361
+ };
362
+ if (custom) item.custom = custom;
363
+ answers.push(item);
364
+ }
365
+ return { answers };
366
+ }
367
+ /** Split the harness's recommendation marker off an option label for display
368
+ * (the marker becomes a badge); the full label stays what the wire echoes
369
+ * back. Lenient like the official parser: ASCII or full-width parentheses,
370
+ * "recommended" in any case, or the Chinese 推荐. */
371
+ function parseRecommendedLabel(label) {
372
+ if (typeof label !== "string") return {
373
+ label,
374
+ recommended: false
375
+ };
376
+ const rest = label.replace(/\s*[((]\s*(?:recommended|推荐)\s*[))]\s*$/i, "");
377
+ if (rest === label || rest === "") return {
378
+ label,
379
+ recommended: false
380
+ };
381
+ return {
382
+ label: rest,
383
+ recommended: true
384
+ };
385
+ }
289
386
  //#endregion
290
387
  //#region src/client/settings.ts
291
388
  const KEY = "dsh-focus-overlay:prefs";
@@ -350,6 +447,408 @@ window.__ModuleLoader__.load({
350
447
  }
351
448
  };
352
449
  //#endregion
450
+ //#region src/client/Composer.tsx
451
+ /**
452
+ * The focus-mode bottom dock: the compact composer region that replaces the
453
+ * old lone "jump to bottom" button at the bottom center of the overlay.
454
+ *
455
+ * Forms (mutually exclusive, decided by the pure `bottomForm` selector):
456
+ * - `bar` the compact input bar — the shared official draft, Enter sends
457
+ * - `pill` the collapsed affordance after a manual hide (blue dot = draft)
458
+ * - `tobottom` the classic jump-to-bottom button
459
+ * - `card` the pending answer card (options / free text / approve-deny),
460
+ * opened in place from the waiting toast
461
+ * (`toast` is decided here too, but rendered by the overlay's toast element.)
462
+ *
463
+ * The bar shares the MAIN composer's draft through the official per-session
464
+ * input machine (`conversation.input.for(binding.ctx)`): setDraft / submit are
465
+ * the same public action face every session-scope component gets, so whatever
466
+ * the user typed before entering focus mode is already here, and whatever they
467
+ * type here is still in the main composer after leaving — one draft, both
468
+ * views, persisted by the host like always. When the input service is absent
469
+ * (older dsh), the bar degrades to a plugin-local draft sent through the
470
+ * session face's `prompt` verb — the same wire call the official default sink
471
+ * makes.
472
+ */
473
+ /** Resolve the per-session input facade for a session's agent scope. Returns
474
+ * null when the conversation service (or its input registry) is absent so the
475
+ * caller can fall back to a plugin-local draft. Pure resolution, memoized per
476
+ * binding — the registry hands back the same stable facade every time. */
477
+ function useInputFace(conversation, actx) {
478
+ return (0, react.useMemo)(() => {
479
+ try {
480
+ if (conversation && conversation.input && actx) return conversation.input.for(actx);
481
+ } catch {}
482
+ return null;
483
+ }, [conversation, actx]);
484
+ }
485
+ /** Subscribe to the input machine's published state (draft, queue, reference
486
+ * occurrences). Mirrors the overlay's session-snapshot hook: read once, then
487
+ * re-read on every store flush. */
488
+ function useInputState(input) {
489
+ const [snap, setSnap] = (0, react.useState)(() => {
490
+ try {
491
+ return input && input.state ? input.state.getSnapshot() : null;
492
+ } catch {
493
+ return null;
494
+ }
495
+ });
496
+ (0, react.useEffect)(() => {
497
+ if (!input || !input.state) {
498
+ setSnap(null);
499
+ return;
500
+ }
501
+ try {
502
+ setSnap(input.state.getSnapshot());
503
+ return input.state.subscribe(() => setSnap(input.state.getSnapshot()));
504
+ } catch {
505
+ return;
506
+ }
507
+ }, [input]);
508
+ return snap;
509
+ }
510
+ function OptionRow(props) {
511
+ const { t, index, label, description, multi, on, onToggle } = props;
512
+ const parsed = parseRecommendedLabel(label);
513
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
514
+ type: "button",
515
+ className: "fm-opt" + (on ? " fm-opt-on" : ""),
516
+ onClick: onToggle,
517
+ "aria-pressed": on,
518
+ children: [multi ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
519
+ className: "fm-opt-check" + (on ? " fm-opt-check-on" : ""),
520
+ children: on ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutline14, {}) : null
521
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
522
+ className: "fm-opt-num",
523
+ children: index + 1
524
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
525
+ className: "fm-opt-copy",
526
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
527
+ className: "fm-opt-line",
528
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
529
+ className: "fm-opt-label",
530
+ children: parsed.label
531
+ }), parsed.recommended ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
532
+ className: "fm-opt-badge",
533
+ children: t("answer.recommended")
534
+ }) : null]
535
+ }), description ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
536
+ className: "fm-opt-desc",
537
+ children: description
538
+ }) : null]
539
+ })]
540
+ });
541
+ }
542
+ function AnswerCard(props) {
543
+ const { t, width, wait, onClose } = props;
544
+ const isQuestion = wait.kind === "question";
545
+ const questions = isQuestion ? wait.payload && wait.payload.questions ? wait.payload.questions : [] : [];
546
+ const [drafts, setDrafts] = (0, react.useState)({});
547
+ const [busy, setBusy] = (0, react.useState)(false);
548
+ const [error, setError] = (0, react.useState)(null);
549
+ const patch = (id, next) => setDrafts((prev) => {
550
+ const cur = prev[id] || {
551
+ selected: [],
552
+ custom: ""
553
+ };
554
+ return {
555
+ ...prev,
556
+ [id]: {
557
+ ...cur,
558
+ ...next
559
+ }
560
+ };
561
+ });
562
+ const toggle = (qid, multi, label) => setDrafts((prev) => {
563
+ const cur = prev[qid] || {
564
+ selected: [],
565
+ custom: ""
566
+ };
567
+ if (multi) {
568
+ const selected = cur.selected.includes(label) ? cur.selected.filter((l) => l !== label) : [...cur.selected, label];
569
+ return {
570
+ ...prev,
571
+ [qid]: {
572
+ ...cur,
573
+ selected
574
+ }
575
+ };
576
+ }
577
+ return {
578
+ ...prev,
579
+ [qid]: {
580
+ selected: [label],
581
+ custom: ""
582
+ }
583
+ };
584
+ });
585
+ const respond = async (result) => {
586
+ setBusy(true);
587
+ setError(null);
588
+ try {
589
+ const receipt = await wait.respond(result);
590
+ if (receipt && receipt.accepted === false) {
591
+ setError(t("answer.rejected") + (receipt.reason ? `: ${receipt.reason}` : ""));
592
+ setBusy(false);
593
+ }
594
+ } catch (err) {
595
+ setError(err instanceof Error ? err.message : String(err));
596
+ setBusy(false);
597
+ }
598
+ };
599
+ const submitAnswers = () => {
600
+ if (!allAnswered(questions, drafts)) {
601
+ setError(t("answer.incomplete"));
602
+ return;
603
+ }
604
+ respond({
605
+ ok: true,
606
+ value: {
607
+ sessionId: wait.sessionId,
608
+ answer: encodeAnswer(questions, drafts)
609
+ }
610
+ });
611
+ };
612
+ const decide = (outcome) => respond({
613
+ ok: true,
614
+ value: {
615
+ sessionId: wait.sessionId,
616
+ approvalId: wait.payload.approvalId,
617
+ outcome
618
+ }
619
+ });
620
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
621
+ className: "fm-card" + (isQuestion ? "" : " fm-card-approval"),
622
+ style: { maxWidth: Math.min(width, 748) },
623
+ children: [
624
+ isQuestion ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
625
+ className: "fm-card-head",
626
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
627
+ className: "fm-card-heading",
628
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
629
+ className: "fm-card-eyebrow",
630
+ children: t("reply.waiting")
631
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", {
632
+ className: "fm-card-title",
633
+ children: t("answer.title")
634
+ })]
635
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
636
+ type: "button",
637
+ className: "fm-card-close",
638
+ title: t("answer.close"),
639
+ "aria-label": t("answer.close"),
640
+ onClick: onClose,
641
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {})
642
+ })]
643
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
644
+ className: "fm-card-head",
645
+ children: [
646
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "fm-card-dot" }),
647
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
648
+ className: "fm-card-strip-title",
649
+ children: t("answer.approvalTitle")
650
+ }),
651
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
652
+ type: "button",
653
+ className: "fm-card-close",
654
+ title: t("answer.close"),
655
+ "aria-label": t("answer.close"),
656
+ onClick: onClose,
657
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {})
658
+ })
659
+ ]
660
+ }),
661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
662
+ className: "fm-card-body",
663
+ children: isQuestion ? questions.map((q, i) => {
664
+ const d = drafts[q.id] || {
665
+ selected: [],
666
+ custom: ""
667
+ };
668
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
669
+ className: "fm-card-q",
670
+ children: [
671
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
672
+ className: "fm-card-q-text",
673
+ children: [questions.length > 1 ? `${i + 1}. ` : "", q.question]
674
+ }),
675
+ q.detail ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
676
+ className: "fm-card-detail",
677
+ children: q.detail
678
+ }) : null,
679
+ q.options && q.options.length ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
680
+ className: "fm-card-opts",
681
+ role: q.multiSelect ? "group" : "radiogroup",
682
+ children: q.options.map((o, oi) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(OptionRow, {
683
+ t,
684
+ index: oi,
685
+ label: o.label,
686
+ description: o.description,
687
+ multi: !!q.multiSelect,
688
+ on: d.selected.includes(o.label),
689
+ onToggle: () => toggle(q.id, !!q.multiSelect, o.label)
690
+ }, o.label))
691
+ }) : null,
692
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
693
+ className: "fm-card-custom",
694
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
695
+ className: "fm-card-field",
696
+ type: "text",
697
+ value: d.custom,
698
+ placeholder: t("answer.custom"),
699
+ onChange: (e) => patch(q.id, { custom: e.target.value })
700
+ })
701
+ })
702
+ ]
703
+ }, q.id);
704
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
705
+ className: "fm-card-q",
706
+ children: [wait.payload && wait.payload.reason ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
707
+ className: "fm-card-q-text",
708
+ children: wait.payload.reason
709
+ }) : null, wait.payload && wait.payload.toolName ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
710
+ className: "fm-card-detail",
711
+ children: wait.payload.toolName
712
+ }) : null]
713
+ })
714
+ }),
715
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
716
+ className: "fm-card-foot",
717
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
718
+ className: "fm-card-err",
719
+ children: error
720
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
721
+ className: "fm-card-actions",
722
+ children: isQuestion ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
723
+ variant: "primary",
724
+ size: "sm",
725
+ disabled: busy,
726
+ onClick: submitAnswers,
727
+ children: t("answer.submit")
728
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
729
+ variant: "primary",
730
+ size: "sm",
731
+ disabled: busy,
732
+ onClick: () => decide("allowed-once"),
733
+ children: t("answer.allow")
734
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
735
+ variant: "outline",
736
+ size: "sm",
737
+ disabled: busy,
738
+ onClick: () => decide("rejected"),
739
+ children: t("answer.reject")
740
+ })] })
741
+ })]
742
+ })
743
+ ]
744
+ });
745
+ }
746
+ function InputBar(props) {
747
+ const { t, width, value, queueCount, occCount, errorLine, textareaRef, onFocusChange, onChange, onSend } = props;
748
+ const empty = value.trim() === "";
749
+ const onKeyDown = (e) => {
750
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && e.nativeEvent.keyCode !== 229) {
751
+ e.preventDefault();
752
+ if (!empty) onSend();
753
+ }
754
+ };
755
+ const autoGrow = (el) => {
756
+ el.style.height = "auto";
757
+ el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
758
+ };
759
+ (0, react.useEffect)(() => {
760
+ if (textareaRef.current) autoGrow(textareaRef.current);
761
+ }, [value, textareaRef]);
762
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
763
+ className: "fm-bar",
764
+ style: { maxWidth: Math.min(width, 680) },
765
+ children: [
766
+ errorLine ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
767
+ className: "fm-bar-note fm-bar-note-error",
768
+ children: errorLine
769
+ }) : null,
770
+ occCount > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
771
+ className: "fm-bar-note",
772
+ children: t("composer.occurrences")
773
+ }) : null,
774
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
775
+ className: "fm-bar-row",
776
+ children: [
777
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
778
+ ref: textareaRef,
779
+ className: "fm-bar-text",
780
+ rows: 1,
781
+ value,
782
+ placeholder: t("composer.placeholder"),
783
+ onChange: (e) => {
784
+ onChange(e.target.value);
785
+ autoGrow(e.target);
786
+ },
787
+ onKeyDown,
788
+ onFocus: () => onFocusChange(true),
789
+ onBlur: () => onFocusChange(false)
790
+ }),
791
+ queueCount > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
792
+ className: "fm-bar-queue",
793
+ children: t("composer.queued", { n: queueCount })
794
+ }) : null,
795
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
796
+ type: "button",
797
+ className: "fm-bar-send",
798
+ title: t("composer.send"),
799
+ "aria-label": t("composer.send"),
800
+ disabled: empty,
801
+ onMouseDown: (e) => e.preventDefault(),
802
+ onClick: onSend,
803
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconSendOutline14, {})
804
+ })
805
+ ]
806
+ })
807
+ ]
808
+ });
809
+ }
810
+ function FocusBottomDock(props) {
811
+ const { t, width, form, wait, onCardClose, draft, queueCount, occCount, errorLine, textareaRef, onFocusChange, onDraftChange, onSend, onExpand, onJumpBottom } = props;
812
+ if (form === "toast") return null;
813
+ if (form === "card") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnswerCard, {
814
+ t,
815
+ width,
816
+ wait,
817
+ onClose: onCardClose
818
+ });
819
+ if (form === "bar") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(InputBar, {
820
+ t,
821
+ width,
822
+ value: draft,
823
+ queueCount,
824
+ occCount,
825
+ errorLine,
826
+ textareaRef,
827
+ onFocusChange,
828
+ onChange: onDraftChange,
829
+ onSend
830
+ });
831
+ if (form === "pill") {
832
+ const hasDraft = draft.trim() !== "";
833
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
834
+ type: "button",
835
+ className: "fm-pill",
836
+ title: t("composer.expand"),
837
+ "aria-label": t("composer.expand"),
838
+ onClick: onExpand,
839
+ 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]
840
+ });
841
+ }
842
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
843
+ type: "button",
844
+ className: "fm-pill",
845
+ title: t("backToLatest"),
846
+ "aria-label": t("backToLatest"),
847
+ onClick: onJumpBottom,
848
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, { size: 16 })
849
+ });
850
+ }
851
+ //#endregion
353
852
  //#region src/client/FocusView.tsx
354
853
  let focusOn = false;
355
854
  let pendingAnchorKey = null;
@@ -490,9 +989,15 @@ window.__ModuleLoader__.load({
490
989
  }
491
990
  }
492
991
  const [activeKey, setActiveKey] = (0, react.useState)(navKeys.length ? navKeys[navKeys.length - 1] : null);
493
- const [atBottom, setAtBottom] = (0, react.useState)(true);
992
+ const [zone, setZone] = (0, react.useState)(true);
494
993
  const rafId = (0, react.useRef)(null);
495
994
  const overlayRef = (0, react.useRef)(null);
995
+ const [cardOpen, setCardOpen] = (0, react.useState)(false);
996
+ const [dockFocused, setDockFocused] = (0, react.useState)(false);
997
+ const [engaged, setEngaged] = (0, react.useState)(false);
998
+ const [localDraft, setLocalDraft] = (0, react.useState)("");
999
+ const taRef = (0, react.useRef)(null);
1000
+ const focusBarOnce = (0, react.useRef)(false);
496
1001
  const computeActive = () => {
497
1002
  if (!bodyEl) return null;
498
1003
  let cur = null;
@@ -504,16 +1009,16 @@ window.__ModuleLoader__.load({
504
1009
  }
505
1010
  return cur;
506
1011
  };
507
- const isNearBottom = () => {
508
- if (!bodyEl) return true;
509
- return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight < 48;
1012
+ const bottomDistance = () => {
1013
+ if (!bodyEl) return Number.POSITIVE_INFINITY;
1014
+ return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight;
510
1015
  };
511
1016
  const handleScroll = () => {
512
1017
  if (rafId.current !== null) return;
513
1018
  rafId.current = requestAnimationFrame(() => {
514
1019
  rafId.current = null;
515
1020
  setActiveKey(computeActive());
516
- setAtBottom(isNearBottom());
1021
+ setZone((prev) => bottomZoneAfter(prev, bottomDistance()));
517
1022
  });
518
1023
  };
519
1024
  const scrollToKey = (key, smooth) => {
@@ -573,18 +1078,35 @@ window.__ModuleLoader__.load({
573
1078
  setActiveKey(navKeys.length ? navKeys[navKeys.length - 1] : null);
574
1079
  }
575
1080
  }, []);
1081
+ (0, react.useEffect)(() => {
1082
+ setZone(bodyEl ? bottomZoneAfter(false, bottomDistance()) : true);
1083
+ }, []);
576
1084
  (0, react.useEffect)(() => {
577
1085
  overlayRef.current?.focus();
1086
+ }, []);
1087
+ (0, react.useEffect)(() => {
578
1088
  const onKey = (e) => {
579
- if (e.key === "Escape") {
580
- e.preventDefault();
581
- e.stopPropagation();
582
- focusStore.set(false);
1089
+ if (e.key !== "Escape") return;
1090
+ if (e.isComposing || e.keyCode === 229) return;
1091
+ e.preventDefault();
1092
+ e.stopPropagation();
1093
+ if (cardOpen) {
1094
+ setCardOpen(false);
1095
+ return;
1096
+ }
1097
+ if (dockFocused || engaged) {
1098
+ hideBar();
1099
+ return;
583
1100
  }
1101
+ focusStore.set(false);
584
1102
  };
585
1103
  window.addEventListener("keydown", onKey, true);
586
1104
  return () => window.removeEventListener("keydown", onKey, true);
587
- }, []);
1105
+ }, [
1106
+ cardOpen,
1107
+ dockFocused,
1108
+ engaged
1109
+ ]);
588
1110
  const loadImage = (0, react.useCallback)((attachment) => {
589
1111
  if (!conversation || !currentId) return Promise.reject(/* @__PURE__ */ new Error("dsh-focus-overlay: conversation service unavailable"));
590
1112
  return conversation.resolveImage(currentId, attachment);
@@ -606,6 +1128,81 @@ window.__ModuleLoader__.load({
606
1128
  return;
607
1129
  }
608
1130
  }, [chatFileMentions, workspaces]);
1131
+ const binding = (0, react.useMemo)(() => sessions && currentId != null ? sessions.binding(currentId) : void 0, [sessions, currentId]);
1132
+ const inputFace = useInputFace(conversation, binding && binding.ctx);
1133
+ const inputState = useInputState(inputFace);
1134
+ const draftValue = inputFace ? inputState && typeof inputState.draft === "string" ? inputState.draft : "" : localDraft;
1135
+ const draftEmpty = draftValue.trim() === "";
1136
+ const pendingList = snap && snap.pending ? snap.pending : [];
1137
+ const pendingCount = pendingList.length;
1138
+ const wait = pendingCount > 0 ? pendingList[0] : null;
1139
+ const onDraftChange = (v) => {
1140
+ if (inputFace) try {
1141
+ inputFace.setDraft(v);
1142
+ return;
1143
+ } catch {}
1144
+ setLocalDraft(v);
1145
+ };
1146
+ const send = () => {
1147
+ const text = draftValue;
1148
+ if (text.trim() === "") return;
1149
+ if (inputFace) try {
1150
+ inputFace.submit("queue");
1151
+ return;
1152
+ } catch {}
1153
+ const face = binding && binding.session;
1154
+ if (face) face.prompt([{
1155
+ type: "text",
1156
+ text
1157
+ }], "queue").then((r) => {
1158
+ if (r && r.ok) setLocalDraft("");
1159
+ }).catch(() => {});
1160
+ };
1161
+ const hideBar = () => {
1162
+ try {
1163
+ taRef.current && taRef.current.blur();
1164
+ } catch {}
1165
+ setEngaged(false);
1166
+ setDockFocused(false);
1167
+ };
1168
+ const expandBar = () => {
1169
+ focusBarOnce.current = true;
1170
+ setEngaged(true);
1171
+ };
1172
+ const onDockFocusChange = (f) => {
1173
+ setDockFocused(f);
1174
+ if (!f) setEngaged(false);
1175
+ };
1176
+ const queueCount = snap && snap.queue ? snap.queue.length : 0;
1177
+ const occCount = inputState && inputState.occurrences ? inputState.occurrences.length : 0;
1178
+ const promptErr = snap && snap.promptError && snap.promptError.op === "send" ? snap.promptError.error : null;
1179
+ const errorLine = promptErr ? promptErr.message || promptErr.code || "send failed" : null;
1180
+ const form = bottomForm({
1181
+ pending: pendingCount > 0,
1182
+ cardOpen,
1183
+ draftEmpty,
1184
+ inZone: zone,
1185
+ focused: dockFocused,
1186
+ engaged
1187
+ });
1188
+ (0, react.useEffect)(() => {
1189
+ if (pendingCount === 0) {
1190
+ if (cardOpen) setCardOpen(false);
1191
+ return;
1192
+ }
1193
+ focusBarOnce.current = false;
1194
+ setDockFocused(false);
1195
+ setEngaged(false);
1196
+ }, [pendingCount, cardOpen]);
1197
+ (0, react.useEffect)(() => {
1198
+ if (form === "bar" && focusBarOnce.current) {
1199
+ focusBarOnce.current = false;
1200
+ try {
1201
+ taRef.current && taRef.current.focus();
1202
+ } catch {}
1203
+ setEngaged(false);
1204
+ }
1205
+ }, [form]);
609
1206
  let title = "Focus Mode";
610
1207
  if (currentId && listState && listState.byId && listState.byId[currentId]) {
611
1208
  const row = listState.byId[currentId];
@@ -701,18 +1298,7 @@ window.__ModuleLoader__.load({
701
1298
  })]
702
1299
  }, k))
703
1300
  }) : 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);
1301
+ const waiting = pendingCount > 0 && !cardOpen;
716
1302
  const donePing = useDonePing();
717
1303
  const [showDoneNotice, setShowDoneNotice] = (0, react.useState)(false);
718
1304
  (0, react.useEffect)(() => {
@@ -740,8 +1326,8 @@ window.__ModuleLoader__.load({
740
1326
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
741
1327
  variant: "primary",
742
1328
  size: "sm",
743
- onClick: () => focusStore.set(false),
744
- children: t("reply.respond")
1329
+ onClick: () => setCardOpen(true),
1330
+ children: t("answer.open")
745
1331
  })
746
1332
  ]
747
1333
  }) : showDoneNotice ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -789,7 +1375,26 @@ window.__ModuleLoader__.load({
789
1375
  children: body
790
1376
  }),
791
1377
  nav,
792
- toBottom,
1378
+ currentId && snap !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1379
+ className: "fm-dock",
1380
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FocusBottomDock, {
1381
+ t,
1382
+ width: prefs.width,
1383
+ form,
1384
+ wait,
1385
+ onCardClose: () => setCardOpen(false),
1386
+ draft: draftValue,
1387
+ queueCount,
1388
+ occCount,
1389
+ errorLine,
1390
+ textareaRef: taRef,
1391
+ onFocusChange: onDockFocusChange,
1392
+ onDraftChange,
1393
+ onSend: send,
1394
+ onExpand: expandBar,
1395
+ onJumpBottom: scrollToBottom
1396
+ })
1397
+ }) : null,
793
1398
  replyNotice
794
1399
  ]
795
1400
  });
@@ -1036,7 +1641,7 @@ window.__ModuleLoader__.load({
1036
1641
  .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
1642
  .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
1643
  .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}
1644
+ .fm-body{flex:1 1 auto;overflow-y:auto;padding:28px 28px 110px}
1040
1645
  .fm-inner{max-width:760px;margin:0 auto}
1041
1646
  .fm-msg{margin:0 0 24px}
1042
1647
  .fm-user-msg{margin-bottom:44px}
@@ -1058,11 +1663,73 @@ window.__ModuleLoader__.load({
1058
1663
  .fm-nav-dot-active .fm-nav-dot-core{background:var(--dsw-static-deepseek-500,#4176e6);height:22px;opacity:1}
1059
1664
  .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
1665
  .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}
1666
+ .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
1667
  .fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
1065
1668
  .fm-reply-toast-text{white-space:nowrap}
1669
+ /* ---- bottom dock: bar / pill / jump-to-bottom / answer card ----
1670
+ Visual language mirrors the official composer cards token-for-token
1671
+ (ui-conversation InputBar/QuestionComposer, ui-user-questions
1672
+ PlanReviewPanel): --dsw-specific-input-major surfaces, 20–22px radii,
1673
+ --dsw-shadow-lv2, the 34px round info-fill primary button, interactive-
1674
+ hover option tinting. */
1675
+ .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}
1676
+ .fm-dock>*{pointer-events:auto;animation:fm-up .18s ease}
1677
+ .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}
1678
+ .fm-bar-row{display:flex;align-items:flex-end;gap:12px;min-width:0;padding:2px 8px 6px}
1679
+ /* flex-end anchors the send button to the row's bottom edge, so a multi-line
1680
+ draft grows the textarea upward and the button's distance to the bar's
1681
+ bottom-right corner never moves. */
1682
+ .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}
1683
+ .fm-bar-text::placeholder{color:var(--dsw-alias-label-caption,#999);user-select:none}
1684
+ .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)}
1685
+ .fm-bar-note-error{color:var(--dsw-alias-state-error-primary,#d23);word-break:break-word}
1686
+ .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}
1687
+ .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}
1688
+ .fm-bar-send:hover:not(:disabled){background:var(--dsw-alias-button-info-hover,#3560c4)}
1689
+ .fm-bar-send:disabled{opacity:.4;cursor:default}
1690
+ .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}
1691
+ .fm-pill:hover{color:var(--dsw-alias-label-primary,#1a1a1a)}
1692
+ .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))}
1693
+ .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)}
1694
+ .fm-card *{box-sizing:border-box}
1695
+ .fm-card-approval{border-color:var(--dsw-alias-state-warn-secondary,#e6a23c)}
1696
+ .fm-card-head{flex:0 0 auto;display:flex;justify-content:space-between;align-items:flex-start;gap:16px;padding:20px 16px 0 24px}
1697
+ .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}
1698
+ .fm-card-heading{min-width:0}
1699
+ .fm-card-eyebrow{color:var(--dsw-alias-label-tertiary,#999);margin:0 0 5px;font-size:11px;line-height:16px}
1700
+ .fm-card-title{margin:0;font-size:16px;font-weight:500;line-height:22px}
1701
+ .fm-card-strip-title{font-size:13px;line-height:18px;flex:1;min-width:0}
1702
+ .fm-card-dot{background:var(--dsw-alias-state-warn-primary,#9a6b12);border-radius:50%;width:8px;height:8px;flex:none}
1703
+ .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}
1704
+ .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)}
1705
+ .fm-card-approval .fm-card-close,.fm-card-approval .fm-card-close:hover:not(:disabled){color:inherit}
1706
+ .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}
1707
+ .fm-card-q{display:flex;flex-direction:column}
1708
+ .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}
1709
+ .fm-card-detail{color:var(--dsw-alias-label-secondary,#888);margin:0 2px 8px;padding:0 22px;font-size:14px;line-height:22px}
1710
+ .fm-card-opts{display:flex;flex-direction:column;gap:1px;margin:8px 0 0;padding:4px 12px}
1711
+ .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}
1712
+ .fm-opt:hover:not(:disabled),.fm-opt-on{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}
1713
+ .fm-opt-on{border-color:var(--dsw-alias-border-l2,rgba(0,0,0,.18))}
1714
+ .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}
1715
+ .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)}
1716
+ .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}
1717
+ .fm-opt-check-on::before{border-color:var(--dsw-alias-label-primary,#1a1a1a);background:var(--dsw-alias-label-primary,#1a1a1a)}
1718
+ .fm-opt-check>*{grid-area:1/1}
1719
+ .fm-opt-copy{flex:1;min-width:0}
1720
+ .fm-opt-line{display:flex;flex-wrap:wrap;align-items:baseline;gap:2px 6px}
1721
+ .fm-opt-label{font-size:14px;font-weight:500;line-height:24px;word-break:break-word}
1722
+ .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}
1723
+ .fm-opt-desc{color:var(--dsw-alias-label-tertiary,#999);font-size:14px;line-height:24px;word-break:break-word}
1724
+ .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}
1725
+ .fm-card-custom:hover,.fm-card-custom:focus-within{background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06))}
1726
+ .fm-card-custom:focus-within{border-color:var(--dsw-alias-border-l2,rgba(0,0,0,.18))}
1727
+ .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}
1728
+ .fm-card-field::placeholder{color:var(--dsw-alias-label-caption,#999)}
1729
+ .fm-card-foot{flex:0 0 auto;display:flex;justify-content:space-between;align-items:center;gap:12px;padding:8px 16px 12px}
1730
+ .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}
1731
+ .fm-card-actions{display:flex;align-items:center;gap:8px;flex:none}
1732
+ @keyframes fm-up{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
1066
1733
  .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
1734
  .fm-plugin-card:hover{border-color:var(--dsw-alias-label-dimmed)}
1068
1735
  .fm-plugin-card-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
@@ -1133,7 +1800,22 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1133
1800
  "reply.ready": "新回复已生成",
1134
1801
  "reply.view": "查看",
1135
1802
  "reply.waiting": "AI 正在等待你的回复",
1136
- "reply.respond": "去回复",
1803
+ "answer.open": "去回答",
1804
+ "answer.title": "等待你的回答",
1805
+ "answer.approvalTitle": "请求审批",
1806
+ "answer.submit": "提交回答",
1807
+ "answer.close": "收起",
1808
+ "answer.custom": "其他答案(可选)",
1809
+ "answer.recommended": "推荐",
1810
+ "answer.incomplete": "请先回答所有问题",
1811
+ "answer.rejected": "提交被拒绝",
1812
+ "answer.allow": "允许",
1813
+ "answer.reject": "拒绝",
1814
+ "composer.placeholder": "输入回复…",
1815
+ "composer.send": "发送",
1816
+ "composer.expand": "展开输入框",
1817
+ "composer.queued": "已排队 {n}",
1818
+ "composer.occurrences": "草稿中包含引用 / 命令,在此编辑可能使其失效,建议退出专注模式编辑",
1137
1819
  "sum.context": "载入了 {n} 项上下文",
1138
1820
  "sum.commands": "运行了 {n} 个命令",
1139
1821
  "sum.edits": "编辑了 {n} 个文件",
@@ -1187,7 +1869,22 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1187
1869
  "reply.ready": "New reply ready",
1188
1870
  "reply.view": "View",
1189
1871
  "reply.waiting": "AI is waiting for your reply",
1190
- "reply.respond": "Reply",
1872
+ "answer.open": "Answer",
1873
+ "answer.title": "Waiting for your answer",
1874
+ "answer.approvalTitle": "Approval requested",
1875
+ "answer.submit": "Submit answer",
1876
+ "answer.close": "Collapse",
1877
+ "answer.custom": "Other (optional)",
1878
+ "answer.recommended": "Recommended",
1879
+ "answer.incomplete": "Answer every question first",
1880
+ "answer.rejected": "Submission rejected",
1881
+ "answer.allow": "Allow",
1882
+ "answer.reject": "Deny",
1883
+ "composer.placeholder": "Type a reply…",
1884
+ "composer.send": "Send",
1885
+ "composer.expand": "Expand input",
1886
+ "composer.queued": "Queued {n}",
1887
+ "composer.occurrences": "Draft contains references / commands — editing here may break them; edit outside focus mode",
1191
1888
  "sum.context": "loaded {n} context items",
1192
1889
  "sum.commands": "ran {n} commands",
1193
1890
  "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.9.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",