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 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
- let cur = null;
1004
- for (const k of navKeys) {
1064
+ const bodyTop = bodyEl.getBoundingClientRect().top;
1065
+ const i = activeNavIndex(navKeys.map((k) => {
1005
1066
  const el = anchors[k];
1006
- if (!el) continue;
1007
- if (el.getBoundingClientRect().top - bodyEl.getBoundingClientRect().top <= 96) cur = k;
1008
- else break;
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 handleScroll = () => {
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
- children: t("running")
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: handleScroll,
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
- .fm-running{color:var(--dsw-alias-label-secondary,#888);font-size:13px;margin:6px 0 16px}
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": "· Working …",
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.9.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",