dsh-milestone 0.6.6 → 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 -165
  2. package/lib/client.js +544 -172
  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
- };
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) {
471
+ function deriveTurnMetaFromProjection(turn) {
472
+ if (turn?.usage === void 0) return EMPTY_META;
411
473
  return {
412
- model: record.requestConfig?.model ?? record.provenance?.model ?? null,
413
- purpose: record.requestConfig?.purpose ?? null,
414
- ...decodeUsage(record.usage)
474
+ model: null,
475
+ purpose: null,
476
+ inputTokens: turn.usage.input,
477
+ outputTokens: turn.usage.output
415
478
  };
416
479
  }
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.
@@ -621,6 +637,10 @@ window.__ModuleLoader__.load({
621
637
  "rail.label": "会话里程碑",
622
638
  /** aria-label on the dot list. */
623
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": "展开里程碑条",
624
644
  /** Hover preview fallback for empty message text. */
625
645
  "no.text": "(无文本)",
626
646
  /** Relative time: `< 60s`. */
@@ -711,6 +731,8 @@ window.__ModuleLoader__.load({
711
731
  "settings.section.personal": "个性化",
712
732
  /** Settings modal: section heading for the focus-mode controls (0.6.3). */
713
733
  "settings.section.focus": "聚焦",
734
+ /** Settings modal: section heading for the floating-ball controls (issue #4). */
735
+ "settings.section.ball": "悬浮球",
714
736
  /** Settings: personalization-section hint shown inside the expanded block. */
715
737
  "settings.personal.hint": "圆点、强调色与位置,即调即存",
716
738
  /** Settings: aria-label on the personalization block toggle while COLLAPSED. */
@@ -747,6 +769,16 @@ window.__ModuleLoader__.load({
747
769
  "settings.side.left": "左侧",
748
770
  /** Settings personalization: side radio — hug the right edge. */
749
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": "重置位置",
750
782
  /** Settings: focus block — hint shown inside the expanded block. */
751
783
  "settings.focus.hint": "这些选项自由组合成你的「聚焦搭配」;总开关仍是工具栏的眼睛按钮",
752
784
  /** Settings: aria-label on the focus block toggle while COLLAPSED. */
@@ -853,6 +885,8 @@ window.__ModuleLoader__.load({
853
885
  "load.older": "Load older messages",
854
886
  "rail.label": "Session milestones",
855
887
  "rail.list": "Session milestone list",
888
+ "rail.collapse": "Collapse to floating ball",
889
+ "ball.expand": "Expand milestone rail",
856
890
  "no.text": "(no text)",
857
891
  "time.justNow": "Just now",
858
892
  "time.minutes": "{n} minutes ago",
@@ -899,6 +933,7 @@ window.__ModuleLoader__.load({
899
933
  "settings.section.features": "Features & Shortcuts",
900
934
  "settings.section.personal": "Personalization",
901
935
  "settings.section.focus": "Focus",
936
+ "settings.section.ball": "Floating ball",
902
937
  "settings.personal.hint": "Dot size, accent color, and position — saved as you adjust",
903
938
  "settings.personal.expand": "Expand personalization",
904
939
  "settings.personal.collapse": "Collapse personalization",
@@ -917,6 +952,11 @@ window.__ModuleLoader__.load({
917
952
  "settings.side": "Position",
918
953
  "settings.side.left": "Left",
919
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",
920
960
  "settings.focus.hint": "Combine these options into your own focus recipe; the eye button on the toolbar stays the master switch",
921
961
  "settings.focus.expand": "Expand focus settings",
922
962
  "settings.focus.collapse": "Collapse focus settings",
@@ -2287,7 +2327,8 @@ window.__ModuleLoader__.load({
2287
2327
  * toolbar-prefs: the persistence layer for the milestone rail's toolbar
2288
2328
  * personalization — WHICH function keys stay visible outside the collapse
2289
2329
  * (pinned) plus the settings-module appearance prefs (accent color, icon/dot
2290
- * 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).
2291
2332
  *
2292
2333
  * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding a
2293
2334
  * JSON object:
@@ -2295,13 +2336,17 @@ window.__ModuleLoader__.load({
2295
2336
  * { "pinned": string[], "accent": "#rrggbb", "iconSize": number,
2296
2337
  * "inset": number, "side": "left" | "right", "locale": "system"|"zh"|"en",
2297
2338
  * "focus": { "dimThink": boolean, "dimTools": boolean,
2298
- * "collapseThink": boolean, "opacity": number } }
2339
+ * "collapseThink": boolean, "opacity": number },
2340
+ * "ballMode": "fixed" | "draggable",
2341
+ * "ball": { "x": number, "y": number } | null }
2299
2342
  *
2300
2343
  * Backward compatibility: the pre-personalization blob `{ "pinned": string[] }`
2301
2344
  * (and an entirely absent value) parses to the DEFAULT prefs with the new
2302
2345
  * fields at their defaults — old users keep their pins untouched. The same
2303
2346
  * rule covers the `focus` object: a blob stored before 0.6.3 (no `focus`
2304
- * 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).
2305
2350
  *
2306
2351
  * All reads are sanitized per field:
2307
2352
  * - `pinned`: whitelisted ids only (`TOOLBAR_PIN_IDS`), duplicates dropped,
@@ -2313,7 +2358,11 @@ window.__ModuleLoader__.load({
2313
2358
  * - `side`: exactly `'left'` or `'right'`;
2314
2359
  * - `focus`: three booleans (`dimThink` / `dimTools` / `collapseThink`)
2315
2360
  * defaulting to `true` / `false` / `false`, plus the dim `opacity`
2316
- * 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.
2317
2366
  *
2318
2367
  * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
2319
2368
  * dependency-free and unit-testable; MilestoneRail's feature registry keys
@@ -2357,7 +2406,9 @@ window.__ModuleLoader__.load({
2357
2406
  inset: 14,
2358
2407
  side: "right",
2359
2408
  locale: "system",
2360
- focus: { ...DEFAULT_FOCUS_PREFS }
2409
+ focus: { ...DEFAULT_FOCUS_PREFS },
2410
+ ballMode: "draggable",
2411
+ ball: null
2361
2412
  };
2362
2413
  /** Type guard for registry ids — unknown strings never survive a parse. */
2363
2414
  function isToolbarPinId(id) {
@@ -2425,7 +2476,7 @@ window.__ModuleLoader__.load({
2425
2476
  return { ...DEFAULT_PREFS };
2426
2477
  }
2427
2478
  if (typeof parsed !== "object" || parsed === null) return { ...DEFAULT_PREFS };
2428
- const { pinned, accent, iconSize, inset, side, locale, focus } = parsed;
2479
+ const { pinned, accent, iconSize, inset, side, locale, focus, ballMode, ball } = parsed;
2429
2480
  return {
2430
2481
  pinned: sanitizePinned(pinned),
2431
2482
  accent: typeof accent === "string" && isHexColor(accent) ? accent.toLowerCase() : DEFAULT_PREFS.accent,
@@ -2433,7 +2484,9 @@ window.__ModuleLoader__.load({
2433
2484
  inset: clampStep(inset, 0, 40, 2, DEFAULT_PREFS.inset),
2434
2485
  side: side === "left" || side === "right" ? side : DEFAULT_PREFS.side,
2435
2486
  locale: locale === "zh" || locale === "en" || locale === "system" ? locale : DEFAULT_PREFS.locale,
2436
- focus: sanitizeFocus(focus)
2487
+ focus: sanitizeFocus(focus),
2488
+ ballMode: ballMode === "fixed" ? "fixed" : "draggable",
2489
+ ball: sanitizeBallPosition(ball)
2437
2490
  };
2438
2491
  }
2439
2492
  /**
@@ -2779,7 +2832,7 @@ window.__ModuleLoader__.load({
2779
2832
  * Installed plugin version. Injected at build time as
2780
2833
  * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
2781
2834
  */
2782
- const PLUGIN_VERSION = "0.6.6";
2835
+ const PLUGIN_VERSION = "0.7.0";
2783
2836
  //#endregion
2784
2837
  //#region src/client/MilestoneRail.tsx
2785
2838
  /**
@@ -2833,9 +2886,12 @@ window.__ModuleLoader__.load({
2833
2886
  */
2834
2887
  /** Minimum user messages before the rail adds value. */
2835
2888
  const MIN_MARKS = 2;
2836
- const PREVIEW_LENGTH = 80;
2837
2889
  /** Stable no-bookmarks fallback for render paths without the store seat. */
2838
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();
2839
2895
  /** Stable no-kinds fallback for marks whose turn carries no badge nodes. */
2840
2896
  const NO_KINDS = [];
2841
2897
  /** Visual dot diameter at the default icon size (px). */
@@ -2958,24 +3014,24 @@ window.__ModuleLoader__.load({
2958
3014
  /* Row and header washes (the inline styles deliberately leave backgrounds
2959
3015
  unset so these rules win over the default padding-box background). */
2960
3016
  [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); }
3017
+ [data-personal-toggle]:hover, [data-focus-toggle-settings]:hover, [data-ball-toggle]:hover { background: rgba(255, 255, 255, 0.05); }
2962
3018
  [data-focus-option]:hover { background: rgba(255, 255, 255, 0.04); }
2963
3019
  [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); }
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); }
2966
3022
  /* BASE state reset: every modal surface must sit transparent on the dark
2967
3023
  panel — without it the UA default button face (light gray) floods through
2968
3024
  and rows become unreadable light-on-light. Hover washes above take over
2969
3025
  on interaction. */
2970
- [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],
2971
3027
  [data-toolbar-settings-close], [data-focus-option] {
2972
3028
  background: transparent;
2973
3029
  }
2974
3030
  /* ONE accent ring for keyboard focus on every modal control. */
2975
3031
  [data-toolbar-pin-toggle]:focus-visible, [data-personal-toggle]:focus-visible,
2976
- [data-focus-toggle-settings]:focus-visible,
3032
+ [data-focus-toggle-settings]:focus-visible, [data-ball-toggle]:focus-visible,
2977
3033
  [data-toolbar-settings-close]:focus-visible, [data-toolbar-settings-reset]:focus-visible,
2978
- [data-onboarding-reopen]:focus-visible {
3034
+ [data-onboarding-reopen]:focus-visible, [data-ball-reset]:focus-visible {
2979
3035
  box-shadow: 0 0 0 2px var(--ms-accent-soft);
2980
3036
  }
2981
3037
  /* Near-row description tip: a rotated square peeks out of the LEFT edge so
@@ -2989,10 +3045,10 @@ window.__ModuleLoader__.load({
2989
3045
  }
2990
3046
  /* Tip + chevron motion lives here (not inline) so reduced-motion can kill it. */
2991
3047
  [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. */
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. */
2994
3050
  @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; }
3051
+ [data-settings-personal-body], [data-settings-focus-body], [data-settings-ball-body] { animation: ms-settings-fade 140ms ease; }
2996
3052
  /* Thin themed scrollbar for the scrollable modal panel. */
2997
3053
  [data-toolbar-settings-panel]::-webkit-scrollbar { width: 10px; }
2998
3054
  [data-toolbar-settings-panel]::-webkit-scrollbar-thumb {
@@ -3000,8 +3056,8 @@ window.__ModuleLoader__.load({
3000
3056
  }
3001
3057
  [data-toolbar-settings-panel]::-webkit-scrollbar-track { background: transparent; }
3002
3058
  @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; }
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; }
3005
3061
  }
3006
3062
  [data-search-toggle] { color: #8b96ab !important; }
3007
3063
  [data-search-toggle][aria-pressed="true"] { background: var(--ms-accent-bg) !important; color: var(--ms-accent-soft) !important; }
@@ -3075,26 +3131,21 @@ window.__ModuleLoader__.load({
3075
3131
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
3076
3132
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
3077
3133
  */
3078
- function findRow(key) {
3079
- 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
+ }
3080
3141
  return null;
3081
3142
  }
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
3143
  /** Compact duration label (ms). */
3087
3144
  function formatDuration(ms) {
3088
3145
  if (ms < 1e3) return `${ms}ms`;
3089
3146
  if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
3090
3147
  return `${Math.floor(ms / 6e4)}m${Math.floor(ms % 6e4 / 1e3)}s`;
3091
3148
  }
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
3149
  /**
3099
3150
  * @param props - session standard kit (useSession, sessionId, useProjection),
3100
3151
  * the injected loadOlder/forkAt actions, the bookmarks store pair (useStore +
@@ -3103,52 +3154,28 @@ window.__ModuleLoader__.load({
3103
3154
  * `locale: 'dsh-milestone'`; defaults to a key-pass fallback for renders
3104
3155
  * outside the slot machinery).
3105
3156
  */
3106
- function MilestoneRail({ useSession, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
3157
+ function MilestoneRail({ useSession, useProjection, loadOlder, forkAt, useStore, actions, searchSessions = async () => ({
3107
3158
  items: [],
3108
3159
  hasMore: false
3109
3160
  }), 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);
3161
+ const projection = useProjection?.("milestone.messages");
3115
3162
  const hasMore = useSession((s) => s.hasMore);
3116
3163
  const loadingOlder = useSession((s) => s.loadingOlder);
3117
3164
  const bookmarkedKeys = useStore?.((s) => s.keys) ?? NO_BOOKMARKS;
3118
3165
  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]);
3136
- 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);
3147
- }
3148
- return result;
3149
- }, [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;
3150
3177
  const running = useSession((s) => s.running);
3151
- const awaitingInput = useSession((s) => s.pending).length > 0;
3178
+ const awaitingInput = useSession((s) => s.queue).length > 0;
3152
3179
  const [railBox, setRailBox] = (0, react.useState)(null);
3153
3180
  const [hover, setHover] = (0, react.useState)(null);
3154
3181
  const [search, setSearch] = (0, react.useState)({
@@ -3167,7 +3194,7 @@ window.__ModuleLoader__.load({
3167
3194
  const [collapsedTurns, setCollapsedTurns] = (0, react.useState)(/* @__PURE__ */ new Set());
3168
3195
  const [focusIndex, setFocusIndex] = (0, react.useState)(0);
3169
3196
  const listRef = (0, react.useRef)(null);
3170
- const currentKey = useCurrentAnchor(order);
3197
+ const currentKey = useCurrentAnchor(marks.map((m) => m.key));
3171
3198
  /**
3172
3199
  * P3: jump to the chat row with the given node key — smooth-scroll it into
3173
3200
  * view and write the position back into the URL hash (`#msg=<key>`) so
@@ -3177,8 +3204,8 @@ window.__ModuleLoader__.load({
3177
3204
  * rail's own updates. No-op when the row is not (yet) rendered — the
3178
3205
  * deep-link mount retry and the load-older flow cover that case.
3179
3206
  */
3180
- const jump = (key) => {
3181
- const row = findRow(key);
3207
+ const jump = (key, messageId) => {
3208
+ const row = findRow(key, messageId);
3182
3209
  if (row === null) return;
3183
3210
  row.scrollIntoView({
3184
3211
  behavior: "smooth",
@@ -3215,7 +3242,7 @@ window.__ModuleLoader__.load({
3215
3242
  }, [displayMarks]);
3216
3243
  const displayTurns = (0, react.useMemo)(() => buildDisplayTurns(marks), [marks]);
3217
3244
  const [prefs, setPrefs] = (0, react.useState)(() => loadPrefs());
3218
- const { pinned, accent, iconSize, inset, side } = prefs;
3245
+ const { pinned, accent, iconSize, inset, side, ballMode, ball } = prefs;
3219
3246
  const scale = iconSize / DOT_HIT;
3220
3247
  const hit = iconSize;
3221
3248
  const size = DOT_SIZE * scale;
@@ -3295,6 +3322,27 @@ window.__ModuleLoader__.load({
3295
3322
  /** B-design (0.6.3): the focus block mirrors the personalization block —
3296
3323
  * collapsed by default, the header leads with a live option summary. */
3297
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]);
3298
3346
  const settingsRef = (0, react.useRef)(null);
3299
3347
  const settingsBtnRef = (0, react.useRef)(null);
3300
3348
  const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
@@ -3396,11 +3444,12 @@ window.__ModuleLoader__.load({
3396
3444
  let timer;
3397
3445
  const attempt = (pollsLeft, canLoadOlder) => {
3398
3446
  if (cancelled) return;
3399
- if (findRow(key) !== null) {
3400
- jump(key);
3447
+ const mark = marksRef.current.find((m) => m.key === key);
3448
+ if (findRow(key, mark?.messageId) !== null) {
3449
+ jump(key, mark?.messageId);
3401
3450
  return;
3402
3451
  }
3403
- if (marksRef.current.length > 0 && !marksRef.current.some((m) => m.key === key)) return;
3452
+ if (marksRef.current.length > 0 && mark === void 0) return;
3404
3453
  if (pollsLeft > 0) {
3405
3454
  timer = window.setTimeout(() => attempt(pollsLeft - 1, canLoadOlder), DEEP_LINK_POLL_DELAY);
3406
3455
  return;
@@ -3422,7 +3471,8 @@ window.__ModuleLoader__.load({
3422
3471
  const onHashChange = () => {
3423
3472
  const key = parseDeepLinkHash(window.location.hash);
3424
3473
  if (key === null) return;
3425
- 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);
3426
3476
  };
3427
3477
  window.addEventListener("hashchange", onHashChange);
3428
3478
  return () => window.removeEventListener("hashchange", onHashChange);
@@ -3531,7 +3581,8 @@ window.__ModuleLoader__.load({
3531
3581
  ...s,
3532
3582
  activePos: next
3533
3583
  }));
3534
- jump(displayMarks[matches[next]].key);
3584
+ const mark = displayMarks[matches[next]];
3585
+ jump(mark.key, mark.messageId);
3535
3586
  };
3536
3587
  const onSearchKeyDown = (e) => {
3537
3588
  if (e.key === "Enter") advanceMatch();
@@ -3861,24 +3912,21 @@ window.__ModuleLoader__.load({
3861
3912
  const buildHover = (mark, index) => {
3862
3913
  if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
3863
3914
  if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
3864
- 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;
3865
3916
  let durationLabel = null;
3866
3917
  let reasonLabel = null;
3867
3918
  let ttftLabel = null;
3868
3919
  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`;
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`;
3879
3927
  }
3880
3928
  }
3881
- const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
3929
+ const meta = deriveTurnMetaFromProjection(turnMeta);
3882
3930
  const summaryCount = collapsedSummaries.get(mark.key);
3883
3931
  return {
3884
3932
  mark,
@@ -3943,6 +3991,157 @@ window.__ModuleLoader__.load({
3943
3991
  forkAt(mark.seq).then(() => setForkedKey(mark.key));
3944
3992
  };
3945
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
+ }
3946
4145
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3947
4146
  style: {
3948
4147
  position: "fixed",
@@ -4000,6 +4199,40 @@ window.__ModuleLoader__.load({
4000
4199
  },
4001
4200
  children: "···"
4002
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
+ }),
4003
4236
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4004
4237
  type: "button",
4005
4238
  "data-toolbar-expand": true,
@@ -4509,6 +4742,139 @@ window.__ModuleLoader__.load({
4509
4742
  ]
4510
4743
  })]
4511
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
+ }),
4512
4878
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4513
4879
  "data-settings-section": true,
4514
4880
  "data-focus-settings": true,
@@ -4907,7 +5273,7 @@ window.__ModuleLoader__.load({
4907
5273
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
4908
5274
  style: { color: "#8b96ab" },
4909
5275
  children: [t("update.current"), ": "]
4910
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.6" })] }),
5276
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.7.0" })] }),
4911
5277
  updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4912
5278
  "data-update-latest": true,
4913
5279
  children: [
@@ -5085,7 +5451,7 @@ window.__ModuleLoader__.load({
5085
5451
  top: rect.top + rect.height / 2
5086
5452
  });
5087
5453
  },
5088
- onClick: () => jump(mark.key),
5454
+ onClick: () => jump(mark.key, mark.messageId),
5089
5455
  "data-rail-dot": true,
5090
5456
  "data-turn-gap": showGroupGap ? "true" : void 0,
5091
5457
  "data-turn": showGroupGap && mark.turn !== void 0 ? mark.turn : void 0,
@@ -5200,13 +5566,18 @@ window.__ModuleLoader__.load({
5200
5566
  * resolved by the engine's `create(scopeKey)`). Consumers must call the
5201
5567
  * FACTORY (never a module-level handle — module-cache identity is a disguised
5202
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.
5203
5574
  */
5204
5575
  /**
5205
5576
  * Declare the bookmarks store handle. Returns a fresh handle per call; the
5206
5577
  * framework (or tests) create per-session instances via `create(scopeKey)`.
5207
5578
  */
5208
5579
  function createBookmarksStore() {
5209
- return (0, _deepseek_ai_dsh_client_runtime_client.defineStore)({
5580
+ return (0, _deepseek_ai_dsh_client_store.defineStore)({
5210
5581
  init: () => ({ keys: [] }),
5211
5582
  persist: "dsh-milestone.bookmarks",
5212
5583
  actions: {
@@ -5245,7 +5616,7 @@ window.__ModuleLoader__.load({
5245
5616
  return {
5246
5617
  items: result.value.items.map((item) => ({
5247
5618
  ...item,
5248
- title: byId[item.sessionId]?.displayTitle
5619
+ title: byId[item.sessionId]?.title
5249
5620
  })),
5250
5621
  hasMore: result.value.hasMore
5251
5622
  };
@@ -5314,6 +5685,7 @@ window.__ModuleLoader__.load({
5314
5685
  * @param ctx - client root context.
5315
5686
  */
5316
5687
  function apply(ctx) {
5688
+ const sessions = ctx.sessions;
5317
5689
  ctx.effect(() => ctx.locale.register("dsh-milestone", {
5318
5690
  zh,
5319
5691
  en
@@ -5332,10 +5704,10 @@ window.__ModuleLoader__.load({
5332
5704
  store: createBookmarksStore,
5333
5705
  locale: "dsh-milestone",
5334
5706
  inject: (sessionId) => ({
5335
- loadOlder: createLoadOlder(ctx.sessions, sessionId),
5336
- forkAt: createForkAt(ctx.sessions, sessionId),
5337
- searchSessions: createSessionSearch(ctx.sessions),
5338
- openSession: createOpenSession(ctx.sessions)
5707
+ loadOlder: createLoadOlder(sessions, sessionId),
5708
+ forkAt: createForkAt(sessions, sessionId),
5709
+ searchSessions: createSessionSearch(sessions),
5710
+ openSession: createOpenSession(sessions)
5339
5711
  })
5340
5712
  }, MilestoneRail));
5341
5713
  }