dsh-milestone 0.6.6 → 0.7.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.
Files changed (4) hide show
  1. package/README.md +192 -165
  2. package/lib/client.js +657 -179
  3. package/lib/index.js +250 -6
  4. package/package.json +29 -9
package/lib/client.js CHANGED
@@ -4,9 +4,31 @@ window.__ModuleLoader__.load({
4
4
  var module = { exports: {} };
5
5
  var exports = module.exports;
6
6
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
- let react_jsx_runtime = require("react/jsx-runtime");
8
7
  let react = require("react");
9
- let _deepseek_ai_dsh_client_runtime_client = require("@deepseek-ai/dsh-client-runtime/client");
8
+ let react_jsx_runtime = require("react/jsx-runtime");
9
+ let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
10
+ //#region src/client/modal-tokens.ts
11
+ /**
12
+ * modal-tokens: the ONE source for the settings modal's dark-panel palette and
13
+ * radius scale, shared with the 0.6.5 coach-tour bubble so the two surfaces
14
+ * cannot drift (same dark panel on a dark host, same three text tiers, same
15
+ * 12px panel / 8px control radius, same border tone).
16
+ *
17
+ * The static MODAL_CSS block (hover/focus-visible/reduced-motion rules) stays
18
+ * in MilestoneRail.tsx because it also styles the settings modal's own chrome
19
+ * (`[data-support-card]`, `[data-toolbar-*]`, ...); the tour bubble carries
20
+ * its own small TOUR_CSS block for its controls, built from these same tokens.
21
+ * MilestoneRail.tsx imports these constants verbatim so a future palette
22
+ * change edits exactly one file.
23
+ */
24
+ const MODAL_BG = "rgba(20, 24, 32, 0.98)";
25
+ const MODAL_FG = "#e6e8ee";
26
+ const MODAL_TITLE = "#c7cede";
27
+ const MODAL_TEXT = "#b9c2d4";
28
+ const MODAL_HINT = "#8b96ab";
29
+ const MODAL_BORDER = "rgba(255, 255, 255, 0.14)";
30
+ const MODAL_TIP_BG = "#222834";
31
+ //#endregion
10
32
  //#region src/client/MilestoneOverlay.tsx
11
33
  /**
12
34
  * @param props - runtime share (root kit) + the narrowed renderSlot and the
@@ -15,7 +37,7 @@ window.__ModuleLoader__.load({
15
37
  function MilestoneOverlay({ SessionProvider, renderSlot }) {
16
38
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SessionProvider, {
17
39
  empty: () => null,
18
- children: () => renderSlot("milestone.rail", {})
40
+ children: renderSlot("milestone.rail", {})
19
41
  });
20
42
  }
21
43
  //#endregion
@@ -129,6 +151,23 @@ window.__ModuleLoader__.load({
129
151
  * @param input - the mark's snapshot signals.
130
152
  * @returns the winning badge kind, or null when no signal applies.
131
153
  */
154
+ /**
155
+ * 0.1.5: durable badge kinds for one projected turn. The fold no longer sees
156
+ * the chat-layer `turn-error` / `turn-max-tokens` node kinds, but `turn/end`'s
157
+ * reason carries the same signal, and every `assistant/attempt` settlement
158
+ * (failed / retried / cancelled / stream error) is a retry.
159
+ * @param turn - the projection's turn meta (only the two fields are read).
160
+ * @returns the node-kind vocabulary {@link deriveBadge} consumes; empty when normal.
161
+ */
162
+ function kindsForTurn(turn) {
163
+ const kinds = [];
164
+ if (turn.endReason === "error" || turn.endReason === "interrupted") kinds.push("turn-error");
165
+ if (turn.endReason === "max-tokens") kinds.push("turn-max-tokens");
166
+ if ((turn.attempts ?? 0) > 0) kinds.push("model-retry");
167
+ return kinds.length === 0 ? EMPTY_KINDS : kinds;
168
+ }
169
+ /** Stable empty result so normal turns never allocate a fresh array. */
170
+ const EMPTY_KINDS = Object.freeze([]);
132
171
  function deriveBadge(input) {
133
172
  if (input.nodeKinds.includes("turn-error")) return "error";
134
173
  if (input.nodeKinds.includes("turn-max-tokens")) return "max-tokens";
@@ -198,6 +237,82 @@ window.__ModuleLoader__.load({
198
237
  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
238
  }`;
200
239
  }
240
+ /**
241
+ * Clamp a ball's top-left so the WHOLE circle stays inside the viewport.
242
+ *
243
+ * Per axis: `max = Math.max(0, viewportSize - ballSize - margin)` and
244
+ * `min = Math.min(margin, max)`. When there IS room, `min` is the margin, so
245
+ * the ball keeps its preferred distance from the edge; when the viewport is
246
+ * smaller than ball + margins, `max` collapses to 0 (or below) and `min`
247
+ * follows it, pinning the ball to the top/left outer edge instead of letting
248
+ * it escape the viewport.
249
+ *
250
+ * Non-finite `pos` values degrade to `0` BEFORE clamping, so a corrupt call
251
+ * still lands on a finite, fully-visible position.
252
+ */
253
+ function clampBallPosition(pos, viewport, ballSize = 40, margin = 8) {
254
+ const xRaw = Number.isFinite(pos.x) ? pos.x : 0;
255
+ const yRaw = Number.isFinite(pos.y) ? pos.y : 0;
256
+ const maxX = Math.max(0, viewport.width - ballSize - margin);
257
+ const minX = Math.min(margin, maxX);
258
+ const maxY = Math.max(0, viewport.height - ballSize - margin);
259
+ return {
260
+ x: Math.min(maxX, Math.max(minX, xRaw)),
261
+ y: Math.min(maxY, Math.max(Math.min(margin, maxY), yRaw))
262
+ };
263
+ }
264
+ /**
265
+ * True when pointer travel from `start` to `current` exceeds the drag
266
+ * threshold — the gesture vocabulary of the ball: a press that stays put (or
267
+ * wiggles less than the threshold) is a CLICK (toggle the rail), a press that
268
+ * travels farther is a DRAG (move the ball).
269
+ *
270
+ * Distance is Euclidean, so a diagonal move counts the same as a straight one.
271
+ * EXACTLY at the threshold is still a click (strictly greater than).
272
+ */
273
+ function isDragGesture(start, current, threshold = 5) {
274
+ const dx = current.x - start.x;
275
+ const dy = current.y - start.y;
276
+ return Math.hypot(dx, dy) > threshold;
277
+ }
278
+ /**
279
+ * Sanitize a persisted ball position; return null when unusable.
280
+ *
281
+ * Accepts only a plain object carrying FINITE numeric `x` and `y` (extra keys
282
+ * are ignored, so a future blob extension stays readable). Everything else —
283
+ * `null`, arrays, strings, numbers, NaN/Infinity, missing keys — degrades to
284
+ * `null`, which callers read as "no stored position, use the computed default
285
+ * resting spot" (see {@link defaultBallPosition}).
286
+ *
287
+ * No clamping happens here: the stored value is resolution-agnostic and gets
288
+ * clamped against the live viewport by {@link clampBallPosition} at render
289
+ * time.
290
+ */
291
+ function sanitizeBallPosition(raw) {
292
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
293
+ const { x, y } = raw;
294
+ if (typeof x !== "number" || !Number.isFinite(x)) return null;
295
+ if (typeof y !== "number" || !Number.isFinite(y)) return null;
296
+ return {
297
+ x,
298
+ y
299
+ };
300
+ }
301
+ /**
302
+ * Default resting spot: hugging `side` at `inset` from the edge, vertically
303
+ * centered. `inset` is measured from the nearest screen edge; the vertical
304
+ * axis is simply centered on the viewport.
305
+ *
306
+ * The raw spot is always passed through {@link clampBallPosition} with the
307
+ * default margin, so the resting spot is fully visible even when `inset`
308
+ * overflows or the viewport is smaller than the ball.
309
+ */
310
+ function defaultBallPosition(viewport, side, inset, ballSize = 40) {
311
+ return clampBallPosition({
312
+ x: side === "left" ? inset : viewport.width - ballSize - inset,
313
+ y: (viewport.height - ballSize) / 2
314
+ }, viewport, ballSize, 8);
315
+ }
201
316
  //#endregion
202
317
  //#region src/client/bookmark-logic.ts
203
318
  /**
@@ -385,65 +500,21 @@ window.__ModuleLoader__.load({
385
500
  inputTokens: null,
386
501
  outputTokens: null
387
502
  };
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
- /**
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.
398
- */
399
- function decodeUsage(usage) {
400
- if (!isRecord(usage)) return {
401
- inputTokens: null,
402
- outputTokens: null
403
- };
404
- return {
405
- inputTokens: typeof usage.inputTokens === "number" ? usage.inputTokens : null,
406
- outputTokens: typeof usage.outputTokens === "number" ? usage.outputTokens : null
407
- };
408
- }
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
503
  /**
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.
504
+ * 0.1.2: derive hover metadata from the `milestone.messages` projection's
505
+ * per-turn fold state. The fold carries token usage and timing; provider /
506
+ * model provenance is not folded yet, so those degrade to null.
507
+ * @param turn - the projection's turn meta, or undefined when absent.
429
508
  * @returns the turn's metadata, null where unknown.
430
509
  */
431
- function deriveTurnMeta(nodes, locations, turn, trajectoryRequests) {
510
+ function deriveTurnMetaFromProjection(turn) {
432
511
  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;
512
+ return {
513
+ model: turn.model ?? null,
514
+ purpose: null,
515
+ inputTokens: turn.usage?.input ?? null,
516
+ outputTokens: turn.usage?.output ?? null
517
+ };
447
518
  }
448
519
  //#endregion
449
520
  //#region src/client/rail-keyboard.ts
@@ -481,22 +552,6 @@ window.__ModuleLoader__.load({
481
552
  * can consume them directly and tests can exercise them in isolation.
482
553
  */
483
554
  /**
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
555
  * Case-insensitive substring filter over mark texts.
501
556
  * @param marks - marks in rail order.
502
557
  * @param query - the search query; empty/whitespace matches everything.
@@ -621,6 +676,10 @@ window.__ModuleLoader__.load({
621
676
  "rail.label": "会话里程碑",
622
677
  /** aria-label on the dot list. */
623
678
  "rail.list": "会话里程碑列表",
679
+ /** aria-label on the rail-collapse control (issue #4: fold the rail into the floating ball). */
680
+ "rail.collapse": "收起为悬浮球",
681
+ /** aria-label + title on the collapsed rail's floating ball (click = expand). */
682
+ "ball.expand": "展开里程碑条",
624
683
  /** Hover preview fallback for empty message text. */
625
684
  "no.text": "(无文本)",
626
685
  /** Relative time: `< 60s`. */
@@ -711,6 +770,8 @@ window.__ModuleLoader__.load({
711
770
  "settings.section.personal": "个性化",
712
771
  /** Settings modal: section heading for the focus-mode controls (0.6.3). */
713
772
  "settings.section.focus": "聚焦",
773
+ /** Settings modal: section heading for the floating-ball controls (issue #4). */
774
+ "settings.section.ball": "悬浮球",
714
775
  /** Settings: personalization-section hint shown inside the expanded block. */
715
776
  "settings.personal.hint": "圆点、强调色与位置,即调即存",
716
777
  /** Settings: aria-label on the personalization block toggle while COLLAPSED. */
@@ -747,6 +808,16 @@ window.__ModuleLoader__.load({
747
808
  "settings.side.left": "左侧",
748
809
  /** Settings personalization: side radio — hug the right edge. */
749
810
  "settings.side.right": "右侧",
811
+ /** Settings: floating-ball block — hint shown inside the expanded block. */
812
+ "settings.ball.hint": "收起后变为悬浮球;可固定,也可自由拖动到任意位置",
813
+ /** Settings: floating-ball behavior row label + radiogroup aria-label. */
814
+ "settings.ball.mode": "行为",
815
+ /** Settings: floating-ball behavior radio — pinned to the resting spot. */
816
+ "settings.ball.mode.fixed": "固定",
817
+ /** Settings: floating-ball behavior radio — free drag anywhere. */
818
+ "settings.ball.mode.draggable": "可拖动",
819
+ /** Settings: floating-ball action — drop the persisted drag position. */
820
+ "settings.ball.reset": "重置位置",
750
821
  /** Settings: focus block — hint shown inside the expanded block. */
751
822
  "settings.focus.hint": "这些选项自由组合成你的「聚焦搭配」;总开关仍是工具栏的眼睛按钮",
752
823
  /** Settings: aria-label on the focus block toggle while COLLAPSED. */
@@ -853,6 +924,8 @@ window.__ModuleLoader__.load({
853
924
  "load.older": "Load older messages",
854
925
  "rail.label": "Session milestones",
855
926
  "rail.list": "Session milestone list",
927
+ "rail.collapse": "Collapse to floating ball",
928
+ "ball.expand": "Expand milestone rail",
856
929
  "no.text": "(no text)",
857
930
  "time.justNow": "Just now",
858
931
  "time.minutes": "{n} minutes ago",
@@ -899,6 +972,7 @@ window.__ModuleLoader__.load({
899
972
  "settings.section.features": "Features & Shortcuts",
900
973
  "settings.section.personal": "Personalization",
901
974
  "settings.section.focus": "Focus",
975
+ "settings.section.ball": "Floating ball",
902
976
  "settings.personal.hint": "Dot size, accent color, and position — saved as you adjust",
903
977
  "settings.personal.expand": "Expand personalization",
904
978
  "settings.personal.collapse": "Collapse personalization",
@@ -917,6 +991,11 @@ window.__ModuleLoader__.load({
917
991
  "settings.side": "Position",
918
992
  "settings.side.left": "Left",
919
993
  "settings.side.right": "Right",
994
+ "settings.ball.hint": "Collapses into a floating ball — pin it, or drag it anywhere",
995
+ "settings.ball.mode": "Behavior",
996
+ "settings.ball.mode.fixed": "Fixed",
997
+ "settings.ball.mode.draggable": "Draggable",
998
+ "settings.ball.reset": "Reset position",
920
999
  "settings.focus.hint": "Combine these options into your own focus recipe; the eye button on the toolbar stays the master switch",
921
1000
  "settings.focus.expand": "Expand focus settings",
922
1001
  "settings.focus.collapse": "Collapse focus settings",
@@ -1423,12 +1502,6 @@ window.__ModuleLoader__.load({
1423
1502
  ]
1424
1503
  });
1425
1504
  }
1426
- const MODAL_FG = "#e6e8ee";
1427
- const MODAL_TITLE = "#c7cede";
1428
- const MODAL_TEXT = "#b9c2d4";
1429
- const MODAL_HINT = "#8b96ab";
1430
- const MODAL_BORDER = "rgba(255, 255, 255, 0.14)";
1431
- const MODAL_TIP_BG = "#222834";
1432
1505
  //#endregion
1433
1506
  //#region src/client/onboarding-store.ts
1434
1507
  /**
@@ -2287,7 +2360,8 @@ window.__ModuleLoader__.load({
2287
2360
  * toolbar-prefs: the persistence layer for the milestone rail's toolbar
2288
2361
  * personalization — WHICH function keys stay visible outside the collapse
2289
2362
  * (pinned) plus the settings-module appearance prefs (accent color, icon/dot
2290
- * size, distance from the rail's screen edge, and rail side).
2363
+ * size, distance from the rail's screen edge, rail side) and the collapsed
2364
+ * rail's floating ball (mode + last position).
2291
2365
  *
2292
2366
  * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding a
2293
2367
  * JSON object:
@@ -2295,13 +2369,17 @@ window.__ModuleLoader__.load({
2295
2369
  * { "pinned": string[], "accent": "#rrggbb", "iconSize": number,
2296
2370
  * "inset": number, "side": "left" | "right", "locale": "system"|"zh"|"en",
2297
2371
  * "focus": { "dimThink": boolean, "dimTools": boolean,
2298
- * "collapseThink": boolean, "opacity": number } }
2372
+ * "collapseThink": boolean, "opacity": number },
2373
+ * "ballMode": "fixed" | "draggable",
2374
+ * "ball": { "x": number, "y": number } | null }
2299
2375
  *
2300
2376
  * Backward compatibility: the pre-personalization blob `{ "pinned": string[] }`
2301
2377
  * (and an entirely absent value) parses to the DEFAULT prefs with the new
2302
2378
  * fields at their defaults — old users keep their pins untouched. The same
2303
2379
  * rule covers the `focus` object: a blob stored before 0.6.3 (no `focus`
2304
- * field) gains the default focus mix.
2380
+ * field) gains the default focus mix, and a blob stored before the floating
2381
+ * ball (no `ballMode` / `ball`) gains `ballMode: 'draggable'` + `ball: null`
2382
+ * (the computed default resting spot).
2305
2383
  *
2306
2384
  * All reads are sanitized per field:
2307
2385
  * - `pinned`: whitelisted ids only (`TOOLBAR_PIN_IDS`), duplicates dropped,
@@ -2313,7 +2391,11 @@ window.__ModuleLoader__.load({
2313
2391
  * - `side`: exactly `'left'` or `'right'`;
2314
2392
  * - `focus`: three booleans (`dimThink` / `dimTools` / `collapseThink`)
2315
2393
  * defaulting to `true` / `false` / `false`, plus the dim `opacity`
2316
- * snapped to the 0.1 step and clamped to [0.2, 0.8].
2394
+ * snapped to the 0.1 step and clamped to [0.2, 0.8];
2395
+ * - `ballMode`: exactly `'fixed'`, otherwise `'draggable'` (including a
2396
+ * legacy blob stored without the field);
2397
+ * - `ball`: a finite `{x, y}` pair, otherwise `null` — viewport clamping
2398
+ * happens at render time (`clampBallPosition`), never in storage.
2317
2399
  *
2318
2400
  * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
2319
2401
  * dependency-free and unit-testable; MilestoneRail's feature registry keys
@@ -2357,7 +2439,9 @@ window.__ModuleLoader__.load({
2357
2439
  inset: 14,
2358
2440
  side: "right",
2359
2441
  locale: "system",
2360
- focus: { ...DEFAULT_FOCUS_PREFS }
2442
+ focus: { ...DEFAULT_FOCUS_PREFS },
2443
+ ballMode: "draggable",
2444
+ ball: null
2361
2445
  };
2362
2446
  /** Type guard for registry ids — unknown strings never survive a parse. */
2363
2447
  function isToolbarPinId(id) {
@@ -2425,7 +2509,7 @@ window.__ModuleLoader__.load({
2425
2509
  return { ...DEFAULT_PREFS };
2426
2510
  }
2427
2511
  if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_PREFS };
2428
- const { pinned, accent, iconSize, inset, side, locale, focus } = parsed;
2512
+ const { pinned, accent, iconSize, inset, side, locale, focus, ballMode, ball } = parsed;
2429
2513
  return {
2430
2514
  pinned: sanitizePinned(pinned),
2431
2515
  accent: typeof accent === "string" && isHexColor(accent) ? accent.toLowerCase() : DEFAULT_PREFS.accent,
@@ -2433,7 +2517,9 @@ window.__ModuleLoader__.load({
2433
2517
  inset: clampStep(inset, 0, 40, 2, DEFAULT_PREFS.inset),
2434
2518
  side: side === "left" || side === "right" ? side : DEFAULT_PREFS.side,
2435
2519
  locale: locale === "zh" || locale === "en" || locale === "system" ? locale : DEFAULT_PREFS.locale,
2436
- focus: sanitizeFocus(focus)
2520
+ focus: sanitizeFocus(focus),
2521
+ ballMode: ballMode === "fixed" ? "fixed" : "draggable",
2522
+ ball: sanitizeBallPosition(ball)
2437
2523
  };
2438
2524
  }
2439
2525
  /**
@@ -2779,7 +2865,7 @@ window.__ModuleLoader__.load({
2779
2865
  * Installed plugin version. Injected at build time as
2780
2866
  * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
2781
2867
  */
2782
- const PLUGIN_VERSION = "0.6.6";
2868
+ const PLUGIN_VERSION = "0.7.1";
2783
2869
  //#endregion
2784
2870
  //#region src/client/MilestoneRail.tsx
2785
2871
  /**
@@ -2833,9 +2919,12 @@ window.__ModuleLoader__.load({
2833
2919
  */
2834
2920
  /** Minimum user messages before the rail adds value. */
2835
2921
  const MIN_MARKS = 2;
2836
- const PREVIEW_LENGTH = 80;
2837
2922
  /** Stable no-bookmarks fallback for render paths without the store seat. */
2838
2923
  const NO_BOOKMARKS = [];
2924
+ /** Stable empty projection fallback before the host unit mounts. */
2925
+ const EMPTY_PROJECTION_MESSAGES = Object.freeze([]);
2926
+ /** 0.1.2: per-turn badge kinds are not projected yet; stays empty. */
2927
+ const EMPTY_KINDS_BY_TURN = /* @__PURE__ */ new Map();
2839
2928
  /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
2840
2929
  const NO_KINDS = [];
2841
2930
  /** Visual dot diameter at the default icon size (px). */
@@ -2958,24 +3047,24 @@ window.__ModuleLoader__.load({
2958
3047
  /* Row and header washes (the inline styles deliberately leave backgrounds
2959
3048
  unset so these rules win over the default padding-box background). */
2960
3049
  [data-toolbar-pin-toggle]:hover, [data-toolbar-pin-toggle]:focus-visible { background: rgba(255, 255, 255, 0.06); }
2961
- [data-personal-toggle]:hover, [data-focus-toggle-settings]:hover { background: rgba(255, 255, 255, 0.05); }
3050
+ [data-personal-toggle]:hover, [data-focus-toggle-settings]:hover, [data-ball-toggle]:hover { background: rgba(255, 255, 255, 0.05); }
2962
3051
  [data-focus-option]:hover { background: rgba(255, 255, 255, 0.04); }
2963
3052
  [data-toolbar-settings-close]:hover { background: rgba(255, 255, 255, 0.08); }
2964
- [data-toolbar-settings-reset], [data-onboarding-reopen] { background: rgba(255, 255, 255, 0.06); }
2965
- [data-toolbar-settings-reset]:hover, [data-onboarding-reopen]:hover { background: rgba(255, 255, 255, 0.1); }
3053
+ [data-toolbar-settings-reset], [data-onboarding-reopen], [data-ball-reset] { background: rgba(255, 255, 255, 0.06); }
3054
+ [data-toolbar-settings-reset]:hover, [data-onboarding-reopen]:hover, [data-ball-reset]:hover { background: rgba(255, 255, 255, 0.1); }
2966
3055
  /* BASE state reset: every modal surface must sit transparent on the dark
2967
3056
  panel — without it the UA default button face (light gray) floods through
2968
3057
  and rows become unreadable light-on-light. Hover washes above take over
2969
3058
  on interaction. */
2970
- [data-toolbar-pin-toggle], [data-personal-toggle], [data-focus-toggle-settings],
3059
+ [data-toolbar-pin-toggle], [data-personal-toggle], [data-focus-toggle-settings], [data-ball-toggle],
2971
3060
  [data-toolbar-settings-close], [data-focus-option] {
2972
3061
  background: transparent;
2973
3062
  }
2974
3063
  /* ONE accent ring for keyboard focus on every modal control. */
2975
3064
  [data-toolbar-pin-toggle]:focus-visible, [data-personal-toggle]:focus-visible,
2976
- [data-focus-toggle-settings]:focus-visible,
3065
+ [data-focus-toggle-settings]:focus-visible, [data-ball-toggle]:focus-visible,
2977
3066
  [data-toolbar-settings-close]:focus-visible, [data-toolbar-settings-reset]:focus-visible,
2978
- [data-onboarding-reopen]:focus-visible {
3067
+ [data-onboarding-reopen]:focus-visible, [data-ball-reset]:focus-visible {
2979
3068
  box-shadow: 0 0 0 2px var(--ms-accent-soft);
2980
3069
  }
2981
3070
  /* Near-row description tip: a rotated square peeks out of the LEFT edge so
@@ -2989,10 +3078,10 @@ window.__ModuleLoader__.load({
2989
3078
  }
2990
3079
  /* Tip + chevron motion lives here (not inline) so reduced-motion can kill it. */
2991
3080
  [data-settings-tip] { transition: opacity 140ms ease, transform 140ms ease, visibility 140ms; }
2992
- [data-personal-toggle] svg, [data-focus-toggle-settings] svg { transition: transform 150ms ease; }
2993
- /* One authored reveal: the personalization/focus bodies fade in on expand. */
3081
+ [data-personal-toggle] svg, [data-focus-toggle-settings] svg, [data-ball-toggle] svg { transition: transform 150ms ease; }
3082
+ /* One authored reveal: the personalization/focus/ball bodies fade in on expand. */
2994
3083
  @keyframes ms-settings-fade { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } }
2995
- [data-settings-personal-body], [data-settings-focus-body] { animation: ms-settings-fade 140ms ease; }
3084
+ [data-settings-personal-body], [data-settings-focus-body], [data-settings-ball-body] { animation: ms-settings-fade 140ms ease; }
2996
3085
  /* Thin themed scrollbar for the scrollable modal panel. */
2997
3086
  [data-toolbar-settings-panel]::-webkit-scrollbar { width: 10px; }
2998
3087
  [data-toolbar-settings-panel]::-webkit-scrollbar-thumb {
@@ -3000,8 +3089,8 @@ window.__ModuleLoader__.load({
3000
3089
  }
3001
3090
  [data-toolbar-settings-panel]::-webkit-scrollbar-track { background: transparent; }
3002
3091
  @media (prefers-reduced-motion: reduce) {
3003
- [data-settings-tip], [data-personal-toggle] svg, [data-focus-toggle-settings] svg, [data-support-card] { transition: none; }
3004
- [data-settings-personal-body], [data-settings-focus-body] { animation: none; }
3092
+ [data-settings-tip], [data-personal-toggle] svg, [data-focus-toggle-settings] svg, [data-ball-toggle] svg, [data-support-card] { transition: none; }
3093
+ [data-settings-personal-body], [data-settings-focus-body], [data-settings-ball-body] { animation: none; }
3005
3094
  }
3006
3095
  [data-search-toggle] { color: #8b96ab !important; }
3007
3096
  [data-search-toggle][aria-pressed="true"] { background: var(--ms-accent-bg) !important; color: var(--ms-accent-soft) !important; }
@@ -3075,26 +3164,21 @@ window.__ModuleLoader__.load({
3075
3164
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
3076
3165
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
3077
3166
  */
3078
- function findRow(key) {
3079
- for (const row of document.querySelectorAll("[data-chat-anchor-key]")) if (row.dataset.chatAnchorKey === key) return row;
3167
+ function findRow(key, messageId) {
3168
+ const rows = document.querySelectorAll("[data-chat-anchor-key]");
3169
+ for (const row of rows) if (row.dataset.chatAnchorKey === key) return row;
3170
+ if (messageId !== void 0 && messageId !== "") for (const row of rows) {
3171
+ const anchor = row.dataset.chatAnchorKey;
3172
+ if (anchor !== void 0 && anchor.endsWith(messageId)) return row;
3173
+ }
3080
3174
  return null;
3081
3175
  }
3082
- /** Extract a plain-text hover preview (first 80 chars) from a ContentBlock[]. */
3083
- function extractPreview(content) {
3084
- return extractText(content).slice(0, PREVIEW_LENGTH);
3085
- }
3086
3176
  /** Compact duration label (ms). */
3087
3177
  function formatDuration(ms) {
3088
3178
  if (ms < 1e3) return `${ms}ms`;
3089
3179
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
3090
3180
  return `${Math.floor(ms / 6e4)}m${Math.floor(ms % 6e4 / 1e3)}s`;
3091
3181
  }
3092
- /** Read the ui-conversation 'turn-tail' location data (ttftMs/tokensPerSecond). */
3093
- function turnTailOf(turn) {
3094
- const data = turn.data;
3095
- if (data?.get === void 0) return void 0;
3096
- return data.get("turn-tail");
3097
- }
3098
3182
  /**
3099
3183
  * @param props - session standard kit (useSession, sessionId, useProjection),
3100
3184
  * the injected loadOlder/forkAt actions, the bookmarks store pair (useStore +
@@ -3103,52 +3187,37 @@ window.__ModuleLoader__.load({
3103
3187
  * `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
3104
3188
  * outside the slot machinery).
3105
3189
  */
3106
- function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
3190
+ function MilestoneRail({ useSession, useProjection, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
3107
3191
  items: [],
3108
3192
  hasMore: false
3109
3193
  }), openSession = () => {}, t: frameworkT = (key) => key }) {
3110
- const order = useSession((s) => s.chat.order);
3111
- const nodes = useSession((s) => s.chat.nodes);
3112
- const locations = useSession((s) => s.chat.locations);
3113
- const timeline = useSession((s) => s.chat.timeline);
3114
- const trajectoryRequests = useSession((s) => s.views.get("trajectory")?.requests);
3194
+ const projection = useProjection?.("milestone.messages");
3115
3195
  const hasMore = useSession((s) => s.hasMore);
3116
3196
  const loadingOlder = useSession((s) => s.loadingOlder);
3117
3197
  const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
3118
3198
  const marks = (0, react.useMemo)(() => {
3119
- const result = [];
3120
- for (const key of order) {
3121
- const node = nodes.get(key);
3122
- if (node === void 0 || node.kind !== "user") continue;
3123
- const data = node.data;
3124
- const turn = node.location.kind === "turn" || node.location.kind === "step" ? node.location.turn.turn : void 0;
3125
- result.push({
3126
- key,
3127
- turn,
3128
- seq: data.seq ?? 0,
3129
- time: data.time ?? 0,
3130
- text: extractText(data.content),
3131
- preview: extractPreview(data.content)
3132
- });
3133
- }
3134
- return result;
3135
- }, [order, nodes]);
3199
+ return (projection?.messages ?? EMPTY_PROJECTION_MESSAGES).map((m) => ({
3200
+ key: String(m.seq),
3201
+ turn: m.turn,
3202
+ seq: m.seq,
3203
+ messageId: m.messageId,
3204
+ time: m.time,
3205
+ text: m.text,
3206
+ preview: m.preview
3207
+ }));
3208
+ }, [projection]);
3136
3209
  const kindsByTurn = (0, react.useMemo)(() => {
3137
- const result = /* @__PURE__ */ new Map();
3138
- for (const node of nodes.values()) {
3139
- if (node.kind !== "turn-error" && node.kind !== "turn-max-tokens" && node.kind !== "model-retry") continue;
3140
- if (node.kind === "model-retry") {
3141
- if (node.data?.retryState === "cancelled") continue;
3142
- }
3143
- if (node.location.kind !== "turn" && node.location.kind !== "step") continue;
3144
- const kinds = result.get(node.location.turn.turn) ?? [];
3145
- kinds.push(node.kind);
3146
- result.set(node.location.turn.turn, kinds);
3210
+ const turns = projection?.turns;
3211
+ if (turns === void 0 || turns.length === 0) return EMPTY_KINDS_BY_TURN;
3212
+ const map = /* @__PURE__ */ new Map();
3213
+ for (const t of turns) {
3214
+ const kinds = kindsForTurn(t);
3215
+ if (kinds.length > 0) map.set(t.turn, kinds);
3147
3216
  }
3148
- return result;
3149
- }, [order, nodes]);
3217
+ return map;
3218
+ }, [projection]);
3150
3219
  const running = useSession((s) => s.running);
3151
- const awaitingInput = useSession((s) => s.pending).length > 0;
3220
+ const awaitingInput = useSession((s) => s.queue).length > 0;
3152
3221
  const [railBox, setRailBox] = (0, react.useState)(null);
3153
3222
  const [hover, setHover] = (0, react.useState)(null);
3154
3223
  const [search, setSearch] = (0, react.useState)({
@@ -3167,7 +3236,7 @@ window.__ModuleLoader__.load({
3167
3236
  const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
3168
3237
  const [focusIndex, setFocusIndex] = (0, react.useState)(0);
3169
3238
  const listRef = (0, react.useRef)(null);
3170
- const currentKey = useCurrentAnchor(order);
3239
+ const currentKey = useCurrentAnchor(marks.map((m) => m.key));
3171
3240
  /**
3172
3241
  * P3: jump to the chat row with the given node key — smooth-scroll it into
3173
3242
  * view and write the position back into the URL hash (`#msg=<key>`) so
@@ -3177,8 +3246,8 @@ window.__ModuleLoader__.load({
3177
3246
  * rail's own updates. No-op when the row is not (yet) rendered — the
3178
3247
  * deep-link mount retry and the load-older flow cover that case.
3179
3248
  */
3180
- const jump = (key) => {
3181
- const row = findRow(key);
3249
+ const jump = (key, messageId) => {
3250
+ const row = findRow(key, messageId);
3182
3251
  if (row === null) return;
3183
3252
  row.scrollIntoView({
3184
3253
  behavior: "smooth",
@@ -3215,7 +3284,7 @@ window.__ModuleLoader__.load({
3215
3284
  }, [displayMarks]);
3216
3285
  const displayTurns = (0, react.useMemo)(() => buildDisplayTurns(marks), [marks]);
3217
3286
  const [prefs, setPrefs] = (0, react.useState)(() => loadPrefs());
3218
- const { pinned, accent, iconSize, inset, side } = prefs;
3287
+ const { pinned, accent, iconSize, inset, side, ballMode, ball } = prefs;
3219
3288
  const scale = iconSize / DOT_HIT;
3220
3289
  const hit = iconSize;
3221
3290
  const size = DOT_SIZE * scale;
@@ -3295,6 +3364,27 @@ window.__ModuleLoader__.load({
3295
3364
  /** B-design (0.6.3): the focus block mirrors the personalization block —
3296
3365
  * collapsed by default, the header leads with a live option summary. */
3297
3366
  const [focusOpen, setFocusOpen] = (0, react.useState)(false);
3367
+ const [railCollapsed, setRailCollapsed] = (0, react.useState)(false);
3368
+ const [railCollapseHovered, setRailCollapseHovered] = (0, react.useState)(false);
3369
+ /** Issue #4 settings: the floating-ball block mirrors the personalization /
3370
+ * focus collapsibles — collapsed by default, the header leads with the mode. */
3371
+ const [ballOpen, setBallOpen] = (0, react.useState)(false);
3372
+ /** The live ball position while a press is in flight (`null` = resting). */
3373
+ const [dragPos, setDragPos] = (0, react.useState)(null);
3374
+ const dragRef = (0, react.useRef)(null);
3375
+ /** Detaches the in-flight press's window listeners (set on pointerdown). */
3376
+ const dragListenersRef = (0, react.useRef)(null);
3377
+ (0, react.useEffect)(() => () => {
3378
+ dragListenersRef.current?.();
3379
+ dragListenersRef.current = null;
3380
+ }, []);
3381
+ const [, setBallTick] = (0, react.useState)(0);
3382
+ (0, react.useEffect)(() => {
3383
+ if (!railCollapsed) return;
3384
+ const onResize = () => setBallTick((n) => n + 1);
3385
+ window.addEventListener("resize", onResize);
3386
+ return () => window.removeEventListener("resize", onResize);
3387
+ }, [railCollapsed]);
3298
3388
  const settingsRef = (0, react.useRef)(null);
3299
3389
  const settingsBtnRef = (0, react.useRef)(null);
3300
3390
  const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
@@ -3396,11 +3486,12 @@ window.__ModuleLoader__.load({
3396
3486
  let timer;
3397
3487
  const attempt = (pollsLeft, canLoadOlder) => {
3398
3488
  if (cancelled) return;
3399
- if (findRow(key) !== null) {
3400
- jump(key);
3489
+ const mark = marksRef.current.find((m) => m.key === key);
3490
+ if (findRow(key, mark?.messageId) !== null) {
3491
+ jump(key, mark?.messageId);
3401
3492
  return;
3402
3493
  }
3403
- if (marksRef.current.length > 0 && !marksRef.current.some((m) => m.key === key)) return;
3494
+ if (marksRef.current.length > 0 && mark === void 0) return;
3404
3495
  if (pollsLeft > 0) {
3405
3496
  timer = window.setTimeout(() => attempt(pollsLeft - 1, canLoadOlder), DEEP_LINK_POLL_DELAY);
3406
3497
  return;
@@ -3422,7 +3513,8 @@ window.__ModuleLoader__.load({
3422
3513
  const onHashChange = () => {
3423
3514
  const key = parseDeepLinkHash(window.location.hash);
3424
3515
  if (key === null) return;
3425
- if (marksRef.current.some((m) => m.key === key)) jump(key);
3516
+ const mark = marksRef.current.find((m) => m.key === key);
3517
+ if (mark !== void 0) jump(key, mark.messageId);
3426
3518
  };
3427
3519
  window.addEventListener("hashchange", onHashChange);
3428
3520
  return () => window.removeEventListener("hashchange", onHashChange);
@@ -3531,7 +3623,8 @@ window.__ModuleLoader__.load({
3531
3623
  ...s,
3532
3624
  activePos: next
3533
3625
  }));
3534
- jump(displayMarks[matches[next]].key);
3626
+ const mark = displayMarks[matches[next]];
3627
+ jump(mark.key, mark.messageId);
3535
3628
  };
3536
3629
  const onSearchKeyDown = (e) => {
3537
3630
  if (e.key === "Enter") advanceMatch();
@@ -3861,24 +3954,21 @@ window.__ModuleLoader__.load({
3861
3954
  const buildHover = (mark, index) => {
3862
3955
  if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
3863
3956
  if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
3864
- const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
3957
+ const turnMeta = mark.turn !== void 0 ? projection?.turns.find((turn) => turn.turn === mark.turn) : void 0;
3865
3958
  let durationLabel = null;
3866
3959
  let reasonLabel = null;
3867
3960
  let ttftLabel = null;
3868
3961
  let tpsLabel = null;
3869
- if (turn !== void 0) {
3870
- if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
3871
- if (turn.end !== void 0) {
3872
- const reason = turn.end.data.reason;
3873
- if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
3874
- }
3875
- const tail = turnTailOf(turn);
3876
- if (tail !== void 0) {
3877
- if (tail.ttftMs !== void 0) ttftLabel = formatDuration(tail.ttftMs);
3878
- if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
3962
+ if (turnMeta !== void 0) {
3963
+ if (turnMeta.startTime !== void 0 && turnMeta.endTime !== void 0) durationLabel = formatDuration(turnMeta.endTime - turnMeta.startTime);
3964
+ if (turnMeta.endReason !== void 0) reasonLabel = t(reasonKeyOf(turnMeta.endReason));
3965
+ if (turnMeta.firstChunkTime !== void 0 && turnMeta.startTime !== void 0) ttftLabel = formatDuration(turnMeta.firstChunkTime - turnMeta.startTime);
3966
+ if (turnMeta.usage !== void 0 && durationLabel !== null) {
3967
+ const seconds = (turnMeta.endTime - turnMeta.startTime) / 1e3;
3968
+ if (seconds > 0) tpsLabel = `${(turnMeta.usage.output / seconds).toFixed(1)} tok/s`;
3879
3969
  }
3880
3970
  }
3881
- const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
3971
+ const meta = deriveTurnMetaFromProjection(turnMeta);
3882
3972
  const summaryCount = collapsedSummaries.get(mark.key);
3883
3973
  return {
3884
3974
  mark,
@@ -3943,6 +4033,157 @@ window.__ModuleLoader__.load({
3943
4033
  forkAt(mark.seq).then(() => setForkedKey(mark.key));
3944
4034
  };
3945
4035
  const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
4036
+ if (railCollapsed) {
4037
+ const viewport = {
4038
+ width: window.innerWidth,
4039
+ height: window.innerHeight
4040
+ };
4041
+ const resting = clampBallPosition(ball ?? defaultBallPosition(viewport, side, inset), viewport);
4042
+ const pos = dragPos ?? resting;
4043
+ /** Detach the in-flight press's window listeners (idempotent). */
4044
+ const removeDragListeners = () => {
4045
+ const detach = dragListenersRef.current;
4046
+ if (detach === null) return;
4047
+ dragListenersRef.current = null;
4048
+ detach();
4049
+ };
4050
+ /** Release the pointer capture when the DOM implementation supports it. */
4051
+ const releaseBallCapture = (el, pointerId) => {
4052
+ if (typeof el.releasePointerCapture !== "function") return;
4053
+ try {
4054
+ el.releasePointerCapture(pointerId);
4055
+ } catch {}
4056
+ };
4057
+ /**
4058
+ * Press start: in draggable mode record the gesture anchor and arm the
4059
+ * window pointer listeners; in fixed mode do nothing drag-related — the
4060
+ * plain onClick fallback below still expands the rail.
4061
+ */
4062
+ const onBallPointerDown = (e) => {
4063
+ if (ballMode !== "draggable") return;
4064
+ const el = e.currentTarget;
4065
+ if (typeof el.setPointerCapture === "function") try {
4066
+ el.setPointerCapture(e.pointerId);
4067
+ } catch {}
4068
+ if (dragRef.current !== null) removeDragListeners();
4069
+ dragRef.current = {
4070
+ pointerId: e.pointerId,
4071
+ startPointer: {
4072
+ x: e.clientX,
4073
+ y: e.clientY
4074
+ },
4075
+ startPos: pos,
4076
+ moved: false
4077
+ };
4078
+ const onMove = (ev) => {
4079
+ const drag = dragRef.current;
4080
+ if (drag === null) return;
4081
+ const live = {
4082
+ width: window.innerWidth,
4083
+ height: window.innerHeight
4084
+ };
4085
+ setDragPos(clampBallPosition({
4086
+ x: drag.startPos.x + (ev.clientX - drag.startPointer.x),
4087
+ y: drag.startPos.y + (ev.clientY - drag.startPointer.y)
4088
+ }, live));
4089
+ drag.moved = drag.moved || isDragGesture(drag.startPointer, {
4090
+ x: ev.clientX,
4091
+ y: ev.clientY
4092
+ });
4093
+ };
4094
+ const onUp = (ev) => {
4095
+ const drag = dragRef.current;
4096
+ if (drag === null) return;
4097
+ removeDragListeners();
4098
+ releaseBallCapture(el, drag.pointerId);
4099
+ if (drag.moved) {
4100
+ const live = {
4101
+ width: window.innerWidth,
4102
+ height: window.innerHeight
4103
+ };
4104
+ updatePrefs({ ball: clampBallPosition({
4105
+ x: drag.startPos.x + (ev.clientX - drag.startPointer.x),
4106
+ y: drag.startPos.y + (ev.clientY - drag.startPointer.y)
4107
+ }, live) });
4108
+ } else setRailCollapsed(false);
4109
+ setDragPos(null);
4110
+ dragRef.current = null;
4111
+ };
4112
+ const onCancel = () => {
4113
+ const drag = dragRef.current;
4114
+ if (drag === null) return;
4115
+ removeDragListeners();
4116
+ releaseBallCapture(el, drag.pointerId);
4117
+ setDragPos(null);
4118
+ dragRef.current = null;
4119
+ };
4120
+ window.addEventListener("pointermove", onMove);
4121
+ window.addEventListener("pointerup", onUp);
4122
+ window.addEventListener("pointercancel", onCancel);
4123
+ dragListenersRef.current = () => {
4124
+ window.removeEventListener("pointermove", onMove);
4125
+ window.removeEventListener("pointerup", onUp);
4126
+ window.removeEventListener("pointercancel", onCancel);
4127
+ };
4128
+ };
4129
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4130
+ "data-milestone-ball": true,
4131
+ role: "button",
4132
+ tabIndex: 0,
4133
+ "aria-label": t("ball.expand"),
4134
+ title: t("ball.expand"),
4135
+ "data-ball-x": String(Math.round(pos.x)),
4136
+ "data-ball-y": String(Math.round(pos.y)),
4137
+ onPointerDown: onBallPointerDown,
4138
+ onClick: ballMode === "fixed" ? () => setRailCollapsed(false) : void 0,
4139
+ onKeyDown: (e) => {
4140
+ if (e.key !== "Enter" && e.key !== " ") return;
4141
+ e.preventDefault();
4142
+ setRailCollapsed(false);
4143
+ },
4144
+ style: {
4145
+ position: "fixed",
4146
+ left: pos.x,
4147
+ top: pos.y,
4148
+ width: 40,
4149
+ height: 40,
4150
+ borderRadius: "50%",
4151
+ background: accent,
4152
+ color: "#ffffff",
4153
+ opacity: .9,
4154
+ zIndex: 100,
4155
+ display: "flex",
4156
+ alignItems: "center",
4157
+ justifyContent: "center",
4158
+ touchAction: "none",
4159
+ userSelect: "none",
4160
+ cursor: ballMode === "draggable" ? "grab" : "pointer",
4161
+ boxShadow: "0 4px 14px rgba(0, 0, 0, 0.35)"
4162
+ },
4163
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
4164
+ width: "18",
4165
+ height: "18",
4166
+ viewBox: "0 0 24 24",
4167
+ fill: "none",
4168
+ stroke: "currentColor",
4169
+ strokeWidth: "2",
4170
+ strokeLinecap: "round",
4171
+ strokeLinejoin: "round",
4172
+ "aria-hidden": "true",
4173
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4174
+ cx: "12",
4175
+ cy: "12",
4176
+ r: "8"
4177
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4178
+ cx: "12",
4179
+ cy: "12",
4180
+ r: "2.5",
4181
+ fill: "currentColor",
4182
+ stroke: "none"
4183
+ })]
4184
+ })
4185
+ });
4186
+ }
3946
4187
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3947
4188
  style: {
3948
4189
  position: "fixed",
@@ -4000,6 +4241,40 @@ window.__ModuleLoader__.load({
4000
4241
  },
4001
4242
  children: "···"
4002
4243
  }),
4244
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4245
+ type: "button",
4246
+ "data-rail-collapse": true,
4247
+ "aria-label": t("rail.collapse"),
4248
+ title: t("rail.collapse"),
4249
+ onClick: () => setRailCollapsed(true),
4250
+ onMouseEnter: () => setRailCollapseHovered(true),
4251
+ onMouseLeave: () => setRailCollapseHovered(false),
4252
+ onFocus: () => setRailCollapseHovered(true),
4253
+ onBlur: () => setRailCollapseHovered(false),
4254
+ style: chromeButtonStyle(railCollapseHovered),
4255
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
4256
+ width: "16",
4257
+ height: "16",
4258
+ viewBox: "0 0 24 24",
4259
+ fill: "none",
4260
+ stroke: "currentColor",
4261
+ strokeWidth: "2",
4262
+ strokeLinecap: "round",
4263
+ strokeLinejoin: "round",
4264
+ "aria-hidden": "true",
4265
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4266
+ cx: "12",
4267
+ cy: "12",
4268
+ r: "8"
4269
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
4270
+ cx: "12",
4271
+ cy: "12",
4272
+ r: "2.5",
4273
+ fill: "currentColor",
4274
+ stroke: "none"
4275
+ })]
4276
+ })
4277
+ }),
4003
4278
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4004
4279
  type: "button",
4005
4280
  "data-toolbar-expand": true,
@@ -4509,6 +4784,139 @@ window.__ModuleLoader__.load({
4509
4784
  ]
4510
4785
  })]
4511
4786
  }),
4787
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4788
+ "data-settings-section": true,
4789
+ "data-settings-ball": true,
4790
+ style: { marginBottom: 20 },
4791
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
4792
+ type: "button",
4793
+ "data-ball-toggle": true,
4794
+ "aria-expanded": ballOpen,
4795
+ "aria-label": t("settings.section.ball"),
4796
+ title: t("settings.section.ball"),
4797
+ onClick: () => setBallOpen((v) => !v),
4798
+ style: SECTION_TOGGLE_STYLE,
4799
+ children: [
4800
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
4801
+ width: "14",
4802
+ height: "14",
4803
+ viewBox: "0 0 24 24",
4804
+ fill: "none",
4805
+ stroke: "currentColor",
4806
+ strokeWidth: "2.5",
4807
+ strokeLinecap: "round",
4808
+ strokeLinejoin: "round",
4809
+ "aria-hidden": "true",
4810
+ style: {
4811
+ flexShrink: 0,
4812
+ transform: ballOpen ? "rotate(90deg)" : "none"
4813
+ },
4814
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m9 18 6-6-6-6" })
4815
+ }),
4816
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4817
+ "data-settings-section-title": true,
4818
+ style: { flexShrink: 0 },
4819
+ children: t("settings.section.ball")
4820
+ }),
4821
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4822
+ "data-ball-summary": true,
4823
+ style: SECTION_SUMMARY_STYLE,
4824
+ children: ballMode === "fixed" ? t("settings.ball.mode.fixed") : t("settings.ball.mode.draggable")
4825
+ })
4826
+ ]
4827
+ }), ballOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4828
+ "data-settings-ball-body": true,
4829
+ style: {
4830
+ padding: "10px 4px 8px",
4831
+ display: "flex",
4832
+ flexDirection: "column",
4833
+ gap: 12
4834
+ },
4835
+ children: [
4836
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4837
+ "data-settings-ball-hint": true,
4838
+ style: {
4839
+ fontSize: 12,
4840
+ color: "#8b96ab",
4841
+ lineHeight: 1.5,
4842
+ padding: "0 6px"
4843
+ },
4844
+ children: t("settings.ball.hint")
4845
+ }),
4846
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4847
+ role: "radiogroup",
4848
+ "aria-label": t("settings.ball.mode"),
4849
+ style: {
4850
+ display: "flex",
4851
+ alignItems: "center",
4852
+ gap: 10,
4853
+ flexWrap: "wrap"
4854
+ },
4855
+ children: [
4856
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4857
+ style: {
4858
+ fontSize: 12.5,
4859
+ color: "#8b96ab",
4860
+ width: 90,
4861
+ flexShrink: 0
4862
+ },
4863
+ children: t("settings.ball.mode")
4864
+ }),
4865
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4866
+ style: {
4867
+ display: "inline-flex",
4868
+ alignItems: "center",
4869
+ gap: 5,
4870
+ fontSize: 13,
4871
+ color: "#e6e8ee",
4872
+ cursor: "pointer"
4873
+ },
4874
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4875
+ type: "radio",
4876
+ name: "ms-ball-mode",
4877
+ "data-ball-mode-radio": true,
4878
+ value: "fixed",
4879
+ checked: ballMode === "fixed",
4880
+ onChange: () => updatePrefs({ ballMode: "fixed" })
4881
+ }), t("settings.ball.mode.fixed")]
4882
+ }),
4883
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
4884
+ style: {
4885
+ display: "inline-flex",
4886
+ alignItems: "center",
4887
+ gap: 5,
4888
+ fontSize: 13,
4889
+ color: "#e6e8ee",
4890
+ cursor: "pointer"
4891
+ },
4892
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
4893
+ type: "radio",
4894
+ name: "ms-ball-mode",
4895
+ "data-ball-mode-radio": true,
4896
+ value: "draggable",
4897
+ checked: ballMode === "draggable",
4898
+ onChange: () => updatePrefs({ ballMode: "draggable" })
4899
+ }), t("settings.ball.mode.draggable")]
4900
+ })
4901
+ ]
4902
+ }),
4903
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4904
+ type: "button",
4905
+ "data-ball-reset": true,
4906
+ onClick: () => updatePrefs({ ball: null }),
4907
+ style: {
4908
+ padding: "7px 16px",
4909
+ border: `1px solid rgba(255, 255, 255, 0.14)`,
4910
+ borderRadius: 8,
4911
+ cursor: "pointer",
4912
+ color: "#b9c2d4",
4913
+ fontSize: 12.5
4914
+ },
4915
+ children: t("settings.ball.reset")
4916
+ }) })
4917
+ ]
4918
+ })]
4919
+ }),
4512
4920
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4513
4921
  "data-settings-section": true,
4514
4922
  "data-focus-settings": true,
@@ -4907,7 +5315,7 @@ window.__ModuleLoader__.load({
4907
5315
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4908
5316
  style: { color: "#8b96ab" },
4909
5317
  children: [t("update.current"), ": "]
4910
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.6" })] }),
5318
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.7.1" })] }),
4911
5319
  updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4912
5320
  "data-update-latest": true,
4913
5321
  children: [
@@ -5085,7 +5493,7 @@ window.__ModuleLoader__.load({
5085
5493
  top: rect.top + rect.height / 2
5086
5494
  });
5087
5495
  },
5088
- onClick: () => jump(mark.key),
5496
+ onClick: () => jump(mark.key, mark.messageId),
5089
5497
  "data-rail-dot": true,
5090
5498
  "data-turn-gap": showGroupGap ? "true" : void 0,
5091
5499
  "data-turn": showGroupGap && mark.turn !== void 0 ? mark.turn : void 0,
@@ -5190,6 +5598,70 @@ window.__ModuleLoader__.load({
5190
5598
  });
5191
5599
  }
5192
5600
  //#endregion
5601
+ //#region src/client/CompatBoundary.tsx
5602
+ /**
5603
+ * Compatibility boundary for both plugin surfaces.
5604
+ *
5605
+ * The plugin consumes the framework's session standard kit (`useSession`,
5606
+ * `useProjection`, the store seat) and the slot registry, all of which a DSH
5607
+ * upgrade may rename or reshape. Without a boundary such a failure throws out
5608
+ * of the plugin's render — and when the registry silently ignores a stale
5609
+ * entry the rail simply vanishes with no explanation.
5610
+ *
5611
+ * The boundary converts any throw into ONE compact, honest notice instead: the
5612
+ * surrounding harness keeps working, and the user is told what to do (0.1.2→
5613
+ * 0.1.5 has already shown that these upgrades do break third-party plugins).
5614
+ *
5615
+ * A class boundary rather than a per-render capability probe is deliberate:
5616
+ * React exposes error boundaries only through class components, and swapping
5617
+ * hook implementations based on a runtime probe would break hook order.
5618
+ */
5619
+ /** Catches a render failure anywhere below and shows the incompatibility notice. */
5620
+ var CompatBoundary = class extends react.Component {
5621
+ state = { failed: false };
5622
+ static getDerivedStateFromError() {
5623
+ return { failed: true };
5624
+ }
5625
+ componentDidCatch(error, info) {
5626
+ console.error("[dsh-milestone] incompatible with this DSH version — the rail is disabled. Please update dsh-milestone (or pin an older DSH).", error, info.componentStack);
5627
+ }
5628
+ render() {
5629
+ if (!this.state.failed) return this.props.children;
5630
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
5631
+ "data-milestone-incompatible": true,
5632
+ role: "status",
5633
+ style: {
5634
+ position: "fixed",
5635
+ right: 14,
5636
+ bottom: 14,
5637
+ zIndex: 100,
5638
+ maxWidth: 300,
5639
+ padding: "10px 12px",
5640
+ background: MODAL_BG,
5641
+ color: MODAL_HINT,
5642
+ border: `1px solid ${MODAL_BORDER}`,
5643
+ borderRadius: 12,
5644
+ fontSize: 12,
5645
+ lineHeight: 1.5,
5646
+ fontFamily: "inherit"
5647
+ },
5648
+ children: [
5649
+ "dsh-milestone 与当前 DSH 版本不兼容,已停用。请升级插件。",
5650
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("br", {}),
5651
+ "Incompatible with this DSH version — please update dsh-milestone."
5652
+ ]
5653
+ });
5654
+ }
5655
+ };
5656
+ /** The `shell.overlay` seat wrapped in the compatibility boundary. */
5657
+ function GuardedMilestoneOverlay(props) {
5658
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompatBoundary, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneOverlay, { ...props }) });
5659
+ }
5660
+ /** The `milestone.rail` seat wrapped in the compatibility boundary. */
5661
+ function GuardedMilestoneRail(props) {
5662
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CompatBoundary, { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneRail, { ...props }) });
5663
+ }
5664
+ //#endregion
5193
5665
  //#region src/client/bookmarkStore.ts
5194
5666
  /**
5195
5667
  * Persisted per-session bookmarks store for the milestone rail.
@@ -5200,13 +5672,18 @@ window.__ModuleLoader__.load({
5200
5672
  * resolved by the engine's `create(scopeKey)`). Consumers must call the
5201
5673
  * FACTORY (never a module-level handle — module-cache identity is a disguised
5202
5674
  * singleton across plugin reloads).
5675
+ *
5676
+ * 0.1.2 compat: the snapshot-store engine moved out of
5677
+ * `@deepseek-ai/dsh-client-runtime/client` into the platform module
5678
+ * `@deepseek-ai/dsh-client-store` (a web module-table seed); the old
5679
+ * specifier is gone from the 0.1.2 module table.
5203
5680
  */
5204
5681
  /**
5205
5682
  * Declare the bookmarks store handle. Returns a fresh handle per call; the
5206
5683
  * framework (or tests) create per-session instances via `create(scopeKey)`.
5207
5684
  */
5208
5685
  function createBookmarksStore() {
5209
- return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
5686
+ return (0, _deepseek_ai_dsh_client_store.defineStore)({
5210
5687
  init: () => ({ keys: [] }),
5211
5688
  persist: "dsh-milestone.bookmarks",
5212
5689
  actions: {
@@ -5245,7 +5722,7 @@ window.__ModuleLoader__.load({
5245
5722
  return {
5246
5723
  items: result.value.items.map((item) => ({
5247
5724
  ...item,
5248
- title: byId[item.sessionId]?.displayTitle
5725
+ title: byId[item.sessionId]?.title
5249
5726
  })),
5250
5727
  hasMore: result.value.hasMore
5251
5728
  };
@@ -5314,6 +5791,7 @@ window.__ModuleLoader__.load({
5314
5791
  * @param ctx - client root context.
5315
5792
  */
5316
5793
  function apply(ctx) {
5794
+ const sessions = ctx.sessions;
5317
5795
  ctx.effect(() => ctx.locale.register("dsh-milestone", {
5318
5796
  zh,
5319
5797
  en
@@ -5326,18 +5804,18 @@ window.__ModuleLoader__.load({
5326
5804
  kind: "single",
5327
5805
  scope: "session"
5328
5806
  } }
5329
- }, MilestoneOverlay));
5807
+ }, GuardedMilestoneOverlay));
5330
5808
  ctx.slots.inject("milestone.rail", () => ctx.slots.register({
5331
5809
  name: "milestone.rail",
5332
5810
  store: createBookmarksStore,
5333
5811
  locale: "dsh-milestone",
5334
5812
  inject: (sessionId) => ({
5335
- loadOlder: createLoadOlder(ctx.sessions, sessionId),
5336
- forkAt: createForkAt(ctx.sessions, sessionId),
5337
- searchSessions: createSessionSearch(ctx.sessions),
5338
- openSession: createOpenSession(ctx.sessions)
5813
+ loadOlder: createLoadOlder(sessions, sessionId),
5814
+ forkAt: createForkAt(sessions, sessionId),
5815
+ searchSessions: createSessionSearch(sessions),
5816
+ openSession: createOpenSession(sessions)
5339
5817
  })
5340
- }, MilestoneRail));
5818
+ }, GuardedMilestoneRail));
5341
5819
  }
5342
5820
  //#endregion
5343
5821
  exports.apply = apply;