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 +7 -4
- package/README.md +6 -4
- package/lib/client.js +736 -39
- package/package.json +1 -1
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 "↓
|
|
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;
|
|
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;
|
|
112
|
-
4.
|
|
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.
|
|
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 [
|
|
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
|
|
508
|
-
if (!bodyEl) return
|
|
509
|
-
return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight
|
|
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
|
-
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
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: () =>
|
|
744
|
-
children: t("
|
|
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
|
-
|
|
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
|
|
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-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|
|
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",
|