dsh-focus-overlay 0.9.0 → 0.10.1
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 +1 -1
- package/README.md +1 -1
- package/lib/client.js +156 -13
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -58,7 +58,7 @@ Once in focus mode, an assistant turn no longer shows every step — it folds in
|
|
|
58
58
|
| Precise scroll preservation | Opens at the message you're reading in the chat (aligned by `seq`), not from the top |
|
|
59
59
|
| Right-side node navbar | One dot per user message; active pill follows scroll, hover preview, click to jump, auto-hidden under 2 messages |
|
|
60
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 |
|
|
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; sending from the live edge auto-scrolls just enough to fully reveal the message you sent, never hidden behind the bar |
|
|
62
62
|
| File mentions | Wires `chatFileMentions`, turning inline-code tokens that name real files into clickable links |
|
|
63
63
|
| i18n | Chinese / English copy, follows the UI language |
|
|
64
64
|
| Plugin configuration card | A collapsible card under "Settings → Plugins → Plugin configuration": navbar toggle, open-position strategy, text-area width — persisted to `localStorage` |
|
package/README.md
CHANGED
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
| 精确保留位置 | 进入专注时定位到聊天视图中正在阅读的消息(按 `seq` 对齐),而非从头开始 |
|
|
56
56
|
| 右侧节点导航条 | 每个 user 消息一个圆点,激活药丸跟随滚动、悬停预览、点击平滑跳转,少于 2 条自动隐藏 |
|
|
57
57
|
| 回到最新 | 无草稿且离开底部时,底部居中显示「↓」悬浮按钮 |
|
|
58
|
-
| 专注内输入条 | 滚到最底部自动展开精简输入框,与主输入框共享同一份草稿;`Enter` 发送(AI
|
|
58
|
+
| 专注内输入条 | 滚到最底部自动展开精简输入框,与主输入框共享同一份草稿;`Enter` 发送(AI 忙时自动排队);有草稿时点击对话区自动收起为带蓝点的圆钮,一键展开续写;在底部发送多行长消息后自动上滚刚好一段,让刚发出的消息完整露出、不被输入条遮挡 |
|
|
59
59
|
| 文件提及 | 接 `chatFileMentions`,内联代码命中真实文件时变成可点链接 |
|
|
60
60
|
| i18n | 中 / 英文案,跟随界面语言 |
|
|
61
61
|
| 插件配置卡片 | 在「设置 → 插件 → 插件配置」中的可折叠卡片,含导航条开关、打开定位策略、文字区宽度,持久化到 `localStorage` |
|
package/lib/client.js
CHANGED
|
@@ -216,6 +216,18 @@ window.__ModuleLoader__.load({
|
|
|
216
216
|
for (let i = items.length - 1; i >= 0; i--) if (items[i].kind === "user") return i;
|
|
217
217
|
return -1;
|
|
218
218
|
}
|
|
219
|
+
/** Seq of the newest prompt row (user OR steering) in an already-built focus
|
|
220
|
+
* list — the row a just-sent message occupies once it renders. Unlike
|
|
221
|
+
* {@link lastUserSeq} (raw nodes), this reads the rendered list, so a blank
|
|
222
|
+
* prompt or an unsent queue entry is already filtered out. Null when no
|
|
223
|
+
* prompt row exists. */
|
|
224
|
+
function lastPromptSeq(items) {
|
|
225
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
226
|
+
const it = items[i];
|
|
227
|
+
if (it.kind === "user" || it.kind === "steering") return it.seq;
|
|
228
|
+
}
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
219
231
|
/**
|
|
220
232
|
* Decide whether a settled conversation snapshot (`running === false`) ends in
|
|
221
233
|
* a *normal* completed reply, and which user message started it.
|
|
@@ -314,6 +326,53 @@ window.__ModuleLoader__.load({
|
|
|
314
326
|
if (distance >= 160) return false;
|
|
315
327
|
return prev;
|
|
316
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* Whether the overlay should scroll to uncover the just-sent prompt row. The
|
|
331
|
+
* send path arms the reveal only while the reader sits in the bottom zone
|
|
332
|
+
* (sending from up in history must never yank the view), and the reveal fires
|
|
333
|
+
* only once the newest prompt seq has advanced — the sent row has actually
|
|
334
|
+
* rendered; delivery can lag the click by a snapshot or two through queue
|
|
335
|
+
* adjudication. An unchanged seq (ordinary streaming snapshots, mount) or an
|
|
336
|
+
* unarmed state never scrolls.
|
|
337
|
+
*/
|
|
338
|
+
function shouldRevealSentPrompt(o) {
|
|
339
|
+
return o.armed && o.nextSeq != null && o.nextSeq !== o.prevSeq;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Index of the conversation-turn dot the nav rail should highlight, from live
|
|
343
|
+
* scroll geometry. The rail is a position indicator, so the decision is pure
|
|
344
|
+
* math over measured anchor tops (document order) plus the distance to the
|
|
345
|
+
* scroll end:
|
|
346
|
+
*
|
|
347
|
+
* 1. Bottom force: within {@link BOTTOM_ENTER_PX} of the scroll end the last
|
|
348
|
+
* turn owns the highlight, whatever its height. Without this rule a short
|
|
349
|
+
* final turn could never light its own dot — with less than a viewport of
|
|
350
|
+
* content below its anchor, that anchor physically cannot reach the top
|
|
351
|
+
* threshold even pinned at the very bottom, so the highlight would stick
|
|
352
|
+
* to an earlier turn forever.
|
|
353
|
+
* 2. Geometric scan: the last anchor whose top sits at/above the
|
|
354
|
+
* {@link NAV_TOP_PX} line — "which turn is at the top of the viewport".
|
|
355
|
+
* Non-finite entries (a stale anchor slot after the item list shifted) are
|
|
356
|
+
* skipped without stopping the scan, matching the old ref-null behavior.
|
|
357
|
+
* 3. Top clamp: every real anchor still below the line means the reader is
|
|
358
|
+
* above the first message's threshold (scrollTop ≈ 0 with leading
|
|
359
|
+
* padding), and the first turn owns the position.
|
|
360
|
+
*
|
|
361
|
+
* A bottom-forced index may land on a stale slot; the caller maps it back to
|
|
362
|
+
* the nearest existing anchor.
|
|
363
|
+
*/
|
|
364
|
+
function activeNavIndex(tops, bottomDistance, topPx = 96, bottomPx = 48) {
|
|
365
|
+
if (!tops.length) return -1;
|
|
366
|
+
if (bottomDistance <= bottomPx) return tops.length - 1;
|
|
367
|
+
let cur = -1;
|
|
368
|
+
for (let i = 0; i < tops.length; i++) {
|
|
369
|
+
const top = tops[i];
|
|
370
|
+
if (!Number.isFinite(top)) continue;
|
|
371
|
+
if (top <= topPx) cur = i;
|
|
372
|
+
else break;
|
|
373
|
+
}
|
|
374
|
+
return cur >= 0 ? cur : 0;
|
|
375
|
+
}
|
|
317
376
|
/** A question is answered when at least one option is selected or the custom
|
|
318
377
|
* text is non-blank — the same completeness rule the official question
|
|
319
378
|
* composer applies before enabling submit. */
|
|
@@ -998,22 +1057,23 @@ window.__ModuleLoader__.load({
|
|
|
998
1057
|
const [localDraft, setLocalDraft] = (0, react.useState)("");
|
|
999
1058
|
const taRef = (0, react.useRef)(null);
|
|
1000
1059
|
const focusBarOnce = (0, react.useRef)(false);
|
|
1060
|
+
const revealArmed = (0, react.useRef)(false);
|
|
1061
|
+
const seenPromptSeq = (0, react.useRef)(null);
|
|
1001
1062
|
const computeActive = () => {
|
|
1002
1063
|
if (!bodyEl) return null;
|
|
1003
|
-
|
|
1004
|
-
|
|
1064
|
+
const bodyTop = bodyEl.getBoundingClientRect().top;
|
|
1065
|
+
const i = activeNavIndex(navKeys.map((k) => {
|
|
1005
1066
|
const el = anchors[k];
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
return cur;
|
|
1067
|
+
return el ? el.getBoundingClientRect().top - bodyTop : Number.POSITIVE_INFINITY;
|
|
1068
|
+
}), bottomDistance());
|
|
1069
|
+
for (let j = i; j >= 0; j--) if (anchors[navKeys[j]]) return navKeys[j];
|
|
1070
|
+
return null;
|
|
1011
1071
|
};
|
|
1012
1072
|
const bottomDistance = () => {
|
|
1013
1073
|
if (!bodyEl) return Number.POSITIVE_INFINITY;
|
|
1014
1074
|
return bodyEl.scrollHeight - bodyEl.scrollTop - bodyEl.clientHeight;
|
|
1015
1075
|
};
|
|
1016
|
-
const
|
|
1076
|
+
const scheduleRecompute = () => {
|
|
1017
1077
|
if (rafId.current !== null) return;
|
|
1018
1078
|
rafId.current = requestAnimationFrame(() => {
|
|
1019
1079
|
rafId.current = null;
|
|
@@ -1021,6 +1081,10 @@ window.__ModuleLoader__.load({
|
|
|
1021
1081
|
setZone((prev) => bottomZoneAfter(prev, bottomDistance()));
|
|
1022
1082
|
});
|
|
1023
1083
|
};
|
|
1084
|
+
const scheduleRef = (0, react.useRef)(scheduleRecompute);
|
|
1085
|
+
(0, react.useEffect)(() => {
|
|
1086
|
+
scheduleRef.current = scheduleRecompute;
|
|
1087
|
+
});
|
|
1024
1088
|
const scrollToKey = (key, smooth) => {
|
|
1025
1089
|
const el = anchors[key];
|
|
1026
1090
|
if (!el || !bodyEl) return;
|
|
@@ -1081,6 +1145,43 @@ window.__ModuleLoader__.load({
|
|
|
1081
1145
|
(0, react.useEffect)(() => {
|
|
1082
1146
|
setZone(bodyEl ? bottomZoneAfter(false, bottomDistance()) : true);
|
|
1083
1147
|
}, []);
|
|
1148
|
+
(0, react.useEffect)(() => {
|
|
1149
|
+
const el = bodyEl;
|
|
1150
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
1151
|
+
const ro = new ResizeObserver(() => scheduleRef.current());
|
|
1152
|
+
ro.observe(el);
|
|
1153
|
+
return () => ro.disconnect();
|
|
1154
|
+
}, []);
|
|
1155
|
+
(0, react.useEffect)(() => {
|
|
1156
|
+
scheduleRef.current();
|
|
1157
|
+
}, [snap, prefs.width]);
|
|
1158
|
+
(0, react.useEffect)(() => {
|
|
1159
|
+
const seq = lastPromptSeq(items);
|
|
1160
|
+
if (seq != null && shouldRevealSentPrompt({
|
|
1161
|
+
armed: revealArmed.current,
|
|
1162
|
+
prevSeq: seenPromptSeq.current,
|
|
1163
|
+
nextSeq: seq
|
|
1164
|
+
})) {
|
|
1165
|
+
revealArmed.current = false;
|
|
1166
|
+
const idx = findSeqIndex(items, seq);
|
|
1167
|
+
const el = idx >= 0 ? anchors["fm-" + idx] : void 0;
|
|
1168
|
+
if (el && bodyEl) {
|
|
1169
|
+
const limit = bodyEl.getBoundingClientRect().bottom - 110;
|
|
1170
|
+
const hidden = el.getBoundingClientRect().bottom - limit;
|
|
1171
|
+
if (hidden > 0) bodyEl.scrollBy({
|
|
1172
|
+
top: hidden,
|
|
1173
|
+
behavior: "smooth"
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
seenPromptSeq.current = seq;
|
|
1178
|
+
}, [items]);
|
|
1179
|
+
(0, react.useEffect)(() => () => {
|
|
1180
|
+
if (rafId.current !== null) {
|
|
1181
|
+
cancelAnimationFrame(rafId.current);
|
|
1182
|
+
rafId.current = null;
|
|
1183
|
+
}
|
|
1184
|
+
}, []);
|
|
1084
1185
|
(0, react.useEffect)(() => {
|
|
1085
1186
|
overlayRef.current?.focus();
|
|
1086
1187
|
}, []);
|
|
@@ -1146,6 +1247,7 @@ window.__ModuleLoader__.load({
|
|
|
1146
1247
|
const send = () => {
|
|
1147
1248
|
const text = draftValue;
|
|
1148
1249
|
if (text.trim() === "") return;
|
|
1250
|
+
if (zone) revealArmed.current = true;
|
|
1149
1251
|
if (inputFace) try {
|
|
1150
1252
|
inputFace.submit("queue");
|
|
1151
1253
|
return;
|
|
@@ -1272,7 +1374,23 @@ window.__ModuleLoader__.load({
|
|
|
1272
1374
|
}, "fm-partial"));
|
|
1273
1375
|
if (running) kids.push(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1274
1376
|
className: "fm-running",
|
|
1275
|
-
|
|
1377
|
+
role: "status",
|
|
1378
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1379
|
+
className: "fm-running-text",
|
|
1380
|
+
children: [
|
|
1381
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1382
|
+
className: "fm-running-dash",
|
|
1383
|
+
"aria-hidden": "true",
|
|
1384
|
+
children: "–"
|
|
1385
|
+
}),
|
|
1386
|
+
t("running"),
|
|
1387
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1388
|
+
className: "fm-running-dash",
|
|
1389
|
+
"aria-hidden": "true",
|
|
1390
|
+
children: "–"
|
|
1391
|
+
})
|
|
1392
|
+
]
|
|
1393
|
+
})
|
|
1276
1394
|
}, "fm-running"));
|
|
1277
1395
|
body = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1278
1396
|
className: "fm-inner",
|
|
@@ -1371,7 +1489,7 @@ window.__ModuleLoader__.load({
|
|
|
1371
1489
|
ref: (el) => {
|
|
1372
1490
|
bodyEl = el;
|
|
1373
1491
|
},
|
|
1374
|
-
onScroll:
|
|
1492
|
+
onScroll: scheduleRecompute,
|
|
1375
1493
|
children: body
|
|
1376
1494
|
}),
|
|
1377
1495
|
nav,
|
|
@@ -1651,7 +1769,32 @@ window.__ModuleLoader__.load({
|
|
|
1651
1769
|
.fm-image{max-width:100%;border-radius:8px;margin:8px 0}
|
|
1652
1770
|
.fm-hidden{text-align:center;color:var(--dsw-alias-label-secondary,#888);font-size:12px;line-height:1.6;margin:6px 0 18px;user-select:none}
|
|
1653
1771
|
.fm-error{color:var(--dsw-alias-state-error-primary,#d23);font-size:13px;white-space:pre-wrap}
|
|
1654
|
-
|
|
1772
|
+
/* "正在工作" live line — the shimmer is a 1:1 replica of the official
|
|
1773
|
+
generating indicator in dsh-client-ui-conversation (the "Deep Diving"
|
|
1774
|
+
turn-status): brand-blue text (--dsw-static-deepseek-500) with a light
|
|
1775
|
+
highlight (--dsw-static-deepseek-200) sweeping through — gradient stops
|
|
1776
|
+
0/40/50/60/100 on a 250% tile, background-position 100%→0, 1.8s linear,
|
|
1777
|
+
font --dsw-font-s-strong-14 (500 14px/22px). The official class name
|
|
1778
|
+
(Md3f7G_turnStatus) is a per-build CSS-modules hash, so reusing it would
|
|
1779
|
+
break silently on dsh updates — the declarations are copied verbatim onto
|
|
1780
|
+
our own class using the same theme tokens instead. The flanking short
|
|
1781
|
+
dashes are part of the same shimmer span, so the sweep covers dashes and
|
|
1782
|
+
label as one unit. Fallbacks: no background-clip:text → secondary gray
|
|
1783
|
+
label; prefers-reduced-motion → animation off, label stays solid
|
|
1784
|
+
deepseek-500 (the official base color). */
|
|
1785
|
+
.fm-running{text-align:center;margin:10px 0 20px;user-select:none;animation:fm-up .18s ease}
|
|
1786
|
+
.fm-running-text{color:var(--dsw-alias-label-secondary,#888);font:var(--dsw-font-s-strong-14,500 14px/22px var(--dsw-font-family,sans-serif));white-space:nowrap}
|
|
1787
|
+
/* Dashes: separate spans with fixed margins instead of space characters — in
|
|
1788
|
+
CJK/Latin mixed runs the browser itemizes the two U+0020 spaces into
|
|
1789
|
+
different fonts (one Latin-narrow, one Han-wide), and the Han font's en
|
|
1790
|
+
dash glyph is left-biased, which made the two gaps visually unequal. The
|
|
1791
|
+
forced Latin-first stack renders a symmetric en dash; margins are exact. */
|
|
1792
|
+
.fm-running-dash{font-family:system-ui,-apple-system,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;margin:0 8px}
|
|
1793
|
+
@supports ((-webkit-background-clip:text) or (background-clip:text)){
|
|
1794
|
+
.fm-running-text{background:linear-gradient(90deg,var(--dsw-static-deepseek-500,#4176e6) 0%,var(--dsw-static-deepseek-500,#4176e6) 40%,var(--dsw-static-deepseek-200,#d3e2ff) 50%,var(--dsw-static-deepseek-500,#4176e6) 60%,var(--dsw-static-deepseek-500,#4176e6) 100%);color:#0000;-webkit-text-fill-color:transparent;background-position:100% 0;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;animation:fm-sweep 1.8s linear infinite}
|
|
1795
|
+
}
|
|
1796
|
+
@keyframes fm-sweep{to{background-position:0 0}}
|
|
1797
|
+
@media (prefers-reduced-motion:reduce){.fm-running{animation:none}.fm-running-text{background:0 0;color:var(--dsw-static-deepseek-500,#4176e6);-webkit-text-fill-color:currentColor;animation:none}}
|
|
1655
1798
|
.fm-empty{color:var(--dsw-alias-label-secondary,#888);padding:48px 16px;text-align:center}
|
|
1656
1799
|
.fm-nav{position:absolute;right:20px;top:50%;transform:translateY(-50%);display:block;width:16px;z-index:2;pointer-events:auto;transition:transform .18s ease}
|
|
1657
1800
|
.fm-nav:hover{transform:translateY(-50%) translateX(-6px)}
|
|
@@ -1796,7 +1939,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
|
|
|
1796
1939
|
"settings.width.hint": "专注模式正文的阅读列宽(480–1200px)",
|
|
1797
1940
|
"empty": "暂无打开的会话",
|
|
1798
1941
|
"loading": "正在读取会话…",
|
|
1799
|
-
"running": "
|
|
1942
|
+
"running": "正在工作",
|
|
1800
1943
|
"reply.ready": "新回复已生成",
|
|
1801
1944
|
"reply.view": "查看",
|
|
1802
1945
|
"reply.waiting": "AI 正在等待你的回复",
|
|
@@ -1865,7 +2008,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
|
|
|
1865
2008
|
"settings.width.hint": "Reading column width (480–1200px)",
|
|
1866
2009
|
"empty": "No open session",
|
|
1867
2010
|
"loading": "Reading session…",
|
|
1868
|
-
"running": "
|
|
2011
|
+
"running": "Working",
|
|
1869
2012
|
"reply.ready": "New reply ready",
|
|
1870
2013
|
"reply.view": "View",
|
|
1871
2014
|
"reply.waiting": "AI is waiting for your reply",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-focus-overlay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
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",
|