dsh-milestone 0.6.5 → 0.7.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 (4) hide show
  1. package/README.md +182 -235
  2. package/lib/client.js +652 -185
  3. package/lib/index.js +203 -6
  4. package/package.json +18 -9
package/lib/client.js CHANGED
@@ -6,7 +6,7 @@ window.__ModuleLoader__.load({
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
7
  let react_jsx_runtime = require("react/jsx-runtime");
8
8
  let react = require("react");
9
- let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
9
+ let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
10
10
  //#region src/client/MilestoneOverlay.tsx
11
11
  /**
12
12
  * @param props - runtime share (root kit) + the narrowed renderSlot and the
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
15
15
  function MilestoneOverlay({ SessionProvider, renderSlot }) {
16
16
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionProvider, {
17
17
  empty: () => null,
18
- children: () => renderSlot("milestone.rail", {})
18
+ children: renderSlot("milestone.rail", {})
19
19
  });
20
20
  }
21
21
  //#endregion
@@ -198,6 +198,82 @@ window.__ModuleLoader__.load({
198
198
  50% { opacity: 0.45; box-shadow: ${`0 0 0 2px ${layer(.35)}, 0 0 4px 1px ${layer(.25)}, 0 0 9px 3px ${layer(.12)}`}; }
199
199
  }`;
200
200
  }
201
+ /**
202
+ * Clamp a ball's top-left so the WHOLE circle stays inside the viewport.
203
+ *
204
+ * Per axis: `max = Math.max(0, viewportSize - ballSize - margin)` and
205
+ * `min = Math.min(margin, max)`. When there IS room, `min` is the margin, so
206
+ * the ball keeps its preferred distance from the edge; when the viewport is
207
+ * smaller than ball + margins, `max` collapses to 0 (or below) and `min`
208
+ * follows it, pinning the ball to the top/left outer edge instead of letting
209
+ * it escape the viewport.
210
+ *
211
+ * Non-finite `pos` values degrade to `0` BEFORE clamping, so a corrupt call
212
+ * still lands on a finite, fully-visible position.
213
+ */
214
+ function clampBallPosition(pos, viewport, ballSize = 40, margin = 8) {
215
+ const xRaw = Number.isFinite(pos.x) ? pos.x : 0;
216
+ const yRaw = Number.isFinite(pos.y) ? pos.y : 0;
217
+ const maxX = Math.max(0, viewport.width - ballSize - margin);
218
+ const minX = Math.min(margin, maxX);
219
+ const maxY = Math.max(0, viewport.height - ballSize - margin);
220
+ return {
221
+ x: Math.min(maxX, Math.max(minX, xRaw)),
222
+ y: Math.min(maxY, Math.max(Math.min(margin, maxY), yRaw))
223
+ };
224
+ }
225
+ /**
226
+ * True when pointer travel from `start` to `current` exceeds the drag
227
+ * threshold — the gesture vocabulary of the ball: a press that stays put (or
228
+ * wiggles less than the threshold) is a CLICK (toggle the rail), a press that
229
+ * travels farther is a DRAG (move the ball).
230
+ *
231
+ * Distance is Euclidean, so a diagonal move counts the same as a straight one.
232
+ * EXACTLY at the threshold is still a click (strictly greater than).
233
+ */
234
+ function isDragGesture(start, current, threshold = 5) {
235
+ const dx = current.x - start.x;
236
+ const dy = current.y - start.y;
237
+ return Math.hypot(dx, dy) > threshold;
238
+ }
239
+ /**
240
+ * Sanitize a persisted ball position; return null when unusable.
241
+ *
242
+ * Accepts only a plain object carrying FINITE numeric `x` and `y` (extra keys
243
+ * are ignored, so a future blob extension stays readable). Everything else —
244
+ * `null`, arrays, strings, numbers, NaN/Infinity, missing keys — degrades to
245
+ * `null`, which callers read as "no stored position, use the computed default
246
+ * resting spot" (see {@link defaultBallPosition}).
247
+ *
248
+ * No clamping happens here: the stored value is resolution-agnostic and gets
249
+ * clamped against the live viewport by {@link clampBallPosition} at render
250
+ * time.
251
+ */
252
+ function sanitizeBallPosition(raw) {
253
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
254
+ const { x, y } = raw;
255
+ if (typeof x !== "number" || !Number.isFinite(x)) return null;
256
+ if (typeof y !== "number" || !Number.isFinite(y)) return null;
257
+ return {
258
+ x,
259
+ y
260
+ };
261
+ }
262
+ /**
263
+ * Default resting spot: hugging `side` at `inset` from the edge, vertically
264
+ * centered. `inset` is measured from the nearest screen edge; the vertical
265
+ * axis is simply centered on the viewport.
266
+ *
267
+ * The raw spot is always passed through {@link clampBallPosition} with the
268
+ * default margin, so the resting spot is fully visible even when `inset`
269
+ * overflows or the viewport is smaller than the ball.
270
+ */
271
+ function defaultBallPosition(viewport, side, inset, ballSize = 40) {
272
+ return clampBallPosition({
273
+ x: side === "left" ? inset : viewport.width - ballSize - inset,
274
+ y: (viewport.height - ballSize) / 2
275
+ }, viewport, ballSize, 8);
276
+ }
201
277
  //#endregion
202
278
  //#region src/client/bookmark-logic.ts
203
279
  /**
@@ -385,66 +461,22 @@ window.__ModuleLoader__.load({
385
461
  inputTokens: null,
386
462
  outputTokens: null
387
463
  };
388
- /** True when the value is a plain (non-array, non-null) object. */
389
- function isRecord(value) {
390
- return value !== null && typeof value === "object" && !Array.isArray(value);
391
- }
392
464
  /**
393
- * Decode a `usage` payload structurally: only numeric `inputTokens` /
394
- * `outputTokens` survive; anything else (absent, malformed, wrong types)
395
- * degrades to null — the boundary owns trust, the callers get plain numbers.
396
- * @param usage - untrusted usage payload (typed `unknown` at runtime).
397
- * @returns the token counts with null for every missing/malformed field.
465
+ * 0.1.2: derive hover metadata from the `milestone.messages` projection's
466
+ * per-turn fold state. The fold carries token usage and timing; provider /
467
+ * model provenance is not folded yet, so those degrade to null.
468
+ * @param turn - the projection's turn meta, or undefined when absent.
469
+ * @returns the turn's metadata, null where unknown.
398
470
  */
399
- function decodeUsage(usage) {
400
- if (!isRecord(usage)) return {
401
- inputTokens: null,
402
- outputTokens: null
403
- };
471
+ function deriveTurnMetaFromProjection(turn) {
472
+ if (turn?.usage === void 0) return EMPTY_META;
404
473
  return {
405
- inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
406
- outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null
474
+ model: null,
475
+ purpose: null,
476
+ inputTokens: turn.usage.input,
477
+ outputTokens: turn.usage.output
407
478
  };
408
479
  }
409
- /** Resolve model/purpose from a request config, falling back to provenance. */
410
- function metaFromRecord(record) {
411
- return {
412
- model: record.requestConfig?.model ?? record.provenance?.model ?? null,
413
- purpose: record.requestConfig?.purpose ?? null,
414
- ...decodeUsage(record.usage)
415
- };
416
- }
417
- /**
418
- * Derive the hover metadata for one turn. Sources, in priority order:
419
- * 1. the `assistant-step` chat node(s) of the turn — their `data.finalNode`
420
- * carries the recorded `requestConfig` / `provenance` / `usage`;
421
- * 2. `trajectoryRequests` — the latest entry whose `turn` matches (used when
422
- * no assistant-step node yields a model or purpose);
423
- * 3. all-null when the turn is absent, no node matches, or everything is
424
- * malformed. Never throws.
425
- * @param nodes - stable per-key chat node reader (as exposed by the snapshot).
426
- * @param locations - turn -> ordered node keys index.
427
- * @param turn - owning turn; undefined yields all-null.
428
- * @param trajectoryRequests - optional fallback request log.
429
- * @returns the turn's metadata, null where unknown.
430
- */
431
- function deriveTurnMeta(nodes, locations, turn, trajectoryRequests) {
432
- if (turn === void 0) return EMPTY_META;
433
- for (const key of locations.getTurn(turn)) {
434
- const node = nodes.get(key);
435
- if (node === void 0 || node.kind !== "assistant-step") continue;
436
- const finalNode = (isRecord(node.data) ? node.data : void 0)?.finalNode;
437
- if (!isRecord(finalNode)) continue;
438
- const meta = metaFromRecord(finalNode);
439
- if (meta.model !== null || meta.purpose !== null) return meta;
440
- }
441
- if (trajectoryRequests !== void 0) {
442
- let latest;
443
- for (const request of trajectoryRequests) if (request.turn === turn) latest = request;
444
- if (latest !== void 0) return metaFromRecord(latest);
445
- }
446
- return EMPTY_META;
447
- }
448
480
  //#endregion
449
481
  //#region src/client/rail-keyboard.ts
450
482
  /**
@@ -481,22 +513,6 @@ window.__ModuleLoader__.load({
481
513
  * can consume them directly and tests can exercise them in isolation.
482
514
  */
483
515
  /**
484
- * Extract the FULL plain text of a ContentBlock[] payload: the `text` of every
485
- * `{ type: 'text', text: string }` block, joined with a single space and
486
- * trimmed. Unlike the rail's hover preview this is NOT truncated — callers use
487
- * it for search matching, so the entire message must be searchable.
488
- * @param content - untrusted payload; anything that is not an array yields ''.
489
- */
490
- function extractText(content) {
491
- if (!Array.isArray(content)) return "";
492
- const parts = [];
493
- for (const block of content) if (block !== null && typeof block === "object" && block.type === "text") {
494
- const text = block.text;
495
- if (typeof text === "string") parts.push(text);
496
- }
497
- return parts.join(" ").trim();
498
- }
499
- /**
500
516
  * Case-insensitive substring filter over mark texts.
501
517
  * @param marks - marks in rail order.
502
518
  * @param query - the search query; empty/whitespace matches everything.
@@ -598,6 +614,9 @@ window.__ModuleLoader__.load({
598
614
  "turn.label": "第 {n} 轮",
599
615
  /** Hover position: `第 {hover.index + 1} / {hover.total} 条`. */
600
616
  "pos.of": "第 {n} / {m} 条",
617
+ /** Hover position of a collapsed-turn summary dot: `第 {a}–{b} / {m} 条`
618
+ * (the range of messages the summary dot represents). */
619
+ "pos.range": "第 {a}–{b} / {m} 条",
601
620
  /** Search input placeholder. */
602
621
  "search.placeholder": "搜索消息内容",
603
622
  /** aria-label on the search toggle button and the search input. */
@@ -618,6 +637,10 @@ window.__ModuleLoader__.load({
618
637
  "rail.label": "会话里程碑",
619
638
  /** aria-label on the dot list. */
620
639
  "rail.list": "会话里程碑列表",
640
+ /** aria-label on the rail-collapse control (issue #4: fold the rail into the floating ball). */
641
+ "rail.collapse": "收起为悬浮球",
642
+ /** aria-label + title on the collapsed rail's floating ball (click = expand). */
643
+ "ball.expand": "展开里程碑条",
621
644
  /** Hover preview fallback for empty message text. */
622
645
  "no.text": "(无文本)",
623
646
  /** Relative time: `< 60s`. */
@@ -658,6 +681,8 @@ window.__ModuleLoader__.load({
658
681
  "list.close": "收起列表",
659
682
  /** Header title of the all-prompts list panel. */
660
683
  "list.label": "全部提问",
684
+ /** Bottom hint of the all-prompts list while it drains older pages. */
685
+ "list.loading": "正在加载更早消息…",
661
686
  /** Header title + input placeholder of the cross-session search panel. */
662
687
  "search.cross": "跨会话搜索",
663
688
  /** aria-label + title on the cross-session search toggle when the panel is CLOSED. */
@@ -706,6 +731,8 @@ window.__ModuleLoader__.load({
706
731
  "settings.section.personal": "个性化",
707
732
  /** Settings modal: section heading for the focus-mode controls (0.6.3). */
708
733
  "settings.section.focus": "聚焦",
734
+ /** Settings modal: section heading for the floating-ball controls (issue #4). */
735
+ "settings.section.ball": "悬浮球",
709
736
  /** Settings: personalization-section hint shown inside the expanded block. */
710
737
  "settings.personal.hint": "圆点、强调色与位置,即调即存",
711
738
  /** Settings: aria-label on the personalization block toggle while COLLAPSED. */
@@ -742,6 +769,16 @@ window.__ModuleLoader__.load({
742
769
  "settings.side.left": "左侧",
743
770
  /** Settings personalization: side radio — hug the right edge. */
744
771
  "settings.side.right": "右侧",
772
+ /** Settings: floating-ball block — hint shown inside the expanded block. */
773
+ "settings.ball.hint": "收起后变为悬浮球;可固定,也可自由拖动到任意位置",
774
+ /** Settings: floating-ball behavior row label + radiogroup aria-label. */
775
+ "settings.ball.mode": "行为",
776
+ /** Settings: floating-ball behavior radio — pinned to the resting spot. */
777
+ "settings.ball.mode.fixed": "固定",
778
+ /** Settings: floating-ball behavior radio — free drag anywhere. */
779
+ "settings.ball.mode.draggable": "可拖动",
780
+ /** Settings: floating-ball action — drop the persisted drag position. */
781
+ "settings.ball.reset": "重置位置",
745
782
  /** Settings: focus block — hint shown inside the expanded block. */
746
783
  "settings.focus.hint": "这些选项自由组合成你的「聚焦搭配」;总开关仍是工具栏的眼睛按钮",
747
784
  /** Settings: aria-label on the focus block toggle while COLLAPSED. */
@@ -836,6 +873,8 @@ window.__ModuleLoader__.load({
836
873
  "window.hint": "Showing {n} messages · more below",
837
874
  "turn.label": "Turn {n}",
838
875
  "pos.of": "Message {n} of {m}",
876
+ /** Collapsed-summary dot position: the message RANGE the dot represents. */
877
+ "pos.range": "Messages {a}–{b} of {m}",
839
878
  "search.placeholder": "Search message content",
840
879
  "search.label": "Search messages",
841
880
  "bookmark.filter": "Bookmarks only",
@@ -846,6 +885,8 @@ window.__ModuleLoader__.load({
846
885
  "load.older": "Load older messages",
847
886
  "rail.label": "Session milestones",
848
887
  "rail.list": "Session milestone list",
888
+ "rail.collapse": "Collapse to floating ball",
889
+ "ball.expand": "Expand milestone rail",
849
890
  "no.text": "(no text)",
850
891
  "time.justNow": "Just now",
851
892
  "time.minutes": "{n} minutes ago",
@@ -866,6 +907,8 @@ window.__ModuleLoader__.load({
866
907
  "list.open": "Open list",
867
908
  "list.close": "Close list",
868
909
  "list.label": "All prompts",
910
+ /** Bottom hint of the all-prompts list while it drains older pages. */
911
+ "list.loading": "Loading earlier messages…",
869
912
  "search.cross": "Cross-session search",
870
913
  "search.cross.open": "Open cross-session search",
871
914
  "search.cross.close": "Close cross-session search",
@@ -890,6 +933,7 @@ window.__ModuleLoader__.load({
890
933
  "settings.section.features": "Features & Shortcuts",
891
934
  "settings.section.personal": "Personalization",
892
935
  "settings.section.focus": "Focus",
936
+ "settings.section.ball": "Floating ball",
893
937
  "settings.personal.hint": "Dot size, accent color, and position — saved as you adjust",
894
938
  "settings.personal.expand": "Expand personalization",
895
939
  "settings.personal.collapse": "Collapse personalization",
@@ -908,6 +952,11 @@ window.__ModuleLoader__.load({
908
952
  "settings.side": "Position",
909
953
  "settings.side.left": "Left",
910
954
  "settings.side.right": "Right",
955
+ "settings.ball.hint": "Collapses into a floating ball — pin it, or drag it anywhere",
956
+ "settings.ball.mode": "Behavior",
957
+ "settings.ball.mode.fixed": "Fixed",
958
+ "settings.ball.mode.draggable": "Draggable",
959
+ "settings.ball.reset": "Reset position",
911
960
  "settings.focus.hint": "Combine these options into your own focus recipe; the eye button on the toolbar stays the master switch",
912
961
  "settings.focus.expand": "Expand focus settings",
913
962
  "settings.focus.collapse": "Collapse focus settings",
@@ -974,6 +1023,31 @@ window.__ModuleLoader__.load({
974
1023
  //#endregion
975
1024
  //#region src/client/turn-group-logic.ts
976
1025
  /**
1026
+ * Renumber the raw harness turn numbers into a compact 1-based DISPLAY
1027
+ * sequence over the marks that actually render. The harness numbers every
1028
+ * engine turn (subagent/injected turns included), so the raw numbers show
1029
+ * gaps (turns that produced no user mark) and repeats (several marks sharing
1030
+ * one turn); labels fed through this map read as clean 1, 2, 3, … rounds.
1031
+ *
1032
+ * Grouping and collapse logic keep operating on the RAW turn (same-turn marks
1033
+ * are contiguous in rail order, so first-appearance ranking preserves the
1034
+ * partition) — only labels consume this map.
1035
+ *
1036
+ * @param marks - marks in rail order (only `turn` is consulted).
1037
+ * @returns raw turn -> display round (1-based), in first-appearance order;
1038
+ * turns that never appear (or marks without turn info) get no entry.
1039
+ */
1040
+ function buildDisplayTurns(marks) {
1041
+ const display = /* @__PURE__ */ new Map();
1042
+ let rank = 0;
1043
+ for (const mark of marks) {
1044
+ if (mark.turn === void 0) continue;
1045
+ if (display.has(mark.turn)) continue;
1046
+ display.set(mark.turn, ++rank);
1047
+ }
1048
+ return display;
1049
+ }
1050
+ /**
977
1051
  * Partition consecutive marks by turn. Marks with the same numeric turn that
978
1052
  * appear one after another share a group; each mark with `turn === undefined`
979
1053
  * becomes its own singleton group with `turn: null`.
@@ -1286,9 +1360,10 @@ window.__ModuleLoader__.load({
1286
1360
  /**
1287
1361
  * @param props - the panel anchor, the full marks array, and the rail's jump handler.
1288
1362
  */
1289
- function MilestoneListPanel({ panelTop, panelRight, marks, onJump, onClose, t }) {
1363
+ function MilestoneListPanel({ panelTop, panelRight, marks, onJump, onClose, loading = false, t }) {
1290
1364
  const panelRef = (0, react.useRef)(null);
1291
1365
  useOutsideDismiss(panelRef, true, onClose, { exclude: (target) => outsideDismissMatches(target, "[data-list-toggle]") });
1366
+ const displayTurns = (0, react.useMemo)(() => buildDisplayTurns(marks), [marks]);
1292
1367
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1293
1368
  ref: panelRef,
1294
1369
  "data-milestone-list": true,
@@ -1328,7 +1403,7 @@ window.__ModuleLoader__.load({
1328
1403
  children: marks.length
1329
1404
  })]
1330
1405
  }),
1331
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1406
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1332
1407
  style: {
1333
1408
  maxHeight: 300,
1334
1409
  overflowY: "auto",
@@ -1336,7 +1411,7 @@ window.__ModuleLoader__.load({
1336
1411
  flexDirection: "column",
1337
1412
  gap: 2
1338
1413
  },
1339
- children: marks.map((mark, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1414
+ children: [marks.map((mark, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
1340
1415
  type: "button",
1341
1416
  "data-list-item": true,
1342
1417
  "data-jump-key": mark.key,
@@ -1363,7 +1438,7 @@ window.__ModuleLoader__.load({
1363
1438
  children: [t("pos.of", {
1364
1439
  n: i + 1,
1365
1440
  m: marks.length
1366
- }), mark.turn !== void 0 ? ` · ${t("turn.label", { n: mark.turn })}` : null]
1441
+ }), mark.turn !== void 0 ? ` · ${t("turn.label", { n: displayTurns.get(mark.turn) ?? mark.turn })}` : null]
1367
1442
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1368
1443
  style: {
1369
1444
  fontSize: 13,
@@ -1374,7 +1449,16 @@ window.__ModuleLoader__.load({
1374
1449
  },
1375
1450
  children: mark.preview || t("no.text")
1376
1451
  })]
1377
- }, mark.key))
1452
+ }, mark.key)), loading && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1453
+ "data-list-loading": true,
1454
+ style: {
1455
+ fontSize: 12,
1456
+ color: "#8b96ab",
1457
+ textAlign: "center",
1458
+ padding: "6px 8px"
1459
+ },
1460
+ children: t("list.loading")
1461
+ })]
1378
1462
  })
1379
1463
  ]
1380
1464
  });
@@ -1831,10 +1915,7 @@ window.__ModuleLoader__.load({
1831
1915
  marginBottom: 4
1832
1916
  },
1833
1917
  children: [
1834
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pos.of", {
1835
- n: hover.index + 1,
1836
- m: hover.total
1837
- }) }),
1918
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.posLabel }),
1838
1919
  hover.turnLabel !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hover.turnLabel }),
1839
1920
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1840
1921
  type: "button",
@@ -2246,7 +2327,8 @@ window.__ModuleLoader__.load({
2246
2327
  * toolbar-prefs: the persistence layer for the milestone rail's toolbar
2247
2328
  * personalization — WHICH function keys stay visible outside the collapse
2248
2329
  * (pinned) plus the settings-module appearance prefs (accent color, icon/dot
2249
- * size, distance from the rail's screen edge, and rail side).
2330
+ * size, distance from the rail's screen edge, rail side) and the collapsed
2331
+ * rail's floating ball (mode + last position).
2250
2332
  *
2251
2333
  * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding a
2252
2334
  * JSON object:
@@ -2254,13 +2336,17 @@ window.__ModuleLoader__.load({
2254
2336
  * { "pinned": string[], "accent": "#rrggbb", "iconSize": number,
2255
2337
  * "inset": number, "side": "left" | "right", "locale": "system"|"zh"|"en",
2256
2338
  * "focus": { "dimThink": boolean, "dimTools": boolean,
2257
- * "collapseThink": boolean, "opacity": number } }
2339
+ * "collapseThink": boolean, "opacity": number },
2340
+ * "ballMode": "fixed" | "draggable",
2341
+ * "ball": { "x": number, "y": number } | null }
2258
2342
  *
2259
2343
  * Backward compatibility: the pre-personalization blob `{ "pinned": string[] }`
2260
2344
  * (and an entirely absent value) parses to the DEFAULT prefs with the new
2261
2345
  * fields at their defaults — old users keep their pins untouched. The same
2262
2346
  * rule covers the `focus` object: a blob stored before 0.6.3 (no `focus`
2263
- * field) gains the default focus mix.
2347
+ * field) gains the default focus mix, and a blob stored before the floating
2348
+ * ball (no `ballMode` / `ball`) gains `ballMode: 'draggable'` + `ball: null`
2349
+ * (the computed default resting spot).
2264
2350
  *
2265
2351
  * All reads are sanitized per field:
2266
2352
  * - `pinned`: whitelisted ids only (`TOOLBAR_PIN_IDS`), duplicates dropped,
@@ -2272,7 +2358,11 @@ window.__ModuleLoader__.load({
2272
2358
  * - `side`: exactly `'left'` or `'right'`;
2273
2359
  * - `focus`: three booleans (`dimThink` / `dimTools` / `collapseThink`)
2274
2360
  * defaulting to `true` / `false` / `false`, plus the dim `opacity`
2275
- * snapped to the 0.1 step and clamped to [0.2, 0.8].
2361
+ * snapped to the 0.1 step and clamped to [0.2, 0.8];
2362
+ * - `ballMode`: exactly `'fixed'`, otherwise `'draggable'` (including a
2363
+ * legacy blob stored without the field);
2364
+ * - `ball`: a finite `{x, y}` pair, otherwise `null` — viewport clamping
2365
+ * happens at render time (`clampBallPosition`), never in storage.
2276
2366
  *
2277
2367
  * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
2278
2368
  * dependency-free and unit-testable; MilestoneRail's feature registry keys
@@ -2316,7 +2406,9 @@ window.__ModuleLoader__.load({
2316
2406
  inset: 14,
2317
2407
  side: "right",
2318
2408
  locale: "system",
2319
- focus: { ...DEFAULT_FOCUS_PREFS }
2409
+ focus: { ...DEFAULT_FOCUS_PREFS },
2410
+ ballMode: "draggable",
2411
+ ball: null
2320
2412
  };
2321
2413
  /** Type guard for registry ids — unknown strings never survive a parse. */
2322
2414
  function isToolbarPinId(id) {
@@ -2384,7 +2476,7 @@ window.__ModuleLoader__.load({
2384
2476
  return { ...DEFAULT_PREFS };
2385
2477
  }
2386
2478
  if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_PREFS };
2387
- const { pinned, accent, iconSize, inset, side, locale, focus } = parsed;
2479
+ const { pinned, accent, iconSize, inset, side, locale, focus, ballMode, ball } = parsed;
2388
2480
  return {
2389
2481
  pinned: sanitizePinned(pinned),
2390
2482
  accent: typeof accent === "string" && isHexColor(accent) ? accent.toLowerCase() : DEFAULT_PREFS.accent,
@@ -2392,7 +2484,9 @@ window.__ModuleLoader__.load({
2392
2484
  inset: clampStep(inset, 0, 40, 2, DEFAULT_PREFS.inset),
2393
2485
  side: side === "left" || side === "right" ? side : DEFAULT_PREFS.side,
2394
2486
  locale: locale === "zh" || locale === "en" || locale === "system" ? locale : DEFAULT_PREFS.locale,
2395
- focus: sanitizeFocus(focus)
2487
+ focus: sanitizeFocus(focus),
2488
+ ballMode: ballMode === "fixed" ? "fixed" : "draggable",
2489
+ ball: sanitizeBallPosition(ball)
2396
2490
  };
2397
2491
  }
2398
2492
  /**
@@ -2738,7 +2832,7 @@ window.__ModuleLoader__.load({
2738
2832
  * Installed plugin version. Injected at build time as
2739
2833
  * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
2740
2834
  */
2741
- const PLUGIN_VERSION = "0.6.5";
2835
+ const PLUGIN_VERSION = "0.7.0";
2742
2836
  //#endregion
2743
2837
  //#region src/client/MilestoneRail.tsx
2744
2838
  /**
@@ -2792,9 +2886,12 @@ window.__ModuleLoader__.load({
2792
2886
  */
2793
2887
  /** Minimum user messages before the rail adds value. */
2794
2888
  const MIN_MARKS = 2;
2795
- const PREVIEW_LENGTH = 80;
2796
2889
  /** Stable no-bookmarks fallback for render paths without the store seat. */
2797
2890
  const NO_BOOKMARKS = [];
2891
+ /** Stable empty projection fallback before the host unit mounts. */
2892
+ const EMPTY_PROJECTION_MESSAGES = Object.freeze([]);
2893
+ /** 0.1.2: per-turn badge kinds are not projected yet; stays empty. */
2894
+ const EMPTY_KINDS_BY_TURN = /* @__PURE__ */ new Map();
2798
2895
  /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
2799
2896
  const NO_KINDS = [];
2800
2897
  /** Visual dot diameter at the default icon size (px). */
@@ -2917,24 +3014,24 @@ window.__ModuleLoader__.load({
2917
3014
  /* Row and header washes (the inline styles deliberately leave backgrounds
2918
3015
  unset so these rules win over the default padding-box background). */
2919
3016
  [data-toolbar-pin-toggle]:hover, [data-toolbar-pin-toggle]:focus-visible { background: rgba(255, 255, 255, 0.06); }
2920
- [data-personal-toggle]:hover, [data-focus-toggle-settings]:hover { background: rgba(255, 255, 255, 0.05); }
3017
+ [data-personal-toggle]:hover, [data-focus-toggle-settings]:hover, [data-ball-toggle]:hover { background: rgba(255, 255, 255, 0.05); }
2921
3018
  [data-focus-option]:hover { background: rgba(255, 255, 255, 0.04); }
2922
3019
  [data-toolbar-settings-close]:hover { background: rgba(255, 255, 255, 0.08); }
2923
- [data-toolbar-settings-reset], [data-onboarding-reopen] { background: rgba(255, 255, 255, 0.06); }
2924
- [data-toolbar-settings-reset]:hover, [data-onboarding-reopen]:hover { background: rgba(255, 255, 255, 0.1); }
3020
+ [data-toolbar-settings-reset], [data-onboarding-reopen], [data-ball-reset] { background: rgba(255, 255, 255, 0.06); }
3021
+ [data-toolbar-settings-reset]:hover, [data-onboarding-reopen]:hover, [data-ball-reset]:hover { background: rgba(255, 255, 255, 0.1); }
2925
3022
  /* BASE state reset: every modal surface must sit transparent on the dark
2926
3023
  panel — without it the UA default button face (light gray) floods through
2927
3024
  and rows become unreadable light-on-light. Hover washes above take over
2928
3025
  on interaction. */
2929
- [data-toolbar-pin-toggle], [data-personal-toggle], [data-focus-toggle-settings],
3026
+ [data-toolbar-pin-toggle], [data-personal-toggle], [data-focus-toggle-settings], [data-ball-toggle],
2930
3027
  [data-toolbar-settings-close], [data-focus-option] {
2931
3028
  background: transparent;
2932
3029
  }
2933
3030
  /* ONE accent ring for keyboard focus on every modal control. */
2934
3031
  [data-toolbar-pin-toggle]:focus-visible, [data-personal-toggle]:focus-visible,
2935
- [data-focus-toggle-settings]:focus-visible,
3032
+ [data-focus-toggle-settings]:focus-visible, [data-ball-toggle]:focus-visible,
2936
3033
  [data-toolbar-settings-close]:focus-visible, [data-toolbar-settings-reset]:focus-visible,
2937
- [data-onboarding-reopen]:focus-visible {
3034
+ [data-onboarding-reopen]:focus-visible, [data-ball-reset]:focus-visible {
2938
3035
  box-shadow: 0 0 0 2px var(--ms-accent-soft);
2939
3036
  }
2940
3037
  /* Near-row description tip: a rotated square peeks out of the LEFT edge so
@@ -2948,10 +3045,10 @@ window.__ModuleLoader__.load({
2948
3045
  }
2949
3046
  /* Tip + chevron motion lives here (not inline) so reduced-motion can kill it. */
2950
3047
  [data-settings-tip] { transition: opacity 140ms ease, transform 140ms ease, visibility 140ms; }
2951
- [data-personal-toggle] svg, [data-focus-toggle-settings] svg { transition: transform 150ms ease; }
2952
- /* One authored reveal: the personalization/focus bodies fade in on expand. */
3048
+ [data-personal-toggle] svg, [data-focus-toggle-settings] svg, [data-ball-toggle] svg { transition: transform 150ms ease; }
3049
+ /* One authored reveal: the personalization/focus/ball bodies fade in on expand. */
2953
3050
  @keyframes ms-settings-fade { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } }
2954
- [data-settings-personal-body], [data-settings-focus-body] { animation: ms-settings-fade 140ms ease; }
3051
+ [data-settings-personal-body], [data-settings-focus-body], [data-settings-ball-body] { animation: ms-settings-fade 140ms ease; }
2955
3052
  /* Thin themed scrollbar for the scrollable modal panel. */
2956
3053
  [data-toolbar-settings-panel]::-webkit-scrollbar { width: 10px; }
2957
3054
  [data-toolbar-settings-panel]::-webkit-scrollbar-thumb {
@@ -2959,8 +3056,8 @@ window.__ModuleLoader__.load({
2959
3056
  }
2960
3057
  [data-toolbar-settings-panel]::-webkit-scrollbar-track { background: transparent; }
2961
3058
  @media (prefers-reduced-motion: reduce) {
2962
- [data-settings-tip], [data-personal-toggle] svg, [data-focus-toggle-settings] svg, [data-support-card] { transition: none; }
2963
- [data-settings-personal-body], [data-settings-focus-body] { animation: none; }
3059
+ [data-settings-tip], [data-personal-toggle] svg, [data-focus-toggle-settings] svg, [data-ball-toggle] svg, [data-support-card] { transition: none; }
3060
+ [data-settings-personal-body], [data-settings-focus-body], [data-settings-ball-body] { animation: none; }
2964
3061
  }
2965
3062
  [data-search-toggle] { color: #8b96ab !important; }
2966
3063
  [data-search-toggle][aria-pressed="true"] { background: var(--ms-accent-bg) !important; color: var(--ms-accent-soft) !important; }
@@ -3034,26 +3131,21 @@ window.__ModuleLoader__.load({
3034
3131
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
3035
3132
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
3036
3133
  */
3037
- function findRow(key) {
3038
- for (const row of document.querySelectorAll("[data-chat-anchor-key]")) if (row.dataset.chatAnchorKey === key) return row;
3134
+ function findRow(key, messageId) {
3135
+ const rows = document.querySelectorAll("[data-chat-anchor-key]");
3136
+ for (const row of rows) if (row.dataset.chatAnchorKey === key) return row;
3137
+ if (messageId !== void 0 && messageId !== "") for (const row of rows) {
3138
+ const anchor = row.dataset.chatAnchorKey;
3139
+ if (anchor !== void 0 && anchor.endsWith(messageId)) return row;
3140
+ }
3039
3141
  return null;
3040
3142
  }
3041
- /** Extract a plain-text hover preview (first 80 chars) from a ContentBlock[]. */
3042
- function extractPreview(content) {
3043
- return extractText(content).slice(0, PREVIEW_LENGTH);
3044
- }
3045
3143
  /** Compact duration label (ms). */
3046
3144
  function formatDuration(ms) {
3047
3145
  if (ms < 1e3) return `${ms}ms`;
3048
3146
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
3049
3147
  return `${Math.floor(ms / 6e4)}m${Math.floor(ms % 6e4 / 1e3)}s`;
3050
3148
  }
3051
- /** Read the ui-conversation 'turn-tail' location data (ttftMs/tokensPerSecond). */
3052
- function turnTailOf(turn) {
3053
- const data = turn.data;
3054
- if (data?.get === void 0) return void 0;
3055
- return data.get("turn-tail");
3056
- }
3057
3149
  /**
3058
3150
  * @param props - session standard kit (useSession, sessionId, useProjection),
3059
3151
  * the injected loadOlder/forkAt actions, the bookmarks store pair (useStore +
@@ -3062,52 +3154,28 @@ window.__ModuleLoader__.load({
3062
3154
  * `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
3063
3155
  * outside the slot machinery).
3064
3156
  */
3065
- function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
3157
+ function MilestoneRail({ useSession, useProjection, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
3066
3158
  items: [],
3067
3159
  hasMore: false
3068
3160
  }), openSession = () => {}, t: frameworkT = (key) => key }) {
3069
- const order = useSession((s) => s.chat.order);
3070
- const nodes = useSession((s) => s.chat.nodes);
3071
- const locations = useSession((s) => s.chat.locations);
3072
- const timeline = useSession((s) => s.chat.timeline);
3073
- const trajectoryRequests = useSession((s) => s.views.get("trajectory")?.requests);
3161
+ const projection = useProjection?.("milestone.messages");
3074
3162
  const hasMore = useSession((s) => s.hasMore);
3075
3163
  const loadingOlder = useSession((s) => s.loadingOlder);
3076
3164
  const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
3077
3165
  const marks = (0, react.useMemo)(() => {
3078
- const result = [];
3079
- for (const key of order) {
3080
- const node = nodes.get(key);
3081
- if (node === void 0 || node.kind !== "user") continue;
3082
- const data = node.data;
3083
- const turn = node.location.kind === "turn" || node.location.kind === "step" ? node.location.turn.turn : void 0;
3084
- result.push({
3085
- key,
3086
- turn,
3087
- seq: data.seq ?? 0,
3088
- time: data.time ?? 0,
3089
- text: extractText(data.content),
3090
- preview: extractPreview(data.content)
3091
- });
3092
- }
3093
- return result;
3094
- }, [order, nodes]);
3095
- const kindsByTurn = (0, react.useMemo)(() => {
3096
- const result = /* @__PURE__ */ new Map();
3097
- for (const node of nodes.values()) {
3098
- if (node.kind !== "turn-error" && node.kind !== "turn-max-tokens" && node.kind !== "model-retry") continue;
3099
- if (node.kind === "model-retry") {
3100
- if (node.data?.retryState === "cancelled") continue;
3101
- }
3102
- if (node.location.kind !== "turn" && node.location.kind !== "step") continue;
3103
- const kinds = result.get(node.location.turn.turn) ?? [];
3104
- kinds.push(node.kind);
3105
- result.set(node.location.turn.turn, kinds);
3106
- }
3107
- return result;
3108
- }, [order, nodes]);
3166
+ return (projection?.messages ?? EMPTY_PROJECTION_MESSAGES).map((m) => ({
3167
+ key: String(m.seq),
3168
+ turn: m.turn,
3169
+ seq: m.seq,
3170
+ messageId: m.messageId,
3171
+ time: m.time,
3172
+ text: m.text,
3173
+ preview: m.preview
3174
+ }));
3175
+ }, [projection]);
3176
+ const kindsByTurn = EMPTY_KINDS_BY_TURN;
3109
3177
  const running = useSession((s) => s.running);
3110
- const awaitingInput = useSession((s) => s.pending).length > 0;
3178
+ const awaitingInput = useSession((s) => s.queue).length > 0;
3111
3179
  const [railBox, setRailBox] = (0, react.useState)(null);
3112
3180
  const [hover, setHover] = (0, react.useState)(null);
3113
3181
  const [search, setSearch] = (0, react.useState)({
@@ -3118,13 +3186,15 @@ window.__ModuleLoader__.load({
3118
3186
  const [bookmarksOnly, setBookmarksOnly] = (0, react.useState)(false);
3119
3187
  const [focusActive, setFocusActive] = (0, react.useState)(false);
3120
3188
  const [listOpen, setListOpen] = (0, react.useState)(false);
3189
+ const [drainPage, setDrainPage] = (0, react.useState)(false);
3190
+ const [drainFailed, setDrainFailed] = (0, react.useState)(false);
3121
3191
  const [crossOpen, setCrossOpen] = (0, react.useState)(false);
3122
3192
  const [copiedKey, setCopiedKey] = (0, react.useState)(null);
3123
3193
  const [forkedKey, setForkedKey] = (0, react.useState)(null);
3124
3194
  const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
3125
3195
  const [focusIndex, setFocusIndex] = (0, react.useState)(0);
3126
3196
  const listRef = (0, react.useRef)(null);
3127
- const currentKey = useCurrentAnchor(order);
3197
+ const currentKey = useCurrentAnchor(marks.map((m) => m.key));
3128
3198
  /**
3129
3199
  * P3: jump to the chat row with the given node key — smooth-scroll it into
3130
3200
  * view and write the position back into the URL hash (`#msg=<key>`) so
@@ -3134,8 +3204,8 @@ window.__ModuleLoader__.load({
3134
3204
  * rail's own updates. No-op when the row is not (yet) rendered — the
3135
3205
  * deep-link mount retry and the load-older flow cover that case.
3136
3206
  */
3137
- const jump = (key) => {
3138
- const row = findRow(key);
3207
+ const jump = (key, messageId) => {
3208
+ const row = findRow(key, messageId);
3139
3209
  if (row === null) return;
3140
3210
  row.scrollIntoView({
3141
3211
  behavior: "smooth",
@@ -3170,8 +3240,9 @@ window.__ModuleLoader__.load({
3170
3240
  }
3171
3241
  return counts;
3172
3242
  }, [displayMarks]);
3243
+ const displayTurns = (0, react.useMemo)(() => buildDisplayTurns(marks), [marks]);
3173
3244
  const [prefs, setPrefs] = (0, react.useState)(() => loadPrefs());
3174
- const { pinned, accent, iconSize, inset, side } = prefs;
3245
+ const { pinned, accent, iconSize, inset, side, ballMode, ball } = prefs;
3175
3246
  const scale = iconSize / DOT_HIT;
3176
3247
  const hit = iconSize;
3177
3248
  const size = DOT_SIZE * scale;
@@ -3251,6 +3322,27 @@ window.__ModuleLoader__.load({
3251
3322
  /** B-design (0.6.3): the focus block mirrors the personalization block —
3252
3323
  * collapsed by default, the header leads with a live option summary. */
3253
3324
  const [focusOpen, setFocusOpen] = (0, react.useState)(false);
3325
+ const [railCollapsed, setRailCollapsed] = (0, react.useState)(false);
3326
+ const [railCollapseHovered, setRailCollapseHovered] = (0, react.useState)(false);
3327
+ /** Issue #4 settings: the floating-ball block mirrors the personalization /
3328
+ * focus collapsibles — collapsed by default, the header leads with the mode. */
3329
+ const [ballOpen, setBallOpen] = (0, react.useState)(false);
3330
+ /** The live ball position while a press is in flight (`null` = resting). */
3331
+ const [dragPos, setDragPos] = (0, react.useState)(null);
3332
+ const dragRef = (0, react.useRef)(null);
3333
+ /** Detaches the in-flight press's window listeners (set on pointerdown). */
3334
+ const dragListenersRef = (0, react.useRef)(null);
3335
+ (0, react.useEffect)(() => () => {
3336
+ dragListenersRef.current?.();
3337
+ dragListenersRef.current = null;
3338
+ }, []);
3339
+ const [, setBallTick] = (0, react.useState)(0);
3340
+ (0, react.useEffect)(() => {
3341
+ if (!railCollapsed) return;
3342
+ const onResize = () => setBallTick((n) => n + 1);
3343
+ window.addEventListener("resize", onResize);
3344
+ return () => window.removeEventListener("resize", onResize);
3345
+ }, [railCollapsed]);
3254
3346
  const settingsRef = (0, react.useRef)(null);
3255
3347
  const settingsBtnRef = (0, react.useRef)(null);
3256
3348
  const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
@@ -3352,11 +3444,12 @@ window.__ModuleLoader__.load({
3352
3444
  let timer;
3353
3445
  const attempt = (pollsLeft, canLoadOlder) => {
3354
3446
  if (cancelled) return;
3355
- if (findRow(key) !== null) {
3356
- jump(key);
3447
+ const mark = marksRef.current.find((m) => m.key === key);
3448
+ if (findRow(key, mark?.messageId) !== null) {
3449
+ jump(key, mark?.messageId);
3357
3450
  return;
3358
3451
  }
3359
- if (marksRef.current.length > 0 && !marksRef.current.some((m) => m.key === key)) return;
3452
+ if (marksRef.current.length > 0 && mark === void 0) return;
3360
3453
  if (pollsLeft > 0) {
3361
3454
  timer = window.setTimeout(() => attempt(pollsLeft - 1, canLoadOlder), DEEP_LINK_POLL_DELAY);
3362
3455
  return;
@@ -3378,7 +3471,8 @@ window.__ModuleLoader__.load({
3378
3471
  const onHashChange = () => {
3379
3472
  const key = parseDeepLinkHash(window.location.hash);
3380
3473
  if (key === null) return;
3381
- if (marksRef.current.some((m) => m.key === key)) jump(key);
3474
+ const mark = marksRef.current.find((m) => m.key === key);
3475
+ if (mark !== void 0) jump(key, mark.messageId);
3382
3476
  };
3383
3477
  window.addEventListener("hashchange", onHashChange);
3384
3478
  return () => window.removeEventListener("hashchange", onHashChange);
@@ -3441,6 +3535,22 @@ window.__ModuleLoader__.load({
3441
3535
  window.addEventListener("keydown", onKey);
3442
3536
  return () => window.removeEventListener("keydown", onKey);
3443
3537
  }, [listOpen, crossOpen]);
3538
+ const listDraining = listOpen && (drainPage || hasMore && !drainFailed);
3539
+ (0, react.useEffect)(() => {
3540
+ if (!listOpen) return;
3541
+ setDrainFailed(false);
3542
+ }, [listOpen]);
3543
+ (0, react.useEffect)(() => {
3544
+ if (!listOpen || !hasMore || drainPage || drainFailed) return;
3545
+ setDrainPage(true);
3546
+ loadOlder().catch(() => setDrainFailed(true)).finally(() => setDrainPage(false));
3547
+ }, [
3548
+ listOpen,
3549
+ hasMore,
3550
+ drainPage,
3551
+ drainFailed,
3552
+ loadOlder
3553
+ ]);
3444
3554
  if (railBox === null || marks.length < MIN_MARKS) return null;
3445
3555
  const updateQuery = (query) => {
3446
3556
  setSearch({
@@ -3471,7 +3581,8 @@ window.__ModuleLoader__.load({
3471
3581
  ...s,
3472
3582
  activePos: next
3473
3583
  }));
3474
- jump(displayMarks[matches[next]].key);
3584
+ const mark = displayMarks[matches[next]];
3585
+ jump(mark.key, mark.messageId);
3475
3586
  };
3476
3587
  const onSearchKeyDown = (e) => {
3477
3588
  if (e.key === "Enter") advanceMatch();
@@ -3801,29 +3912,35 @@ window.__ModuleLoader__.load({
3801
3912
  const buildHover = (mark, index) => {
3802
3913
  if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
3803
3914
  if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
3804
- const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
3915
+ const turnMeta = mark.turn !== void 0 ? projection?.turns.find((turn) => turn.turn === mark.turn) : void 0;
3805
3916
  let durationLabel = null;
3806
3917
  let reasonLabel = null;
3807
3918
  let ttftLabel = null;
3808
3919
  let tpsLabel = null;
3809
- if (turn !== void 0) {
3810
- if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
3811
- if (turn.end !== void 0) {
3812
- const reason = turn.end.data.reason;
3813
- if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
3814
- }
3815
- const tail = turnTailOf(turn);
3816
- if (tail !== void 0) {
3817
- if (tail.ttftMs !== void 0) ttftLabel = formatDuration(tail.ttftMs);
3818
- if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
3920
+ if (turnMeta !== void 0) {
3921
+ if (turnMeta.startTime !== void 0 && turnMeta.endTime !== void 0) durationLabel = formatDuration(turnMeta.endTime - turnMeta.startTime);
3922
+ if (turnMeta.endReason !== void 0) reasonLabel = t(reasonKeyOf(turnMeta.endReason));
3923
+ if (turnMeta.firstChunkTime !== void 0 && turnMeta.startTime !== void 0) ttftLabel = formatDuration(turnMeta.firstChunkTime - turnMeta.startTime);
3924
+ if (turnMeta.usage !== void 0 && durationLabel !== null) {
3925
+ const seconds = (turnMeta.endTime - turnMeta.startTime) / 1e3;
3926
+ if (seconds > 0) tpsLabel = `${(turnMeta.usage.output / seconds).toFixed(1)} tok/s`;
3819
3927
  }
3820
3928
  }
3821
- const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
3929
+ const meta = deriveTurnMetaFromProjection(turnMeta);
3930
+ const summaryCount = collapsedSummaries.get(mark.key);
3822
3931
  return {
3823
3932
  mark,
3824
3933
  index,
3825
3934
  total: displayMarks.length,
3826
- turnLabel: mark.turn !== void 0 ? t("turn.label", { n: mark.turn }) : null,
3935
+ posLabel: summaryCount !== void 0 ? t("pos.range", {
3936
+ a: index - summaryCount + 2,
3937
+ b: index + 1,
3938
+ m: displayMarks.length
3939
+ }) : t("pos.of", {
3940
+ n: index + 1,
3941
+ m: displayMarks.length
3942
+ }),
3943
+ turnLabel: mark.turn !== void 0 ? t("turn.label", { n: displayTurns.get(mark.turn) ?? mark.turn }) : null,
3827
3944
  durationLabel,
3828
3945
  reasonLabel,
3829
3946
  ttftLabel,
@@ -3874,6 +3991,157 @@ window.__ModuleLoader__.load({
3874
3991
  forkAt(mark.seq).then(() => setForkedKey(mark.key));
3875
3992
  };
3876
3993
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
3994
+ if (railCollapsed) {
3995
+ const viewport = {
3996
+ width: window.innerWidth,
3997
+ height: window.innerHeight
3998
+ };
3999
+ const resting = clampBallPosition(ball ?? defaultBallPosition(viewport, side, inset), viewport);
4000
+ const pos = dragPos ?? resting;
4001
+ /** Detach the in-flight press's window listeners (idempotent). */
4002
+ const removeDragListeners = () => {
4003
+ const detach = dragListenersRef.current;
4004
+ if (detach === null) return;
4005
+ dragListenersRef.current = null;
4006
+ detach();
4007
+ };
4008
+ /** Release the pointer capture when the DOM implementation supports it. */
4009
+ const releaseBallCapture = (el, pointerId) => {
4010
+ if (typeof el.releasePointerCapture !== "function") return;
4011
+ try {
4012
+ el.releasePointerCapture(pointerId);
4013
+ } catch {}
4014
+ };
4015
+ /**
4016
+ * Press start: in draggable mode record the gesture anchor and arm the
4017
+ * window pointer listeners; in fixed mode do nothing drag-related — the
4018
+ * plain onClick fallback below still expands the rail.
4019
+ */
4020
+ const onBallPointerDown = (e) => {
4021
+ if (ballMode !== "draggable") return;
4022
+ const el = e.currentTarget;
4023
+ if (typeof el.setPointerCapture === "function") try {
4024
+ el.setPointerCapture(e.pointerId);
4025
+ } catch {}
4026
+ if (dragRef.current !== null) removeDragListeners();
4027
+ dragRef.current = {
4028
+ pointerId: e.pointerId,
4029
+ startPointer: {
4030
+ x: e.clientX,
4031
+ y: e.clientY
4032
+ },
4033
+ startPos: pos,
4034
+ moved: false
4035
+ };
4036
+ const onMove = (ev) => {
4037
+ const drag = dragRef.current;
4038
+ if (drag === null) return;
4039
+ const live = {
4040
+ width: window.innerWidth,
4041
+ height: window.innerHeight
4042
+ };
4043
+ setDragPos(clampBallPosition({
4044
+ x: drag.startPos.x + (ev.clientX - drag.startPointer.x),
4045
+ y: drag.startPos.y + (ev.clientY - drag.startPointer.y)
4046
+ }, live));
4047
+ drag.moved = drag.moved || isDragGesture(drag.startPointer, {
4048
+ x: ev.clientX,
4049
+ y: ev.clientY
4050
+ });
4051
+ };
4052
+ const onUp = (ev) => {
4053
+ const drag = dragRef.current;
4054
+ if (drag === null) return;
4055
+ removeDragListeners();
4056
+ releaseBallCapture(el, drag.pointerId);
4057
+ if (drag.moved) {
4058
+ const live = {
4059
+ width: window.innerWidth,
4060
+ height: window.innerHeight
4061
+ };
4062
+ updatePrefs({ ball: clampBallPosition({
4063
+ x: drag.startPos.x + (ev.clientX - drag.startPointer.x),
4064
+ y: drag.startPos.y + (ev.clientY - drag.startPointer.y)
4065
+ }, live) });
4066
+ } else setRailCollapsed(false);
4067
+ setDragPos(null);
4068
+ dragRef.current = null;
4069
+ };
4070
+ const onCancel = () => {
4071
+ const drag = dragRef.current;
4072
+ if (drag === null) return;
4073
+ removeDragListeners();
4074
+ releaseBallCapture(el, drag.pointerId);
4075
+ setDragPos(null);
4076
+ dragRef.current = null;
4077
+ };
4078
+ window.addEventListener("pointermove", onMove);
4079
+ window.addEventListener("pointerup", onUp);
4080
+ window.addEventListener("pointercancel", onCancel);
4081
+ dragListenersRef.current = () => {
4082
+ window.removeEventListener("pointermove", onMove);
4083
+ window.removeEventListener("pointerup", onUp);
4084
+ window.removeEventListener("pointercancel", onCancel);
4085
+ };
4086
+ };
4087
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4088
+ "data-milestone-ball": true,
4089
+ role: "button",
4090
+ tabIndex: 0,
4091
+ "aria-label": t("ball.expand"),
4092
+ title: t("ball.expand"),
4093
+ "data-ball-x": String(Math.round(pos.x)),
4094
+ "data-ball-y": String(Math.round(pos.y)),
4095
+ onPointerDown: onBallPointerDown,
4096
+ onClick: ballMode === "fixed" ? () => setRailCollapsed(false) : void 0,
4097
+ onKeyDown: (e) => {
4098
+ if (e.key !== "Enter" && e.key !== " ") return;
4099
+ e.preventDefault();
4100
+ setRailCollapsed(false);
4101
+ },
4102
+ style: {
4103
+ position: "fixed",
4104
+ left: pos.x,
4105
+ top: pos.y,
4106
+ width: 40,
4107
+ height: 40,
4108
+ borderRadius: "50%",
4109
+ background: accent,
4110
+ color: "#ffffff",
4111
+ opacity: .9,
4112
+ zIndex: 100,
4113
+ display: "flex",
4114
+ alignItems: "center",
4115
+ justifyContent: "center",
4116
+ touchAction: "none",
4117
+ userSelect: "none",
4118
+ cursor: ballMode === "draggable" ? "grab" : "pointer",
4119
+ boxShadow: "0 4px 14px rgba(0, 0, 0, 0.35)"
4120
+ },
4121
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
4122
+ width: "18",
4123
+ height: "18",
4124
+ viewBox: "0 0 24 24",
4125
+ fill: "none",
4126
+ stroke: "currentColor",
4127
+ strokeWidth: "2",
4128
+ strokeLinecap: "round",
4129
+ strokeLinejoin: "round",
4130
+ "aria-hidden": "true",
4131
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4132
+ cx: "12",
4133
+ cy: "12",
4134
+ r: "8"
4135
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4136
+ cx: "12",
4137
+ cy: "12",
4138
+ r: "2.5",
4139
+ fill: "currentColor",
4140
+ stroke: "none"
4141
+ })]
4142
+ })
4143
+ });
4144
+ }
3877
4145
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3878
4146
  style: {
3879
4147
  position: "fixed",
@@ -3931,6 +4199,40 @@ window.__ModuleLoader__.load({
3931
4199
  },
3932
4200
  children: "···"
3933
4201
  }),
4202
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4203
+ type: "button",
4204
+ "data-rail-collapse": true,
4205
+ "aria-label": t("rail.collapse"),
4206
+ title: t("rail.collapse"),
4207
+ onClick: () => setRailCollapsed(true),
4208
+ onMouseEnter: () => setRailCollapseHovered(true),
4209
+ onMouseLeave: () => setRailCollapseHovered(false),
4210
+ onFocus: () => setRailCollapseHovered(true),
4211
+ onBlur: () => setRailCollapseHovered(false),
4212
+ style: chromeButtonStyle(railCollapseHovered),
4213
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
4214
+ width: "16",
4215
+ height: "16",
4216
+ viewBox: "0 0 24 24",
4217
+ fill: "none",
4218
+ stroke: "currentColor",
4219
+ strokeWidth: "2",
4220
+ strokeLinecap: "round",
4221
+ strokeLinejoin: "round",
4222
+ "aria-hidden": "true",
4223
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4224
+ cx: "12",
4225
+ cy: "12",
4226
+ r: "8"
4227
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4228
+ cx: "12",
4229
+ cy: "12",
4230
+ r: "2.5",
4231
+ fill: "currentColor",
4232
+ stroke: "none"
4233
+ })]
4234
+ })
4235
+ }),
3934
4236
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3935
4237
  type: "button",
3936
4238
  "data-toolbar-expand": true,
@@ -4440,6 +4742,139 @@ window.__ModuleLoader__.load({
4440
4742
  ]
4441
4743
  })]
4442
4744
  }),
4745
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4746
+ "data-settings-section": true,
4747
+ "data-settings-ball": true,
4748
+ style: { marginBottom: 20 },
4749
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4750
+ type: "button",
4751
+ "data-ball-toggle": true,
4752
+ "aria-expanded": ballOpen,
4753
+ "aria-label": t("settings.section.ball"),
4754
+ title: t("settings.section.ball"),
4755
+ onClick: () => setBallOpen((v) => !v),
4756
+ style: SECTION_TOGGLE_STYLE,
4757
+ children: [
4758
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4759
+ width: "14",
4760
+ height: "14",
4761
+ viewBox: "0 0 24 24",
4762
+ fill: "none",
4763
+ stroke: "currentColor",
4764
+ strokeWidth: "2.5",
4765
+ strokeLinecap: "round",
4766
+ strokeLinejoin: "round",
4767
+ "aria-hidden": "true",
4768
+ style: {
4769
+ flexShrink: 0,
4770
+ transform: ballOpen ? "rotate(90deg)" : "none"
4771
+ },
4772
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m9 18 6-6-6-6" })
4773
+ }),
4774
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4775
+ "data-settings-section-title": true,
4776
+ style: { flexShrink: 0 },
4777
+ children: t("settings.section.ball")
4778
+ }),
4779
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4780
+ "data-ball-summary": true,
4781
+ style: SECTION_SUMMARY_STYLE,
4782
+ children: ballMode === "fixed" ? t("settings.ball.mode.fixed") : t("settings.ball.mode.draggable")
4783
+ })
4784
+ ]
4785
+ }), ballOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4786
+ "data-settings-ball-body": true,
4787
+ style: {
4788
+ padding: "10px 4px 8px",
4789
+ display: "flex",
4790
+ flexDirection: "column",
4791
+ gap: 12
4792
+ },
4793
+ children: [
4794
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4795
+ "data-settings-ball-hint": true,
4796
+ style: {
4797
+ fontSize: 12,
4798
+ color: "#8b96ab",
4799
+ lineHeight: 1.5,
4800
+ padding: "0 6px"
4801
+ },
4802
+ children: t("settings.ball.hint")
4803
+ }),
4804
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4805
+ role: "radiogroup",
4806
+ "aria-label": t("settings.ball.mode"),
4807
+ style: {
4808
+ display: "flex",
4809
+ alignItems: "center",
4810
+ gap: 10,
4811
+ flexWrap: "wrap"
4812
+ },
4813
+ children: [
4814
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4815
+ style: {
4816
+ fontSize: 12.5,
4817
+ color: "#8b96ab",
4818
+ width: 90,
4819
+ flexShrink: 0
4820
+ },
4821
+ children: t("settings.ball.mode")
4822
+ }),
4823
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4824
+ style: {
4825
+ display: "inline-flex",
4826
+ alignItems: "center",
4827
+ gap: 5,
4828
+ fontSize: 13,
4829
+ color: "#e6e8ee",
4830
+ cursor: "pointer"
4831
+ },
4832
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4833
+ type: "radio",
4834
+ name: "ms-ball-mode",
4835
+ "data-ball-mode-radio": true,
4836
+ value: "fixed",
4837
+ checked: ballMode === "fixed",
4838
+ onChange: () => updatePrefs({ ballMode: "fixed" })
4839
+ }), t("settings.ball.mode.fixed")]
4840
+ }),
4841
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4842
+ style: {
4843
+ display: "inline-flex",
4844
+ alignItems: "center",
4845
+ gap: 5,
4846
+ fontSize: 13,
4847
+ color: "#e6e8ee",
4848
+ cursor: "pointer"
4849
+ },
4850
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4851
+ type: "radio",
4852
+ name: "ms-ball-mode",
4853
+ "data-ball-mode-radio": true,
4854
+ value: "draggable",
4855
+ checked: ballMode === "draggable",
4856
+ onChange: () => updatePrefs({ ballMode: "draggable" })
4857
+ }), t("settings.ball.mode.draggable")]
4858
+ })
4859
+ ]
4860
+ }),
4861
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4862
+ type: "button",
4863
+ "data-ball-reset": true,
4864
+ onClick: () => updatePrefs({ ball: null }),
4865
+ style: {
4866
+ padding: "7px 16px",
4867
+ border: `1px solid rgba(255, 255, 255, 0.14)`,
4868
+ borderRadius: 8,
4869
+ cursor: "pointer",
4870
+ color: "#b9c2d4",
4871
+ fontSize: 12.5
4872
+ },
4873
+ children: t("settings.ball.reset")
4874
+ }) })
4875
+ ]
4876
+ })]
4877
+ }),
4443
4878
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4444
4879
  "data-settings-section": true,
4445
4880
  "data-focus-settings": true,
@@ -4838,7 +5273,7 @@ window.__ModuleLoader__.load({
4838
5273
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4839
5274
  style: { color: "#8b96ab" },
4840
5275
  children: [t("update.current"), ": "]
4841
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.5" })] }),
5276
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.7.0" })] }),
4842
5277
  updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4843
5278
  "data-update-latest": true,
4844
5279
  children: [
@@ -4943,6 +5378,8 @@ window.__ModuleLoader__.load({
4943
5378
  panelRight: panelRightFor(PANEL_WIDTH_STANDARD),
4944
5379
  marks,
4945
5380
  onJump: jump,
5381
+ onClose: () => setListOpen(false),
5382
+ loading: listDraining,
4946
5383
  t
4947
5384
  }),
4948
5385
  crossOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneSessionSearch, {
@@ -5014,7 +5451,7 @@ window.__ModuleLoader__.load({
5014
5451
  top: rect.top + rect.height / 2
5015
5452
  });
5016
5453
  },
5017
- onClick: () => jump(mark.key),
5454
+ onClick: () => jump(mark.key, mark.messageId),
5018
5455
  "data-rail-dot": true,
5019
5456
  "data-turn-gap": showGroupGap ? "true" : void 0,
5020
5457
  "data-turn": showGroupGap && mark.turn !== void 0 ? mark.turn : void 0,
@@ -5026,7 +5463,7 @@ window.__ModuleLoader__.load({
5026
5463
  "aria-current": dotState === "active" ? "true" : void 0,
5027
5464
  "data-current": dotState === "current" ? "true" : void 0,
5028
5465
  "data-dimmed": dotState === "dimmed" ? "true" : void 0,
5029
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5466
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5030
5467
  style: {
5031
5468
  position: "relative",
5032
5469
  width: size,
@@ -5039,7 +5476,7 @@ window.__ModuleLoader__.load({
5039
5476
  opacity: isHovered || dotState !== "dimmed" ? 1 : .22
5040
5477
  },
5041
5478
  "data-bookmarked": bookmarked ? "true" : void 0,
5042
- children: ringStyle !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5479
+ children: [ringStyle !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
5043
5480
  "data-badge": badge,
5044
5481
  style: {
5045
5482
  position: "absolute",
@@ -5050,7 +5487,31 @@ window.__ModuleLoader__.load({
5050
5487
  pointerEvents: "none",
5051
5488
  animation: ringStyle.pulse ? "milestone-badge-pulse 2s ease-in-out infinite" : void 0
5052
5489
  }
5053
- })
5490
+ }), summaryCount !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
5491
+ "data-collapsed-badge": true,
5492
+ style: {
5493
+ position: "absolute",
5494
+ top: -5,
5495
+ right: -7,
5496
+ minWidth: 15,
5497
+ height: 15,
5498
+ padding: "0 3px",
5499
+ display: "flex",
5500
+ alignItems: "center",
5501
+ justifyContent: "center",
5502
+ borderRadius: 8,
5503
+ background: "rgba(20, 24, 32, 0.96)",
5504
+ border: `1px solid ${accentSoft}`,
5505
+ boxSizing: "border-box",
5506
+ color: "#e6e8ee",
5507
+ fontSize: 9,
5508
+ lineHeight: 1,
5509
+ fontWeight: 600,
5510
+ whiteSpace: "nowrap",
5511
+ pointerEvents: "none"
5512
+ },
5513
+ children: ["×", summaryCount]
5514
+ })]
5054
5515
  })
5055
5516
  }) }, mark.key);
5056
5517
  })
@@ -5105,13 +5566,18 @@ window.__ModuleLoader__.load({
5105
5566
  * resolved by the engine's `create(scopeKey)`). Consumers must call the
5106
5567
  * FACTORY (never a module-level handle — module-cache identity is a disguised
5107
5568
  * singleton across plugin reloads).
5569
+ *
5570
+ * 0.1.2 compat: the snapshot-store engine moved out of
5571
+ * `@deepseek-ai/dsh-client-runtime/client` into the platform module
5572
+ * `@deepseek-ai/dsh-client-store` (a web module-table seed); the old
5573
+ * specifier is gone from the 0.1.2 module table.
5108
5574
  */
5109
5575
  /**
5110
5576
  * Declare the bookmarks store handle. Returns a fresh handle per call; the
5111
5577
  * framework (or tests) create per-session instances via `create(scopeKey)`.
5112
5578
  */
5113
5579
  function createBookmarksStore() {
5114
- return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
5580
+ return (0, _deepseek_ai_dsh_client_store.defineStore)({
5115
5581
  init: () => ({ keys: [] }),
5116
5582
  persist: "dsh-milestone.bookmarks",
5117
5583
  actions: {
@@ -5150,7 +5616,7 @@ window.__ModuleLoader__.load({
5150
5616
  return {
5151
5617
  items: result.value.items.map((item) => ({
5152
5618
  ...item,
5153
- title: byId[item.sessionId]?.displayTitle
5619
+ title: byId[item.sessionId]?.title
5154
5620
  })),
5155
5621
  hasMore: result.value.hasMore
5156
5622
  };
@@ -5219,6 +5685,7 @@ window.__ModuleLoader__.load({
5219
5685
  * @param ctx - client root context.
5220
5686
  */
5221
5687
  function apply(ctx) {
5688
+ const sessions = ctx.sessions;
5222
5689
  ctx.effect(() => ctx.locale.register("dsh-milestone", {
5223
5690
  zh,
5224
5691
  en
@@ -5237,10 +5704,10 @@ window.__ModuleLoader__.load({
5237
5704
  store: createBookmarksStore,
5238
5705
  locale: "dsh-milestone",
5239
5706
  inject: (sessionId) => ({
5240
- loadOlder: createLoadOlder(ctx.sessions, sessionId),
5241
- forkAt: createForkAt(ctx.sessions, sessionId),
5242
- searchSessions: createSessionSearch(ctx.sessions),
5243
- openSession: createOpenSession(ctx.sessions)
5707
+ loadOlder: createLoadOlder(sessions, sessionId),
5708
+ forkAt: createForkAt(sessions, sessionId),
5709
+ searchSessions: createSessionSearch(sessions),
5710
+ openSession: createOpenSession(sessions)
5244
5711
  })
5245
5712
  }, MilestoneRail));
5246
5713
  }