dsh-focus-overlay 0.11.2 → 0.12.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.
Files changed (2) hide show
  1. package/lib/client.js +188 -22
  2. package/package.json +2 -1
package/lib/client.js CHANGED
@@ -557,6 +557,14 @@ window.__ModuleLoader__.load({
557
557
  if (o.engaged || o.focused || o.draftEmpty && o.inZone) return "bar";
558
558
  return o.draftEmpty ? "tobottom" : "pill";
559
559
  }
560
+ /** Whether the bottom dock should render the OFFICIAL composer (CSS-lifted
561
+ * above the overlay) as its bar form: the user wants the borrow, the
562
+ * composer DOM probed available, and the dock currently wants the bar.
563
+ * Pure so the decision is unit-testable at the seam the lift effect
564
+ * drives — the e2e loop exercises the DOM consequences. */
565
+ function shouldLiftOfficialComposer(o) {
566
+ return o.wanted && o.available && o.form === "bar";
567
+ }
560
568
  /**
561
569
  * Bottom-zone membership with hysteresis: entering requires the viewport edge
562
570
  * within {@link BOTTOM_ENTER_PX} of the scroll end, leaving requires drifting
@@ -693,7 +701,8 @@ window.__ModuleLoader__.load({
693
701
  scroll: "preserve",
694
702
  width: 760,
695
703
  autoFocus: false,
696
- hotkey: true
704
+ hotkey: true,
705
+ borrow: true
697
706
  };
698
707
  function load() {
699
708
  try {
@@ -1036,13 +1045,27 @@ window.__ModuleLoader__.load({
1036
1045
  function InputBar(props) {
1037
1046
  const { t, width, value, queueCount, occCount, errorLine, textareaRef, onFocusChange, onChange, onSend } = props;
1038
1047
  const composingRef = (0, react.useRef)(false);
1048
+ const echoingRef = (0, react.useRef)(false);
1039
1049
  const [pending, setPending] = (0, react.useState)(null);
1040
1050
  const shown = pending ?? value;
1041
1051
  const empty = shown.trim() === "";
1042
- const flush = (v) => {
1043
- if (v === null) return;
1052
+ const echo = (el, next) => {
1053
+ const start = el.selectionStart;
1054
+ const end = el.selectionEnd;
1055
+ echoingRef.current = true;
1056
+ try {
1057
+ onChange(next);
1058
+ } finally {
1059
+ echoingRef.current = false;
1060
+ }
1061
+ if (document.activeElement !== el) {
1062
+ el.focus();
1063
+ el.setSelectionRange(start, end);
1064
+ }
1065
+ };
1066
+ const flush = (el) => {
1044
1067
  setPending(null);
1045
- onChange(v);
1068
+ echo(el, el.value);
1046
1069
  };
1047
1070
  const onCompositionStart = () => {
1048
1071
  composingRef.current = true;
@@ -1050,8 +1073,7 @@ window.__ModuleLoader__.load({
1050
1073
  };
1051
1074
  const onCompositionEnd = (e) => {
1052
1075
  composingRef.current = false;
1053
- flush(e.currentTarget.value);
1054
- refocusAfterEcho(e.currentTarget);
1076
+ flush(e.currentTarget);
1055
1077
  };
1056
1078
  const onKeyDown = (e) => {
1057
1079
  if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && e.nativeEvent.keyCode !== 229) {
@@ -1063,12 +1085,6 @@ window.__ModuleLoader__.load({
1063
1085
  el.style.height = "auto";
1064
1086
  el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
1065
1087
  };
1066
- const refocusAfterEcho = (el) => {
1067
- if (document.activeElement === el) return;
1068
- el.focus();
1069
- const end = el.value.length;
1070
- el.setSelectionRange(end, end);
1071
- };
1072
1088
  (0, react.useEffect)(() => {
1073
1089
  if (textareaRef.current) autoGrow(textareaRef.current);
1074
1090
  }, [shown, textareaRef]);
@@ -1077,10 +1093,9 @@ window.__ModuleLoader__.load({
1077
1093
  if (composingRef.current) setPending(el.value);
1078
1094
  else {
1079
1095
  setPending(null);
1080
- onChange(el.value);
1096
+ echo(el, el.value);
1081
1097
  }
1082
1098
  autoGrow(el);
1083
- refocusAfterEcho(el);
1084
1099
  };
1085
1100
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1086
1101
  className: "fm-bar",
@@ -1109,10 +1124,14 @@ window.__ModuleLoader__.load({
1109
1124
  onKeyDown,
1110
1125
  onFocus: () => onFocusChange(true),
1111
1126
  onBlur: () => {
1127
+ if (echoingRef.current) return;
1112
1128
  if (composingRef.current) {
1113
1129
  composingRef.current = false;
1114
- flush(shown);
1115
- if (textareaRef.current) refocusAfterEcho(textareaRef.current);
1130
+ const el = textareaRef.current;
1131
+ if (el) {
1132
+ flush(el);
1133
+ if (document.activeElement === el) return;
1134
+ }
1116
1135
  }
1117
1136
  onFocusChange(false);
1118
1137
  }
@@ -1137,8 +1156,9 @@ window.__ModuleLoader__.load({
1137
1156
  });
1138
1157
  }
1139
1158
  function FocusBottomDock(props) {
1140
- const { t, width, form, wait, onCardClose, draft, queueCount, occCount, errorLine, textareaRef, onFocusChange, onDraftChange, onSend, onExpand, onJumpBottom } = props;
1159
+ const { t, width, form, borrowed, wait, onCardClose, draft, queueCount, occCount, errorLine, textareaRef, onFocusChange, onDraftChange, onSend, onExpand, onJumpBottom } = props;
1141
1160
  if (form === "toast") return null;
1161
+ if (form === "bar" && borrowed) return null;
1142
1162
  if (form === "card") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnswerCard, {
1143
1163
  t,
1144
1164
  width,
@@ -1185,7 +1205,7 @@ window.__ModuleLoader__.load({
1185
1205
  .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}
1186
1206
  .fm-title{font-size:calc(14px + var(--dsh-content-font-delta,0px));font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1187
1207
  .fm-body-wrap{position:relative;flex:1 1 auto;display:flex;min-height:0}
1188
- .fm-body{flex:1 1 auto;min-width:0;overflow-y:auto;padding:28px 28px 110px}
1208
+ .fm-body{flex:1 1 auto;min-width:0;overflow-y:auto;padding:28px 28px 160px}
1189
1209
  .fm-inner{max-width:760px;margin:0 auto}
1190
1210
  .fm-msg{margin:0}
1191
1211
  .fm-user-msg{margin:0}
@@ -1238,6 +1258,31 @@ window.__ModuleLoader__.load({
1238
1258
  .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:var(--dsw-shadow-lv2,0 4px 16px rgba(0,0,0,.12));font-size:var(--dsh-content-font-size-secondary,13px)}
1239
1259
  .fm-reply-toast-dot{width:8px;height:8px;border-radius:999px;background:var(--dsw-static-deepseek-500,#4176e6);flex:0 0 auto}
1240
1260
  .fm-reply-toast-text{white-space:nowrap}
1261
+ /* ---- official-composer borrow: the lifted input bar ----
1262
+ The main view's composer seat ([data-composer-seat], normally in the
1263
+ conversation scroll flow UNDER this overlay) is re-anchored above the
1264
+ overlay while the dock wants the bar form. Its ancestor chain has no
1265
+ transform/filter/positioned-z traps (verified against the 0.1.2 build),
1266
+ so position:fixed escapes to the viewport; the z-index sits just above
1267
+ .fm-overlay's 2147483000 and below the int32 ceiling. The slash/@ menus
1268
+ are descendants of the composer card, so they lift with it.
1269
+ The seat itself must paint NOTHING: it is a layout box larger than the
1270
+ composer card (hero variant stacks brand/workspace rows above it), so a
1271
+ seat-level background or shadow would draw a translucent SQUARE behind
1272
+ the rounded card. Only the card's own skin shows; the seat's empty area
1273
+ is click-transparent so the reading surface beneath stays interactive. */
1274
+ .fm-lift{position:fixed!important;left:50%!important;right:auto!important;top:auto!important;bottom:20px!important;transform:translateX(-50%)!important;width:min(var(--fm-lift-width,680px),calc(100vw - 48px))!important;max-width:calc(100vw - 48px)!important;z-index:2147483200!important;margin:0!important;box-shadow:none!important;background:0 0!important;pointer-events:none}
1275
+ /* The composer's dock row (queue/references rail) is hidden while lifted on
1276
+ purpose: focus mode's dock is the plugin's own bottom-center region, and a
1277
+ second rail inside the floating bar would duplicate it. Noteboard's canvas
1278
+ borrow does the same. This drops that row's affordances IN FOCUS MODE only
1279
+ (the main view keeps them); revisit if a focus-mode user needs them. */
1280
+ .fm-lift [data-slot="conversation.composer.dock"]{display:none!important}
1281
+ .fm-lift [data-composer-card]{pointer-events:auto}
1282
+ /* Cap the lifted editor's scroll area: a long multi-line draft must not grow
1283
+ the floating card over half the reading surface (the main view allows the
1284
+ full composer column height, which focus mode cannot afford). */
1285
+ .fm-lift [data-input-scroll]{max-height:180px!important;overscroll-behavior:contain}
1241
1286
  /* ---- bottom dock: bar / pill / jump-to-bottom / answer card ----
1242
1287
  Visual language mirrors the official composer cards token-for-token
1243
1288
  (ui-conversation InputBar/QuestionComposer, ui-user-questions
@@ -1912,6 +1957,20 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
1912
1957
  }, i))]
1913
1958
  });
1914
1959
  }
1960
+ /** The main view's composer seat — the DOM node the borrow path CSS-lifts
1961
+ * above the overlay. ONE selector shared by the probe, the lift, the focus
1962
+ * bridge, and the Esc peel, so they can never disagree on WHAT they lift. */
1963
+ function composerSeat() {
1964
+ return document.querySelector("[data-composer-seat]");
1965
+ }
1966
+ /** The official editor's editable surface inside the seat. The SAME node the
1967
+ * availability probe verifies and the pill→bar handoff focuses: probe
1968
+ * success therefore implies a focusable editor. If a dsh update moves either
1969
+ * surface, the probe fails → the dock degrades to the built-in textarea bar
1970
+ * — never to a silently un-focusable lifted composer. */
1971
+ function composerEditable(seat) {
1972
+ return seat ? seat.querySelector("[contenteditable=\"true\"]") : null;
1973
+ }
1915
1974
  function FocusContent(props) {
1916
1975
  const { useSessions, sessions, workspaces, conversation, chatFileMentions, uiSession, uiConversation, settingsScope, t } = props;
1917
1976
  const prefs = usePrefs();
@@ -2185,7 +2244,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2185
2244
  const idx = findSeqIndex(items, seq);
2186
2245
  const el = idx >= 0 ? anchors["fm-" + idx] : void 0;
2187
2246
  if (el && bodyEl) {
2188
- const limit = bodyEl.getBoundingClientRect().bottom - 110;
2247
+ const limit = bodyEl.getBoundingClientRect().bottom - 160;
2189
2248
  const hidden = el.getBoundingClientRect().bottom - limit;
2190
2249
  if (hidden > 0) bodyEl.scrollBy({
2191
2250
  top: hidden,
@@ -2208,6 +2267,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2208
2267
  const onKey = (e) => {
2209
2268
  if (e.key !== "Escape") return;
2210
2269
  if (e.isComposing || e.keyCode === 229) return;
2270
+ if (composerSeat()?.querySelector("[role=\"listbox\"]")) return;
2211
2271
  e.preventDefault();
2212
2272
  e.stopPropagation();
2213
2273
  if (cardOpen) {
@@ -2292,7 +2352,10 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2292
2352
  };
2293
2353
  const hideBar = () => {
2294
2354
  try {
2295
- taRef.current && taRef.current.blur();
2355
+ if (taRef.current) taRef.current.blur();
2356
+ const seat = composerSeat();
2357
+ const active = document.activeElement;
2358
+ if (seat && active && seat.contains(active)) active.blur();
2296
2359
  } catch {}
2297
2360
  setEngaged(false);
2298
2361
  setDockFocused(false);
@@ -2317,6 +2380,87 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2317
2380
  focused: dockFocused,
2318
2381
  engaged
2319
2382
  });
2383
+ const [borrowAvailable, setBorrowAvailable] = (0, react.useState)(false);
2384
+ const borrowWanted = prefs.borrow && !!currentId && snap !== null;
2385
+ const borrowedBar = shouldLiftOfficialComposer({
2386
+ wanted: borrowWanted,
2387
+ available: borrowAvailable,
2388
+ form
2389
+ });
2390
+ (0, react.useEffect)(() => {
2391
+ if (!borrowWanted) {
2392
+ setBorrowAvailable(false);
2393
+ return;
2394
+ }
2395
+ let tries = 0;
2396
+ let timer = null;
2397
+ const probe = () => {
2398
+ if (composerEditable(composerSeat())) {
2399
+ setBorrowAvailable(true);
2400
+ return;
2401
+ }
2402
+ if (++tries > 30) return;
2403
+ timer = setTimeout(probe, 100);
2404
+ };
2405
+ probe();
2406
+ return () => {
2407
+ if (timer) clearTimeout(timer);
2408
+ };
2409
+ }, [borrowWanted]);
2410
+ (0, react.useEffect)(() => {
2411
+ if (!borrowedBar) return;
2412
+ const seat = composerSeat();
2413
+ if (!seat) return;
2414
+ const inSeat = (t) => t instanceof Node && seat.contains(t);
2415
+ const onFocusIn = (e) => {
2416
+ if (inSeat(e.target)) onDockFocusChange(true);
2417
+ };
2418
+ const onFocusOut = (e) => {
2419
+ if (inSeat(e.relatedTarget)) return;
2420
+ setTimeout(() => {
2421
+ if (!inSeat(document.activeElement)) onDockFocusChange(false);
2422
+ }, 0);
2423
+ };
2424
+ seat.addEventListener("focusin", onFocusIn);
2425
+ seat.addEventListener("focusout", onFocusOut);
2426
+ return () => {
2427
+ seat.removeEventListener("focusin", onFocusIn);
2428
+ seat.removeEventListener("focusout", onFocusOut);
2429
+ onDockFocusChange(false);
2430
+ };
2431
+ }, [borrowedBar]);
2432
+ (0, react.useEffect)(() => {
2433
+ const seat = composerSeat();
2434
+ if (!seat) return;
2435
+ if (!borrowedBar) return;
2436
+ seat.style.setProperty("--fm-lift-width", `${Math.min(prefs.width, 680)}px`);
2437
+ seat.classList.add("fm-lift");
2438
+ if (focusBarOnce.current) {
2439
+ const editable = composerEditable(seat);
2440
+ if (!editable) {
2441
+ setBorrowAvailable(false);
2442
+ setDockFocused(false);
2443
+ } else {
2444
+ focusBarOnce.current = false;
2445
+ let landed = false;
2446
+ try {
2447
+ editable.focus();
2448
+ landed = seat.contains(document.activeElement);
2449
+ } catch {}
2450
+ onDockFocusChange(landed);
2451
+ setEngaged(false);
2452
+ }
2453
+ }
2454
+ return () => {
2455
+ seat.classList.remove("fm-lift");
2456
+ seat.style.removeProperty("--fm-lift-width");
2457
+ };
2458
+ }, [borrowedBar, prefs.width]);
2459
+ const prevDraftRef = (0, react.useRef)(draftValue);
2460
+ (0, react.useEffect)(() => {
2461
+ if (prevDraftRef.current !== "" && draftValue === "" && zone) revealArmed.current = true;
2462
+ prevDraftRef.current = draftValue;
2463
+ }, [draftValue, zone]);
2320
2464
  (0, react.useEffect)(() => {
2321
2465
  if (!pending) {
2322
2466
  if (cardOpen) setCardOpen(false);
@@ -2327,14 +2471,14 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2327
2471
  setEngaged(false);
2328
2472
  }, [pending, cardOpen]);
2329
2473
  (0, react.useEffect)(() => {
2330
- if (form === "bar" && focusBarOnce.current) {
2474
+ if (form === "bar" && !borrowedBar && focusBarOnce.current) {
2331
2475
  focusBarOnce.current = false;
2332
2476
  try {
2333
2477
  taRef.current && taRef.current.focus();
2334
2478
  } catch {}
2335
2479
  setEngaged(false);
2336
2480
  }
2337
- }, [form]);
2481
+ }, [form, borrowedBar]);
2338
2482
  let title = "Focus Mode";
2339
2483
  if (currentId && listState && listState.byId && listState.byId[currentId]) {
2340
2484
  const row = listState.byId[currentId];
@@ -2578,6 +2722,7 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2578
2722
  t,
2579
2723
  width: prefs.width,
2580
2724
  form,
2725
+ borrowed: borrowedBar,
2581
2726
  wait,
2582
2727
  onCardClose: () => setCardOpen(false),
2583
2728
  draft: draftValue,
@@ -2677,6 +2822,23 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2677
2822
  children: t("settings.hotkey.hint")
2678
2823
  })]
2679
2824
  }),
2825
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2826
+ className: "fm-plugin-field",
2827
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
2828
+ className: "fm-plugin-check",
2829
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
2830
+ type: "checkbox",
2831
+ checked: prefs.borrow,
2832
+ onChange: (e) => prefsStore.update({ borrow: e.target.checked })
2833
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2834
+ className: "fm-plugin-check-label",
2835
+ children: t("settings.borrow")
2836
+ })]
2837
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
2838
+ className: "fm-plugin-field-hint",
2839
+ children: t("settings.borrow.hint")
2840
+ })]
2841
+ }),
2680
2842
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2681
2843
  className: "fm-plugin-field",
2682
2844
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
@@ -2854,6 +3016,8 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2854
3016
  "settings.autoFocus.hint": "AI 正常完成回复后自动进入并定位到你的提问",
2855
3017
  "settings.hotkey": "按 F 键进入专注模式",
2856
3018
  "settings.hotkey.hint": "在任意界面按下 F 立即进入专注模式;在输入框内打字不会触发",
3019
+ "settings.borrow": "使用官方输入框",
3020
+ "settings.borrow.hint": "专注模式直接借用主界面的官方输入框(支持 / 指令、@ 引用文件与会话);关闭时使用插件内置输入框。官方输入框不可用时自动回退。",
2857
3021
  "process.toolCalls": "{n} 次工具调用",
2858
3022
  "process.messages": "{n} 条消息",
2859
3023
  "process.subagents": "{n} 个 subagent",
@@ -2933,6 +3097,8 @@ body[data-fm-focus] #root :has(> [data-slot="conversation"]){margin-bottom:0!imp
2933
3097
  "settings.autoFocus.hint": "Auto-open at your question after a normal reply",
2934
3098
  "settings.hotkey": "Press F to enter focus mode",
2935
3099
  "settings.hotkey.hint": "Press F anywhere to enter focus mode instantly; typing in an input never triggers it",
3100
+ "settings.borrow": "Use the official composer",
3101
+ "settings.borrow.hint": "Focus mode borrows the main view's official composer (slash commands, @ file/session references included); turn off to use the built-in input bar. Falls back automatically when the official composer is unavailable.",
2936
3102
  "process.toolCalls": "{n} tool calls",
2937
3103
  "process.messages": "{n} messages",
2938
3104
  "process.subagents": "{n} subagents",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-focus-overlay",
3
- "version": "0.11.2",
3
+ "version": "0.12.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",
@@ -54,6 +54,7 @@
54
54
  "bundle"
55
55
  ],
56
56
  "devDependencies": {
57
+ "playwright-core": "^1.63.0",
57
58
  "tsdown": "0.22.2",
58
59
  "vitest": "^4.1.10"
59
60
  },