dsh-milestone 0.6.0 → 0.6.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 (3) hide show
  1. package/README.md +14 -0
  2. package/lib/client.js +1396 -195
  3. package/package.json +9 -9
package/lib/client.js CHANGED
@@ -503,13 +503,110 @@ window.__ModuleLoader__.load({
503
503
  };
504
504
  }
505
505
  //#endregion
506
+ //#region src/client/useOutsideDismiss.ts
507
+ /**
508
+ * useOutsideDismiss: the shared "click outside to dismiss" contract behind the
509
+ * rail's floating panels (in-rail search / all-prompts list / cross-session
510
+ * search). While a panel is open, a pointerdown anywhere OUTSIDE it calls the
511
+ * caller's close handler; a pointerdown inside the panel (or on an excluded
512
+ * element) is left untouched.
513
+ *
514
+ * Event choice — window `pointerdown`:
515
+ * - `pointerdown` unifies mouse / touch / pen, so dismissal works for every
516
+ * input the harness surfaces; a `mousedown`-only listener would miss
517
+ * touch taps entirely.
518
+ * - Closing on the DOWN side of the gesture feels instant — the panel is
519
+ * gone before the pointer is released, which is the expected behaviour
520
+ * for fixed floating layers.
521
+ *
522
+ * Lifecycle: the listener is attached ONLY while `open` and removed on close
523
+ * / unmount, so a closed panel never intercepts events and no listener leaks.
524
+ *
525
+ * Opening-gesture guard: hooks arm their listener in an effect, which React
526
+ * runs AFTER the toggle's pointerdown/click that opened the panel — that
527
+ * gesture can therefore never reach the listener by construction. As
528
+ * defense-in-depth the hook also records its arming time and drops any
529
+ * pointerdown whose `timeStamp` STRICTLY predates it (an event still in
530
+ * flight from the opening gesture). Same-tick and later events pass through,
531
+ * and synthetic test events with `timeStamp === 0` (which carry no real
532
+ * timestamp) are kept — the guard never swallows a legitimate dispatch.
533
+ *
534
+ * `options.exclude` names targets the caller keeps for its own handler — the
535
+ * panel's own toggle button is the canonical case: its click owns the
536
+ * open/close flip, so a pointerdown on it must NOT also dismiss through the
537
+ * hook, or clicking an armed toggle would close on pointerdown and re-open on
538
+ * click.
539
+ */
540
+ /**
541
+ * True when `target` is (or sits inside) an element matching `selector`.
542
+ * Panels use it to exclude their own rail-top toggle from dismissal (the
543
+ * toggle's data attribute is the established rail DOM contract, so the
544
+ * exclusion is just another access to it).
545
+ */
546
+ function outsideDismissMatches(target, selector) {
547
+ return target instanceof Element && target.closest(selector) !== null;
548
+ }
549
+ /**
550
+ * @param panelRef - the floating panel's root element (only mounted while open).
551
+ * @param open - whether the panel is open; the window listener arms only when true.
552
+ * @param onClose - called once per outside pointerdown. `undefined` keeps the
553
+ * hook inert — used while a call site still has no close path wired.
554
+ * @param options - optional exclusion predicate (see {@link OutsideDismissOptions}).
555
+ */
556
+ function useOutsideDismiss(panelRef, open, onClose, options) {
557
+ const onCloseRef = (0, react.useRef)(onClose);
558
+ const excludeRef = (0, react.useRef)(options?.exclude);
559
+ (0, react.useEffect)(() => {
560
+ onCloseRef.current = onClose;
561
+ excludeRef.current = options?.exclude;
562
+ });
563
+ (0, react.useEffect)(() => {
564
+ if (!open) return;
565
+ const armedAt = performance.now();
566
+ const onPointerDown = (e) => {
567
+ const onClose = onCloseRef.current;
568
+ if (onClose === void 0) return;
569
+ if (e.timeStamp > 0 && e.timeStamp < armedAt) return;
570
+ if (excludeRef.current?.(e.target)) return;
571
+ const panel = panelRef.current;
572
+ if (panel !== null && e.target instanceof Node && panel.contains(e.target)) return;
573
+ onClose();
574
+ };
575
+ window.addEventListener("pointerdown", onPointerDown);
576
+ return () => window.removeEventListener("pointerdown", onPointerDown);
577
+ }, [open, panelRef]);
578
+ }
579
+ //#endregion
506
580
  //#region src/client/MilestoneRailSearch.tsx
581
+ /**
582
+ * RailSearchUi: the in-rail search chrome (F1) — the magnifier toggle pinned
583
+ * to the rail's top and the compact search panel to its left (input, match
584
+ * counter, clear button).
585
+ *
586
+ * Pure presentation: it owns no state. MilestoneRail holds the search state
587
+ * and handlers and feeds them in as props, so the search lifecycle (query,
588
+ * match cycle, escape semantics) stays component-local in the rail. Splitting
589
+ * the chrome into its own file keeps the rail component under the size
590
+ * ceiling while the two still render one DOM contract
591
+ * (`data-search-toggle` / `data-rail-search` / `data-match-count` /
592
+ * `data-search-clear`).
593
+ *
594
+ * Outside dismissal: while the panel is open, a pointerdown anywhere outside
595
+ * it closes it (shared useOutsideDismiss hook). The toggle's own click keeps
596
+ * its flip semantics — a pointerdown on `[data-search-toggle]` is excluded
597
+ * from the hook and left to the rail's onToggle. Without a dedicated onClose
598
+ * prop the fallback is the toggle-off path (query retained, same as clicking
599
+ * the toggle), matching the rail's current call site; a dedicated onClose
600
+ * (clearSearch-equivalent) takes precedence once MilestoneRail feeds one.
601
+ */
507
602
  /** Dot diameter (px) — matches the rail's DOT_HIT so the toggle aligns. */
508
603
  const DOT_HIT$1 = 28;
509
604
  /**
510
605
  * @param props - the search state slice plus the rail's event handlers.
511
606
  */
512
- function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, t }) {
607
+ function RailSearchUi({ panelTop, panelRight, query, panelOpen, matches, total, onToggle, onQueryChange, onSearchKeyDown, onClear, onClose, t }) {
608
+ const panelRef = (0, react.useRef)(null);
609
+ useOutsideDismiss(panelRef, panelOpen, onClose ?? (() => onToggle()), { exclude: (target) => outsideDismissMatches(target, "[data-search-toggle]") });
513
610
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
514
611
  type: "button",
515
612
  "data-search-toggle": true,
@@ -545,6 +642,7 @@ window.__ModuleLoader__.load({
545
642
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m21 21-4.3-4.3" })]
546
643
  })
547
644
  }), panelOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
645
+ ref: panelRef,
548
646
  style: {
549
647
  position: "fixed",
550
648
  top: panelTop,
@@ -631,10 +729,33 @@ window.__ModuleLoader__.load({
631
729
  //#endregion
632
730
  //#region src/client/MilestoneListPanel.tsx
633
731
  /**
732
+ * MilestoneListPanel: the expandable all-prompts panel (P3) — the compact
733
+ * list chrome pinned to the rail's top that enumerates EVERY user-prompt
734
+ * milestone (序号 + turn + preview), independent of the search/bookmarks
735
+ * filters. Clicking an entry jumps to that message through the rail's own
736
+ * `jump` handler (the same path the dots use).
737
+ *
738
+ * Pure presentation: it owns no state. MilestoneRail holds the `listOpen`
739
+ * boolean (the toggle and the Escape/close semantics live there) and feeds
740
+ * the panel its anchor, the full marks array, and the jump handler as props
741
+ * — same split as RailSearchUi. Renders one DOM contract
742
+ * (`data-milestone-list` root / `data-list-item` rows with `data-jump-key`).
743
+ *
744
+ * Outside dismissal: while the panel is mounted (it only renders while open),
745
+ * a pointerdown anywhere outside it calls the rail-fed `onClose` (shared
746
+ * useOutsideDismiss hook; the toggle's own click keeps its flip semantics
747
+ * through a `[data-list-toggle]` exclusion). MilestoneRail.tsx does NOT pass
748
+ * onClose in the current tree (its owner is wiring it separately) — until
749
+ * then the hook is inert and the panel keeps its existing behaviour.
750
+ */
751
+ /**
634
752
  * @param props - the panel anchor, the full marks array, and the rail's jump handler.
635
753
  */
636
- function MilestoneListPanel({ panelTop, panelRight, marks, onJump, t }) {
754
+ function MilestoneListPanel({ panelTop, panelRight, marks, onJump, onClose, t }) {
755
+ const panelRef = (0, react.useRef)(null);
756
+ useOutsideDismiss(panelRef, true, onClose, { exclude: (target) => outsideDismissMatches(target, "[data-list-toggle]") });
637
757
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
758
+ ref: panelRef,
638
759
  "data-milestone-list": true,
639
760
  style: {
640
761
  position: "fixed",
@@ -944,6 +1065,11 @@ window.__ModuleLoader__.load({
944
1065
  * (`data-session-search` root / `data-session-search-input` /
945
1066
  * `data-session-search-result` rows / `data-session-search-error` /
946
1067
  * `data-session-search-more`).
1068
+ *
1069
+ * Outside dismissal: while the panel is mounted (it only renders while open),
1070
+ * a pointerdown anywhere outside it calls the rail-fed `onClose` (shared
1071
+ * useOutsideDismiss hook; the toggle's own click keeps its flip semantics
1072
+ * through a `[data-session-search-toggle]` exclusion).
947
1073
  */
948
1074
  /** Debounce window for the cross-session query (ms). */
949
1075
  const SEARCH_DEBOUNCE_MS = 250;
@@ -955,6 +1081,8 @@ window.__ModuleLoader__.load({
955
1081
  const [status, setStatus] = (0, react.useState)("idle");
956
1082
  const [hits, setHits] = (0, react.useState)([]);
957
1083
  const [hasMore, setHasMore] = (0, react.useState)(false);
1084
+ const panelRef = (0, react.useRef)(null);
1085
+ useOutsideDismiss(panelRef, true, onClose, { exclude: (target) => outsideDismissMatches(target, "[data-session-search-toggle]") });
958
1086
  (0, react.useEffect)(() => {
959
1087
  const trimmed = query.trim();
960
1088
  if (trimmed === "") {
@@ -983,6 +1111,7 @@ window.__ModuleLoader__.load({
983
1111
  if (e.key === "Escape") onClose();
984
1112
  };
985
1113
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1114
+ ref: panelRef,
986
1115
  "data-session-search": true,
987
1116
  style: {
988
1117
  position: "fixed",
@@ -1169,6 +1298,416 @@ window.__ModuleLoader__.load({
1169
1298
  return current;
1170
1299
  }
1171
1300
  //#endregion
1301
+ //#region src/client/toolbar-prefs.ts
1302
+ /**
1303
+ * toolbar-prefs: the persistence layer for the milestone rail's collapsible
1304
+ * toolbar "pinned outside the collapse" preferences.
1305
+ *
1306
+ * Storage contract: one localStorage key (`dsh-milestone.toolbar`) holding
1307
+ * `{ "pinned": string[] }` — the ids of the function-key features the user
1308
+ * wants to keep visible while the toolbar is COLLAPSED. An absent or corrupt
1309
+ * value degrades to `[]` (everything folded away).
1310
+ *
1311
+ * All reads are sanitized: ids are checked against the canonical whitelist
1312
+ * (`TOOLBAR_PIN_IDS`), duplicates are dropped, unknown ids are discarded, and
1313
+ * the pinned order is the caller's first-seen order (the order ids were
1314
+ * pinned) — a future feature removal therefore never leaves a stale id
1315
+ * behind, and a hand-edited value can never smuggle an out-of-registry id
1316
+ * into render state.
1317
+ *
1318
+ * The whitelist lives HERE (not in MilestoneRail) so the pure functions stay
1319
+ * dependency-free and unit-testable; MilestoneRail's feature registry keys
1320
+ * itself against the same `ToolbarPinId` type, so id drift is a compile error.
1321
+ */
1322
+ /** The single localStorage key holding the toolbar preference blob. */
1323
+ const TOOLBAR_PREFS_KEY = "dsh-milestone.toolbar";
1324
+ /**
1325
+ * Canonical function-key ids that may be pinned outside the collapse, in
1326
+ * toolbar render order. Adding a feature here (plus its registry entry in
1327
+ * MilestoneRail) is the whole "pin it" extension point.
1328
+ */
1329
+ const TOOLBAR_PIN_IDS = [
1330
+ "search",
1331
+ "list",
1332
+ "sessionSearch",
1333
+ "bookmarks",
1334
+ "focus",
1335
+ "updateCheck"
1336
+ ];
1337
+ /** Type guard for registry ids — unknown strings never survive a parse. */
1338
+ function isToolbarPinId(id) {
1339
+ return TOOLBAR_PIN_IDS.includes(id);
1340
+ }
1341
+ /**
1342
+ * Parse + sanitize the raw persisted blob: `null` (nothing stored), invalid
1343
+ * JSON, or a non-`{pinned: string[]}` shape all degrade to `[]`; unknown ids
1344
+ * and duplicates are dropped; the result keeps the caller's first-seen
1345
+ * (pin) order.
1346
+ */
1347
+ function parsePrefs(raw) {
1348
+ if (raw === null) return [];
1349
+ let parsed;
1350
+ try {
1351
+ parsed = JSON.parse(raw);
1352
+ } catch {
1353
+ return [];
1354
+ }
1355
+ if (typeof parsed !== "object" || parsed === null) return [];
1356
+ const { pinned } = parsed;
1357
+ if (!Array.isArray(pinned)) return [];
1358
+ const seen = /* @__PURE__ */ new Set();
1359
+ const result = [];
1360
+ for (const id of pinned) {
1361
+ if (typeof id !== "string" || !isToolbarPinId(id) || seen.has(id)) continue;
1362
+ seen.add(id);
1363
+ result.push(id);
1364
+ }
1365
+ return result;
1366
+ }
1367
+ /**
1368
+ * Read + sanitize the persisted toolbar prefs from localStorage. Degrades to
1369
+ * `[]` when storage is unavailable (SSR, sandboxed iframe) — pinning is a
1370
+ * best-effort enhancement, never a render blocker.
1371
+ */
1372
+ function loadPrefs() {
1373
+ try {
1374
+ return parsePrefs(localStorage.getItem(TOOLBAR_PREFS_KEY));
1375
+ } catch {
1376
+ return [];
1377
+ }
1378
+ }
1379
+ /**
1380
+ * Persist the pinned ids (sanitized on the way out so a corrupt in-memory
1381
+ * value is never written). Takes plain strings — it cannot trust its input
1382
+ * any more than it trusts localStorage, so unknown ids are dropped, not
1383
+ * rejected. Swallows storage failures for the same best-effort reason as
1384
+ * {@link loadPrefs}.
1385
+ */
1386
+ function savePrefs(pinned) {
1387
+ const cleaned = parsePrefs(JSON.stringify({ pinned: [...pinned] }));
1388
+ try {
1389
+ localStorage.setItem(TOOLBAR_PREFS_KEY, JSON.stringify({ pinned: cleaned }));
1390
+ } catch {}
1391
+ }
1392
+ /**
1393
+ * Pure toggle: adds `id` to the pinned set when absent, removes it when
1394
+ * present. Unknown ids are ignored (the set is returned unchanged) and the
1395
+ * result is always deduped via the parse path, so callers can feed state
1396
+ * straight back into {@link savePrefs}.
1397
+ */
1398
+ function togglePin(pinned, id) {
1399
+ if (!isToolbarPinId(id)) return [...pinned];
1400
+ const next = new Set(pinned);
1401
+ if (next.has(id)) next.delete(id);
1402
+ else next.add(id);
1403
+ return parsePrefs(JSON.stringify({ pinned: [...next] }));
1404
+ }
1405
+ //#endregion
1406
+ //#region src/client/version-logic.ts
1407
+ /**
1408
+ * Pure update-check logic for dsh-milestone, deliberately free of React, the
1409
+ * harness runtime, and any node-only API so it runs identically in the browser
1410
+ * bundle, vitest (jsdom), and future shells.
1411
+ *
1412
+ * Design intent:
1413
+ * - `compareVersions` / `needsUpdate` are pure, side-effect-free functions that
1414
+ * follow npm semver precedence for the shapes this plugin actually publishes
1415
+ * (`x.y.z` plus optional `-rc.N` / `-beta.N` prereleases). Anything else is
1416
+ * an explicit error rather than a silent mis-comparison.
1417
+ * - `fetchLatestVersion` is the only I/O: it reads the `latest` dist-tag from
1418
+ * npmmirror's cheap dist-tags endpoint first (it CORS-echoes this page's
1419
+ * Origin), then falls back to the full npm packument (ACAO: *). Every
1420
+ * request carries an internal 8s timeout combined with the caller's signal —
1421
+ * whichever aborts first wins — and every failure degrades to a structured
1422
+ * `{ ok: false }` result, never a throw.
1423
+ * - `SUPPORTED_HOST_LINES` is a SELF-DECLARED compatibility list, not a probe:
1424
+ * the harness reports no trustworthy host version in the browser
1425
+ * (`host.describe().version` is a stub), so the plugin declares which npm
1426
+ * `latest` host-version lines it supports and the UI renders that as-is.
1427
+ */
1428
+ /** Host-version lines this plugin declares support for (npm official latest
1429
+ * line; bump it as the peer/dependency ranges move). */
1430
+ const SUPPORTED_HOST_LINES = ["0.1.1-rc.2"];
1431
+ /** Internal per-request timeout for the network attempts. */
1432
+ const REQUEST_TIMEOUT_MS = 8e3;
1433
+ /** npmmirror dist-tags endpoint: lean JSON (`{"latest":"0.6.0", ...}`),
1434
+ * echoes this page's Origin in `Access-Control-Allow-Origin`. */
1435
+ const NPMIRROR_DIST_TAGS_URL = "https://registry.npmmirror.com/-/package/dsh-milestone/dist-tags";
1436
+ /** npm full packument; `dist-tags.latest` sits at the JSON root, and the
1437
+ * endpoint sends `Access-Control-Allow-Origin: *`. */
1438
+ const NPM_PACKUMENT_URL = "https://registry.npmjs.org/dsh-milestone";
1439
+ /** Numeric identifier test shared by core and prerelease segments. */
1440
+ const NUMERIC_RE = /^\d+$/;
1441
+ /**
1442
+ * Parse a version string into comparable parts.
1443
+ * Accepts `major[.minor[.patch]][-prerelease][+build]`; missing core segments
1444
+ * pad with 0 and `+build` metadata is ignored (per semver precedence).
1445
+ * @throws {Error} with the offending input when the shape is not parseable.
1446
+ */
1447
+ function parseVersion(input) {
1448
+ if (typeof input !== "string" || input.trim() === "") throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (expected "x.y.z" with optional "-pre" suffix)`);
1449
+ const s = input.trim();
1450
+ const plus = s.indexOf("+");
1451
+ const withoutBuild = plus === -1 ? s : s.slice(0, plus);
1452
+ const dash = withoutBuild.indexOf("-");
1453
+ const corePart = dash === -1 ? withoutBuild : withoutBuild.slice(0, dash);
1454
+ const prePart = dash === -1 ? void 0 : withoutBuild.slice(dash + 1);
1455
+ const segments = corePart.split(".");
1456
+ if (segments.length < 1 || segments.length > 3 || !segments.every((seg) => NUMERIC_RE.test(seg))) throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (expected "x.y.z" with optional "-pre" suffix)`);
1457
+ const core = segments.map(Number);
1458
+ let pre = [];
1459
+ if (prePart !== void 0) {
1460
+ if (prePart === "" || !prePart.split(".").every((id) => /^[0-9A-Za-z-]+$/.test(id))) throw new Error(`Invalid semantic version: ${JSON.stringify(input)} (bad prerelease suffix)`);
1461
+ pre = prePart.split(".");
1462
+ }
1463
+ return {
1464
+ core,
1465
+ pre
1466
+ };
1467
+ }
1468
+ /**
1469
+ * Compare two version strings per npm semver precedence.
1470
+ * @param a - first version (`x.y.z` with optional `-pre` suffix; build
1471
+ * metadata ignored; missing core segments pad with 0).
1472
+ * @param b - second version, same grammar.
1473
+ * @returns -1 when `a < b`, 0 when equal, 1 when `a > b`. Prereleases sort
1474
+ * below their same-number release; prerelease identifiers compare numerically
1475
+ * when both are numeric, lexically when both are alphanumeric, and numeric
1476
+ * identifiers always sort before alphanumeric ones.
1477
+ * @throws {Error} for inputs that do not match the grammar.
1478
+ */
1479
+ function compareVersions(a, b) {
1480
+ const pa = parseVersion(a);
1481
+ const pb = parseVersion(b);
1482
+ for (let i = 0; i < 3; i++) {
1483
+ const x = pa.core[i] ?? 0;
1484
+ const y = pb.core[i] ?? 0;
1485
+ if (x < y) return -1;
1486
+ if (x > y) return 1;
1487
+ }
1488
+ if (pa.pre.length === 0 && pb.pre.length === 0) return 0;
1489
+ if (pa.pre.length === 0) return 1;
1490
+ if (pb.pre.length === 0) return -1;
1491
+ const len = Math.max(pa.pre.length, pb.pre.length);
1492
+ for (let i = 0; i < len; i++) {
1493
+ const x = pa.pre[i];
1494
+ const y = pb.pre[i];
1495
+ if (x === void 0) return -1;
1496
+ if (y === void 0) return 1;
1497
+ const xNum = NUMERIC_RE.test(x);
1498
+ const yNum = NUMERIC_RE.test(y);
1499
+ if (xNum && yNum) {
1500
+ if (x !== y) return Number(x) < Number(y) ? -1 : 1;
1501
+ } else if (xNum) return -1;
1502
+ else if (yNum) return 1;
1503
+ else if (x !== y) return x < y ? -1 : 1;
1504
+ }
1505
+ return 0;
1506
+ }
1507
+ /**
1508
+ * Whether the installed plugin should offer an update.
1509
+ * @param current - installed version.
1510
+ * @param latest - newest published version.
1511
+ * @returns true only when `latest` is strictly greater than `current`
1512
+ * (identical versions, or a newer installed version, return false).
1513
+ * @throws {Error} when either input is not a parseable version.
1514
+ */
1515
+ function needsUpdate(current, latest) {
1516
+ return compareVersions(current, latest) < 0;
1517
+ }
1518
+ /**
1519
+ * Build a request signal: a fresh AbortController aborted by EITHER the
1520
+ * caller's external signal (which wins when it fires first) OR an internal
1521
+ * timeout. Uses `AbortSignal.timeout` when the environment provides it and
1522
+ * falls back to a manual `setTimeout` + abort otherwise (jsdom older
1523
+ * versions expose no `AbortSignal.timeout`).
1524
+ * @param external - caller signal; when already aborted the request fires
1525
+ * immediately with an aborted signal.
1526
+ * @param timeoutMs - internal timeout in ms.
1527
+ * @returns the combined signal plus a cleanup that detaches all listeners
1528
+ * (must be called so no listener outlives the request).
1529
+ */
1530
+ function createRequestSignal(external, timeoutMs) {
1531
+ const controller = new AbortController();
1532
+ const abort = () => controller.abort();
1533
+ const detach = [];
1534
+ if (external) if (external.aborted) controller.abort();
1535
+ else {
1536
+ external.addEventListener("abort", abort, { once: true });
1537
+ detach.push(() => external.removeEventListener("abort", abort));
1538
+ }
1539
+ if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
1540
+ const t = AbortSignal.timeout(timeoutMs);
1541
+ t.addEventListener("abort", abort, { once: true });
1542
+ detach.push(() => t.removeEventListener("abort", abort));
1543
+ } else {
1544
+ const timer = setTimeout(abort, timeoutMs);
1545
+ detach.push(() => clearTimeout(timer));
1546
+ }
1547
+ return {
1548
+ signal: controller.signal,
1549
+ cleanup: () => {
1550
+ for (const fn of detach) fn();
1551
+ detach.length = 0;
1552
+ }
1553
+ };
1554
+ }
1555
+ /** Extract `latest` from the npmmirror dist-tags JSON root. */
1556
+ function extractFromDistTags(data) {
1557
+ if (data === null || typeof data !== "object") return null;
1558
+ const latest = data.latest;
1559
+ return typeof latest === "string" && latest.length > 0 ? latest : null;
1560
+ }
1561
+ /** Extract `latest` from the npm packument's root `dist-tags` object. */
1562
+ function extractFromPackument(data) {
1563
+ if (data === null || typeof data !== "object") return null;
1564
+ const distTags = data["dist-tags"];
1565
+ if (distTags === null || typeof distTags !== "object") return null;
1566
+ const latest = distTags.latest;
1567
+ return typeof latest === "string" && latest.length > 0 ? latest : null;
1568
+ }
1569
+ /** Human-readable failure detail for one endpoint attempt. */
1570
+ function describeError(source, cause) {
1571
+ const name = cause instanceof Error ? cause.name : "";
1572
+ const message = cause instanceof Error ? cause.message : String(cause);
1573
+ if (name === "AbortError") return `${source}: aborted (${message})`;
1574
+ return `${source}: ${message}`;
1575
+ }
1576
+ /**
1577
+ * One attempt at fetching the `latest` dist-tag from an endpoint.
1578
+ * @returns ok with the tag, or ok:false with a per-source error description.
1579
+ */
1580
+ async function tryFetchLatest(url, extract, source, external) {
1581
+ try {
1582
+ const { signal, cleanup } = createRequestSignal(external, REQUEST_TIMEOUT_MS);
1583
+ try {
1584
+ const res = await fetch(url, { signal });
1585
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
1586
+ const latest = extract(await res.json());
1587
+ if (latest === null) throw new Error("unexpected response shape (no \"latest\" dist-tag)");
1588
+ return {
1589
+ ok: true,
1590
+ latest,
1591
+ source
1592
+ };
1593
+ } finally {
1594
+ cleanup();
1595
+ }
1596
+ } catch (cause) {
1597
+ return {
1598
+ ok: false,
1599
+ error: describeError(source, cause)
1600
+ };
1601
+ }
1602
+ }
1603
+ /**
1604
+ * Query npm for the newest published version of `dsh-milestone`.
1605
+ *
1606
+ * Strategy: npmmirror's dist-tags endpoint first (lightweight and CORS-open
1607
+ * to this Origin); if that fails or its shape is wrong, the full npm
1608
+ * packument fallback (`dist-tags.latest` at the JSON root). Each request
1609
+ * aborts after 8s via `AbortSignal.timeout` (manual timer fallback when the
1610
+ * environment lacks it) OR immediately when the passed-in signal aborts.
1611
+ * @param signal - optional caller abort signal; aborts the in-flight attempt
1612
+ * with priority over the internal timeout.
1613
+ * @returns the latest version and which registry answered, or a structured
1614
+ * error when both endpoints fail. Never throws.
1615
+ */
1616
+ async function fetchLatestVersion(signal) {
1617
+ const viaMirror = await tryFetchLatest(NPMIRROR_DIST_TAGS_URL, extractFromDistTags, "npmmirror", signal);
1618
+ if (viaMirror.ok) return viaMirror;
1619
+ const viaNpm = await tryFetchLatest(NPM_PACKUMENT_URL, extractFromPackument, "npm", signal);
1620
+ if (viaNpm.ok) return viaNpm;
1621
+ return {
1622
+ ok: false,
1623
+ error: `${viaMirror.error}; ${viaNpm.error}`
1624
+ };
1625
+ }
1626
+ /** localStorage key holding the last successful update check. */
1627
+ const UPDATE_CACHE_KEY = "dsh-milestone.update-cache";
1628
+ /**
1629
+ * Pure parse + freshness check of a stored blob: `null` for `null`/invalid
1630
+ * JSON, a wrong shape, or an entry older than {@link UPDATE_CACHE_TTL_MS}.
1631
+ * A `checkedAt` in the future (clock skew) is treated as fresh — it decays
1632
+ * naturally once wall time catches up. Never throws.
1633
+ * @param raw - the raw `localStorage` value (or null when absent).
1634
+ * @param now - wall-clock epoch ms to judge freshness against (injectable for
1635
+ * tests; defaults to `Date.now()`).
1636
+ */
1637
+ function parseUpdateCache(raw, now = Date.now()) {
1638
+ if (raw === null) return null;
1639
+ let parsed;
1640
+ try {
1641
+ parsed = JSON.parse(raw);
1642
+ } catch {
1643
+ return null;
1644
+ }
1645
+ if (typeof parsed !== "object" || parsed === null) return null;
1646
+ const { latest, source, checkedAt } = parsed;
1647
+ if (typeof latest !== "string" || latest === "") return null;
1648
+ if (source !== "npmmirror" && source !== "npm") return null;
1649
+ if (typeof checkedAt !== "number" || !Number.isFinite(checkedAt)) return null;
1650
+ if (now - checkedAt >= 216e5) return null;
1651
+ return {
1652
+ latest,
1653
+ source,
1654
+ checkedAt
1655
+ };
1656
+ }
1657
+ /**
1658
+ * Read + parse the persisted cache entry from localStorage. Any storage
1659
+ * failure degrades to `null` (cache miss) — the update check is best-effort.
1660
+ * @param now - wall-clock epoch ms for freshness (see {@link parseUpdateCache}).
1661
+ */
1662
+ function readUpdateCache(now = Date.now()) {
1663
+ try {
1664
+ return parseUpdateCache(localStorage.getItem(UPDATE_CACHE_KEY), now);
1665
+ } catch {
1666
+ return null;
1667
+ }
1668
+ }
1669
+ /**
1670
+ * Persist a successful check result (the caller supplies `checkedAt`, usually
1671
+ * `Date.now()`). Silently ignores storage failures — the cache is an
1672
+ * optimization, never a hard dependency.
1673
+ */
1674
+ function writeUpdateCache(entry) {
1675
+ try {
1676
+ localStorage.setItem(UPDATE_CACHE_KEY, JSON.stringify(entry));
1677
+ } catch {}
1678
+ }
1679
+ /**
1680
+ * The cache-aware entry point for the UI: reuse an unexpired cached result
1681
+ * when one exists, otherwise query npm (npmmirror → packument fallback) and
1682
+ * persist a successful result for the next {@link UPDATE_CACHE_TTL_MS}.
1683
+ * Never throws; failures return the same structured `{ ok: false }` result as
1684
+ * {@link fetchLatestVersion}.
1685
+ * @param signal - optional caller abort signal, forwarded to the network
1686
+ * attempt only (a cache hit needs no signal).
1687
+ */
1688
+ async function loadCachedLatest(signal) {
1689
+ const cached = readUpdateCache();
1690
+ if (cached !== null) return {
1691
+ ok: true,
1692
+ latest: cached.latest,
1693
+ source: cached.source
1694
+ };
1695
+ const fresh = await fetchLatestVersion(signal);
1696
+ if (fresh.ok) writeUpdateCache({
1697
+ latest: fresh.latest,
1698
+ source: fresh.source,
1699
+ checkedAt: Date.now()
1700
+ });
1701
+ return fresh;
1702
+ }
1703
+ //#endregion
1704
+ //#region src/client/version-meta.ts
1705
+ /**
1706
+ * Installed plugin version. Injected at build time as
1707
+ * `__DSH_MILESTONE_VERSION__`; falls back to `0.0.0-dev` when unbuilt.
1708
+ */
1709
+ const PLUGIN_VERSION = "0.6.1";
1710
+ //#endregion
1172
1711
  //#region src/client/MilestoneRail.tsx
1173
1712
  /**
1174
1713
  * MilestoneRail: the milestone.rail entry (session scope). Renders a fixed
@@ -1252,6 +1791,26 @@ window.__ModuleLoader__.load({
1252
1791
  const DEEP_LINK_MAX_POLLS = 5;
1253
1792
  /** P3: bounded polls after `loadOlder`, then the deep link gives up silently. */
1254
1793
  const DEEP_LINK_MAX_RETRY_POLLS = 5;
1794
+ /** B4 update-check: mount-time silent check delay (ms) — give the harness
1795
+ * time to settle before hitting the registry. */
1796
+ const UPDATE_CHECK_MOUNT_DELAY = 1500;
1797
+ /** Initial state: no check has completed yet, nothing to show. */
1798
+ const NO_UPDATE_CHECK = {
1799
+ phase: "idle",
1800
+ latest: null,
1801
+ source: null,
1802
+ error: null,
1803
+ available: false
1804
+ };
1805
+ /**
1806
+ * B4 display label for one supported host line: strips the fixed
1807
+ * `x.y.z` prefix and appends "line" — `0.1.1-rc.2` → `rc.2 line`,
1808
+ * `0.1.1` → `0.1.1 line`. Pure presentation metadata.
1809
+ */
1810
+ function hostLineLabel(line) {
1811
+ const suffix = line.replace(/^\d+\.\d+\.\d+-?/, "");
1812
+ return suffix === "" ? `${line} line` : `${suffix} line`;
1813
+ }
1255
1814
  /**
1256
1815
  * Find a chat row by its node key, avoiding CSS.escape pitfalls on keys that
1257
1816
  * contain `<`/`>`/`:` (the node key is `13:input-message<messageId>`).
@@ -1429,6 +1988,88 @@ window.__ModuleLoader__.load({
1429
1988
  window.addEventListener("keydown", onKey);
1430
1989
  return () => window.removeEventListener("keydown", onKey);
1431
1990
  }, [listOpen, crossOpen]);
1991
+ const [toolbarExpanded, setToolbarExpanded] = (0, react.useState)(false);
1992
+ const [settingsOpen, setSettingsOpen] = (0, react.useState)(false);
1993
+ const [pinned, setPinned] = (0, react.useState)(() => loadPrefs());
1994
+ const settingsRef = (0, react.useRef)(null);
1995
+ const settingsBtnRef = (0, react.useRef)(null);
1996
+ const [updateOpen, setUpdateOpen] = (0, react.useState)(false);
1997
+ const [updateCheck, setUpdateCheck] = (0, react.useState)(NO_UPDATE_CHECK);
1998
+ const updatePanelRef = (0, react.useRef)(null);
1999
+ const updateBtnRef = (0, react.useRef)(null);
2000
+ /**
2001
+ * B1 settings menu: outside-pointerdown dismisses the menu (shared
2002
+ * useOutsideDismiss contract) with focus returning to the gear afterwards.
2003
+ * The gear's own click keeps its flip semantics through a
2004
+ * `[data-toolbar-settings]` exclusion — pointerdown on an armed gear must
2005
+ * not double-close.
2006
+ */
2007
+ useOutsideDismiss(settingsRef, settingsOpen, () => {
2008
+ setSettingsOpen(false);
2009
+ settingsBtnRef.current?.focus();
2010
+ }, { exclude: (target) => outsideDismissMatches(target, "[data-toolbar-settings]") });
2011
+ (0, react.useEffect)(() => {
2012
+ if (!settingsOpen) return;
2013
+ const onKey = (e) => {
2014
+ if (e.key !== "Escape") return;
2015
+ setSettingsOpen(false);
2016
+ settingsBtnRef.current?.focus();
2017
+ };
2018
+ window.addEventListener("keydown", onKey);
2019
+ return () => window.removeEventListener("keydown", onKey);
2020
+ }, [settingsOpen]);
2021
+ /**
2022
+ * B4: run one update check. Cache-aware (`loadCachedLatest` reuses an
2023
+ * unexpired cached result without any network traffic) and never throws:
2024
+ * a network failure lands in the `failed` phase with the structured error,
2025
+ * and an unparseable `latest` (registry anomaly) is treated as "not
2026
+ * available" rather than crashing the panel.
2027
+ */
2028
+ const runUpdateCheck = () => {
2029
+ setUpdateCheck((prev) => ({
2030
+ ...prev,
2031
+ phase: "checking"
2032
+ }));
2033
+ loadCachedLatest().then((result) => {
2034
+ if (result.ok) {
2035
+ let available = false;
2036
+ try {
2037
+ available = needsUpdate(PLUGIN_VERSION, result.latest);
2038
+ } catch {
2039
+ available = false;
2040
+ }
2041
+ setUpdateCheck({
2042
+ phase: "ok",
2043
+ latest: result.latest,
2044
+ source: result.source,
2045
+ error: null,
2046
+ available
2047
+ });
2048
+ } else setUpdateCheck((prev) => ({
2049
+ ...prev,
2050
+ phase: "failed",
2051
+ error: result.error
2052
+ }));
2053
+ });
2054
+ };
2055
+ (0, react.useEffect)(() => {
2056
+ const timer = window.setTimeout(runUpdateCheck, UPDATE_CHECK_MOUNT_DELAY);
2057
+ return () => window.clearTimeout(timer);
2058
+ }, []);
2059
+ useOutsideDismiss(updatePanelRef, updateOpen, () => {
2060
+ setUpdateOpen(false);
2061
+ updateBtnRef.current?.focus();
2062
+ }, { exclude: (target) => outsideDismissMatches(target, "[data-update-check]") });
2063
+ (0, react.useEffect)(() => {
2064
+ if (!updateOpen) return;
2065
+ const onKey = (e) => {
2066
+ if (e.key !== "Escape") return;
2067
+ setUpdateOpen(false);
2068
+ updateBtnRef.current?.focus();
2069
+ };
2070
+ window.addEventListener("keydown", onKey);
2071
+ return () => window.removeEventListener("keydown", onKey);
2072
+ }, [updateOpen]);
1432
2073
  const marksRef = (0, react.useRef)(marks);
1433
2074
  (0, react.useEffect)(() => {
1434
2075
  marksRef.current = marks;
@@ -1507,150 +2148,104 @@ window.__ModuleLoader__.load({
1507
2148
  if (e.key === "Enter") advanceMatch();
1508
2149
  if (e.key === "Escape") closeSearch();
1509
2150
  };
1510
- /** Focus the dot at `index` (no-op while the list is unmounted). */
1511
- const focusDotAt = (index) => {
1512
- listRef.current?.querySelectorAll("[data-rail-dot]")[index]?.focus();
1513
- };
1514
- /** Tab lands on the list itself: hand focus to the dot owning the tab stop. */
1515
- const onListFocus = (e) => {
1516
- if (e.target !== e.currentTarget) return;
1517
- focusDotAt(clampIndex(focusIndex, render.items.length));
1518
- };
1519
- /**
1520
- * Roving-tabindex keys: ArrowDown/ArrowUp move focus (wrapping), Home/End
1521
- * jump to first/last. Enter/Space are deliberately NOT handled — the dots
1522
- * are real buttons, so native activation fires the jump click untouched
1523
- * (preventDefault here would swallow it). The rover counts RENDERED dots
1524
- * (collapsed turns shrink the list).
1525
- */
1526
- const onListKeyDown = (e) => {
1527
- const count = render.items.length;
1528
- let next = null;
1529
- switch (e.key) {
1530
- case "ArrowDown":
1531
- next = nextFocusIndex(focusIndex, count, 1);
1532
- break;
1533
- case "ArrowUp":
1534
- next = nextFocusIndex(focusIndex, count, -1);
1535
- break;
1536
- case "Home":
1537
- next = 0;
1538
- break;
1539
- case "End":
1540
- next = count - 1;
1541
- break;
1542
- default: return;
1543
- }
1544
- e.preventDefault();
1545
- const target = clampIndex(next, count);
1546
- setFocusIndex(target);
1547
- focusDotAt(target);
1548
- };
1549
- const buildHover = (mark, index) => {
1550
- if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
1551
- if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
1552
- const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
1553
- let durationLabel = null;
1554
- let reasonLabel = null;
1555
- let ttftLabel = null;
1556
- let tpsLabel = null;
1557
- if (turn !== void 0) {
1558
- if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
1559
- if (turn.end !== void 0) {
1560
- const reason = turn.end.data.reason;
1561
- if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
1562
- }
1563
- const tail = turnTailOf(turn);
1564
- if (tail !== void 0) {
1565
- if (tail.ttftMs !== void 0) ttftLabel = formatDuration(tail.ttftMs);
1566
- if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
1567
- }
1568
- }
1569
- const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
1570
- return {
1571
- mark,
1572
- index,
1573
- total: displayMarks.length,
1574
- turnLabel: mark.turn !== void 0 ? t("turn.label", { n: mark.turn }) : null,
1575
- durationLabel,
1576
- reasonLabel,
1577
- ttftLabel,
1578
- tpsLabel,
1579
- modelLabel: meta.model,
1580
- purposeLabel: meta.purpose,
1581
- tokensLabel: meta.inputTokens !== null && meta.outputTokens !== null ? `${meta.inputTokens} / ${meta.outputTokens} tok` : null,
1582
- turnMarkCount: mark.turn !== void 0 ? turnMarkCounts.get(mark.turn) ?? 0 : null
1583
- };
1584
- };
1585
- /**
1586
- * C4: collapse/expand the hovered mark's turn in the rail. The set is
1587
- * replaced immutably (a turn toggles out when already present); collapsing
1588
- * keeps the turn's LAST mark visible via buildRenderList.
1589
- */
1590
- const onToggleCollapse = (turn) => {
1591
- setCollapsedTurns((prev) => {
1592
- const next = new Set(prev);
1593
- if (next.has(turn)) next.delete(turn);
1594
- else next.add(turn);
2151
+ /** B1: flip one feature's pin — state and the persisted blob update together. */
2152
+ const onTogglePin = (id) => {
2153
+ setPinned((prev) => {
2154
+ const next = togglePin(prev, id);
2155
+ savePrefs(next);
1595
2156
  return next;
1596
2157
  });
1597
2158
  };
1598
- /**
1599
- * T10: flip a mark's bookmark in the persisted store. The store action is
1600
- * the write path (the engine persists synchronously). The hover re-assert
1601
- * forces a re-render so the star reflects the toggled state — production
1602
- * re-renders through the framework's uSES-bound useStore; the component
1603
- * test harness injects an unsubscribed selector, so this local re-render is
1604
- * what syncs the DOM there. Both paths converge on the same fresh snapshot.
1605
- */
1606
- const onToggleBookmark = (key) => {
1607
- actions?.toggle(key);
1608
- setHover((h) => h === null ? h : { ...h });
2159
+ /** B1: reset every pin (everything folds away) and persist the empty set. */
2160
+ const onResetPins = () => {
2161
+ setPinned([]);
2162
+ savePrefs([]);
1609
2163
  };
2164
+ /** B1: a feature renders while the toolbar is EXPANDED or while it is pinned. */
2165
+ const featureVisible = (id) => toolbarExpanded || pinned.includes(id);
1610
2166
  /**
1611
- * C3: copy the hovered mark's FULL message text to the system clipboard.
1612
- * The acknowledgement only shows when the write actually succeeded.
2167
+ * B1: the data-driven feature registry. Each entry's render is the feature's
2168
+ * UNCHANGED rail-top chrome (data attributes / aria semantics preserved),
2169
+ * moved verbatim from the previous static button block; `search` is the
2170
+ * whole RailSearchUi (toggle + panel) so its lifecycle stays component-local
2171
+ * in the rail (search state lives in the rail and survives unmount).
2172
+ * Registry order = settings-menu order (站内搜索/全部提问/跨会话搜索/只看收藏/聚焦模式).
2173
+ *
2174
+ * EXTENSION POINT: push a new feature here (+ its id in toolbar-prefs.ts's
2175
+ * TOOLBAR_PIN_IDS and its locale keys) and pinning/settings/expand all
2176
+ * follow automatically — see the ToolbarFeatureDef doc above.
1613
2177
  */
1614
- const onCopy = async (mark) => {
1615
- if (await copyText(mark.text)) setCopiedKey(mark.key);
1616
- };
1617
- /**
1618
- * C3: fork the session at the hovered mark, anchoring the cut at its event
1619
- * seq. The acknowledgement only shows once the fork resolved.
1620
- */
1621
- const onFork = (mark) => {
1622
- forkAt(mark.seq).then(() => setForkedKey(mark.key));
1623
- };
1624
- const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
1625
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
1626
- style: {
1627
- position: "fixed",
1628
- top: railBox.top,
1629
- right: railBox.right,
1630
- height: railBox.height,
1631
- width: DOT_HIT,
1632
- pointerEvents: "auto",
1633
- zIndex: 100,
1634
- display: "flex",
1635
- flexDirection: "column",
1636
- gap: 6,
1637
- paddingTop: 6
2178
+ const toolbarFeatures = [
2179
+ {
2180
+ id: "search",
2181
+ labelKey: "search.label",
2182
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
2183
+ panelTop: railBox.top,
2184
+ panelRight: railBox.right + DOT_HIT + 8,
2185
+ query: search.query,
2186
+ panelOpen: search.panelOpen,
2187
+ matches: matches.length,
2188
+ total: displayMarks.length,
2189
+ onToggle: () => setSearch((s) => ({
2190
+ ...s,
2191
+ panelOpen: !s.panelOpen
2192
+ })),
2193
+ onQueryChange: updateQuery,
2194
+ onSearchKeyDown,
2195
+ onClear: clearSearch,
2196
+ t
2197
+ })
1638
2198
  },
1639
- "aria-label": t("rail.label"),
1640
- "data-focus-active": focusActive ? "true" : void 0,
1641
- children: [
1642
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
1643
- focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
1644
- showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2199
+ {
2200
+ id: "list",
2201
+ labelKey: "list.label",
2202
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1645
2203
  type: "button",
1646
- "data-load-older": true,
1647
- "data-loading-older": loadingOlder ? "true" : void 0,
1648
- title: t("load.older"),
1649
- "aria-label": t("load.older"),
1650
- disabled: loadingOlder,
1651
- onClick: () => {
1652
- loadOlder();
2204
+ "data-list-toggle": true,
2205
+ "aria-label": listOpen ? t("list.close") : t("list.open"),
2206
+ title: listOpen ? t("list.close") : t("list.open"),
2207
+ "aria-pressed": listOpen,
2208
+ onClick: () => setListOpen((v) => !v),
2209
+ style: {
2210
+ width: DOT_HIT,
2211
+ height: DOT_HIT,
2212
+ flexShrink: 0,
2213
+ display: "flex",
2214
+ alignItems: "center",
2215
+ justifyContent: "center",
2216
+ background: listOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2217
+ border: "none",
2218
+ padding: 0,
2219
+ cursor: "pointer",
2220
+ color: listOpen ? "#9db8ff" : "#8b96ab"
1653
2221
  },
2222
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2223
+ width: "16",
2224
+ height: "16",
2225
+ viewBox: "0 0 24 24",
2226
+ fill: "none",
2227
+ stroke: "currentColor",
2228
+ strokeWidth: "2.5",
2229
+ strokeLinecap: "round",
2230
+ "aria-hidden": "true",
2231
+ children: [
2232
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
2233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h18" }),
2234
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h18" })
2235
+ ]
2236
+ })
2237
+ })
2238
+ },
2239
+ {
2240
+ id: "sessionSearch",
2241
+ labelKey: "search.cross",
2242
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2243
+ type: "button",
2244
+ "data-session-search-toggle": true,
2245
+ "aria-label": crossOpen ? t("search.cross.close") : t("search.cross.open"),
2246
+ title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
2247
+ "aria-pressed": crossOpen,
2248
+ onClick: () => setCrossOpen((v) => !v),
1654
2249
  style: {
1655
2250
  width: DOT_HIT,
1656
2251
  height: DOT_HIT,
@@ -1658,18 +2253,40 @@ window.__ModuleLoader__.load({
1658
2253
  display: "flex",
1659
2254
  alignItems: "center",
1660
2255
  justifyContent: "center",
1661
- background: "transparent",
2256
+ background: crossOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
1662
2257
  border: "none",
1663
2258
  padding: 0,
1664
- cursor: loadingOlder ? "default" : "pointer",
1665
- color: loadingOlder ? "#5a6375" : "#8b96ab",
1666
- fontSize: 13,
1667
- lineHeight: 1,
1668
- letterSpacing: 1
2259
+ cursor: "pointer",
2260
+ color: crossOpen ? "#9db8ff" : "#8b96ab"
1669
2261
  },
1670
- children: "···"
1671
- }),
1672
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2262
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2263
+ width: "16",
2264
+ height: "16",
2265
+ viewBox: "0 0 24 24",
2266
+ fill: "none",
2267
+ stroke: "currentColor",
2268
+ strokeWidth: "2",
2269
+ strokeLinecap: "round",
2270
+ strokeLinejoin: "round",
2271
+ "aria-hidden": "true",
2272
+ children: [
2273
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h9" }),
2274
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h9" }),
2275
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h9" }),
2276
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
2277
+ cx: "17",
2278
+ cy: "7",
2279
+ r: "3.5"
2280
+ }),
2281
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m19.5 9.5 2.5 2.5" })
2282
+ ]
2283
+ })
2284
+ })
2285
+ },
2286
+ {
2287
+ id: "bookmarks",
2288
+ labelKey: "bookmark.filter",
2289
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1673
2290
  type: "button",
1674
2291
  "data-bookmarks-toggle": true,
1675
2292
  "aria-label": t("bookmark.filter"),
@@ -1700,8 +2317,12 @@ window.__ModuleLoader__.load({
1700
2317
  "aria-hidden": "true",
1701
2318
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m12 2 3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" })
1702
2319
  })
1703
- }),
1704
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2320
+ })
2321
+ },
2322
+ {
2323
+ id: "focus",
2324
+ labelKey: "focus.on",
2325
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1705
2326
  type: "button",
1706
2327
  "data-focus-toggle": true,
1707
2328
  "aria-label": focusActive ? t("focus.off") : t("focus.on"),
@@ -1737,14 +2358,235 @@ window.__ModuleLoader__.load({
1737
2358
  r: "3"
1738
2359
  })]
1739
2360
  })
2361
+ })
2362
+ },
2363
+ {
2364
+ id: "updateCheck",
2365
+ labelKey: "update.check",
2366
+ render: () => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2367
+ type: "button",
2368
+ ref: updateBtnRef,
2369
+ "data-update-check": true,
2370
+ "aria-expanded": updateOpen,
2371
+ "aria-label": t("update.check"),
2372
+ title: t("update.check"),
2373
+ onClick: () => setUpdateOpen((v) => !v),
2374
+ style: {
2375
+ position: "relative",
2376
+ width: DOT_HIT,
2377
+ height: DOT_HIT,
2378
+ flexShrink: 0,
2379
+ display: "flex",
2380
+ alignItems: "center",
2381
+ justifyContent: "center",
2382
+ background: updateOpen || updateCheck.available ? "rgba(245, 197, 66, 0.14)" : "transparent",
2383
+ border: "none",
2384
+ padding: 0,
2385
+ cursor: "pointer",
2386
+ color: updateCheck.available ? "#f5c542" : "#8b96ab"
2387
+ },
2388
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2389
+ width: "16",
2390
+ height: "16",
2391
+ viewBox: "0 0 24 24",
2392
+ fill: "none",
2393
+ stroke: "currentColor",
2394
+ strokeWidth: "2",
2395
+ strokeLinecap: "round",
2396
+ strokeLinejoin: "round",
2397
+ "aria-hidden": "true",
2398
+ children: [
2399
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }),
2400
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21 3v5h-5" }),
2401
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }),
2402
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M8 16H3v5" })
2403
+ ]
2404
+ }), updateCheck.available && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2405
+ "data-update-available": true,
2406
+ style: {
2407
+ position: "absolute",
2408
+ top: -2,
2409
+ right: -2,
2410
+ width: 8,
2411
+ height: 8,
2412
+ borderRadius: "50%",
2413
+ background: "#f5c542",
2414
+ border: "2px solid rgba(20, 24, 32, 0.95)",
2415
+ pointerEvents: "none"
2416
+ }
2417
+ })]
2418
+ })
2419
+ }
2420
+ ];
2421
+ /** Focus the dot at `index` (no-op while the list is unmounted). */
2422
+ const focusDotAt = (index) => {
2423
+ listRef.current?.querySelectorAll("[data-rail-dot]")[index]?.focus();
2424
+ };
2425
+ /** Tab lands on the list itself: hand focus to the dot owning the tab stop. */
2426
+ const onListFocus = (e) => {
2427
+ if (e.target !== e.currentTarget) return;
2428
+ focusDotAt(clampIndex(focusIndex, render.items.length));
2429
+ };
2430
+ /**
2431
+ * Roving-tabindex keys: ArrowDown/ArrowUp move focus (wrapping), Home/End
2432
+ * jump to first/last. Enter/Space are deliberately NOT handled — the dots
2433
+ * are real buttons, so native activation fires the jump click untouched
2434
+ * (preventDefault here would swallow it). The rover counts RENDERED dots
2435
+ * (collapsed turns shrink the list).
2436
+ */
2437
+ const onListKeyDown = (e) => {
2438
+ const count = render.items.length;
2439
+ let next = null;
2440
+ switch (e.key) {
2441
+ case "ArrowDown":
2442
+ next = nextFocusIndex(focusIndex, count, 1);
2443
+ break;
2444
+ case "ArrowUp":
2445
+ next = nextFocusIndex(focusIndex, count, -1);
2446
+ break;
2447
+ case "Home":
2448
+ next = 0;
2449
+ break;
2450
+ case "End":
2451
+ next = count - 1;
2452
+ break;
2453
+ default: return;
2454
+ }
2455
+ e.preventDefault();
2456
+ const target = clampIndex(next, count);
2457
+ setFocusIndex(target);
2458
+ focusDotAt(target);
2459
+ };
2460
+ const buildHover = (mark, index) => {
2461
+ if (copiedKey !== null && mark.key !== copiedKey) setCopiedKey(null);
2462
+ if (forkedKey !== null && mark.key !== forkedKey) setForkedKey(null);
2463
+ const turn = mark.turn !== void 0 ? timeline.turns.get(mark.turn) : void 0;
2464
+ let durationLabel = null;
2465
+ let reasonLabel = null;
2466
+ let ttftLabel = null;
2467
+ let tpsLabel = null;
2468
+ if (turn !== void 0) {
2469
+ if (turn.start !== void 0 && turn.end !== void 0) durationLabel = formatDuration(turn.end.time - turn.start.time);
2470
+ if (turn.end !== void 0) {
2471
+ const reason = turn.end.data.reason;
2472
+ if (reason?.kind !== void 0) reasonLabel = t(reasonKeyOf(reason.kind));
2473
+ }
2474
+ const tail = turnTailOf(turn);
2475
+ if (tail !== void 0) {
2476
+ if (tail.ttftMs !== void 0) ttftLabel = formatDuration(tail.ttftMs);
2477
+ if (tail.tokensPerSecond !== void 0) tpsLabel = `${tail.tokensPerSecond.toFixed(1)} tok/s`;
2478
+ }
2479
+ }
2480
+ const meta = deriveTurnMeta(nodes, locations, mark.turn, trajectoryRequests);
2481
+ return {
2482
+ mark,
2483
+ index,
2484
+ total: displayMarks.length,
2485
+ turnLabel: mark.turn !== void 0 ? t("turn.label", { n: mark.turn }) : null,
2486
+ durationLabel,
2487
+ reasonLabel,
2488
+ ttftLabel,
2489
+ tpsLabel,
2490
+ modelLabel: meta.model,
2491
+ purposeLabel: meta.purpose,
2492
+ tokensLabel: meta.inputTokens !== null && meta.outputTokens !== null ? `${meta.inputTokens} / ${meta.outputTokens} tok` : null,
2493
+ turnMarkCount: mark.turn !== void 0 ? turnMarkCounts.get(mark.turn) ?? 0 : null
2494
+ };
2495
+ };
2496
+ /**
2497
+ * C4: collapse/expand the hovered mark's turn in the rail. The set is
2498
+ * replaced immutably (a turn toggles out when already present); collapsing
2499
+ * keeps the turn's LAST mark visible via buildRenderList.
2500
+ */
2501
+ const onToggleCollapse = (turn) => {
2502
+ setCollapsedTurns((prev) => {
2503
+ const next = new Set(prev);
2504
+ if (next.has(turn)) next.delete(turn);
2505
+ else next.add(turn);
2506
+ return next;
2507
+ });
2508
+ };
2509
+ /**
2510
+ * T10: flip a mark's bookmark in the persisted store. The store action is
2511
+ * the write path (the engine persists synchronously). The hover re-assert
2512
+ * forces a re-render so the star reflects the toggled state — production
2513
+ * re-renders through the framework's uSES-bound useStore; the component
2514
+ * test harness injects an unsubscribed selector, so this local re-render is
2515
+ * what syncs the DOM there. Both paths converge on the same fresh snapshot.
2516
+ */
2517
+ const onToggleBookmark = (key) => {
2518
+ actions?.toggle(key);
2519
+ setHover((h) => h === null ? h : { ...h });
2520
+ };
2521
+ /**
2522
+ * C3: copy the hovered mark's FULL message text to the system clipboard.
2523
+ * The acknowledgement only shows when the write actually succeeded.
2524
+ */
2525
+ const onCopy = async (mark) => {
2526
+ if (await copyText(mark.text)) setCopiedKey(mark.key);
2527
+ };
2528
+ /**
2529
+ * C3: fork the session at the hovered mark, anchoring the cut at its event
2530
+ * seq. The acknowledgement only shows once the fork resolved.
2531
+ */
2532
+ const onFork = (mark) => {
2533
+ forkAt(mark.seq).then(() => setForkedKey(mark.key));
2534
+ };
2535
+ const showLoadOlder = hasMore && marks.length >= MIN_MARKS;
2536
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2537
+ style: {
2538
+ position: "fixed",
2539
+ top: railBox.top,
2540
+ right: railBox.right,
2541
+ height: railBox.height,
2542
+ width: DOT_HIT,
2543
+ pointerEvents: "auto",
2544
+ zIndex: 100,
2545
+ display: "flex",
2546
+ flexDirection: "column",
2547
+ gap: 6,
2548
+ paddingTop: 6
2549
+ },
2550
+ "aria-label": t("rail.label"),
2551
+ "data-focus-active": focusActive ? "true" : void 0,
2552
+ children: [
2553
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: BADGE_PULSE_CSS }),
2554
+ focusActive && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: FOCUS_CSS }),
2555
+ showLoadOlder && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2556
+ type: "button",
2557
+ "data-load-older": true,
2558
+ "data-loading-older": loadingOlder ? "true" : void 0,
2559
+ title: t("load.older"),
2560
+ "aria-label": t("load.older"),
2561
+ disabled: loadingOlder,
2562
+ onClick: () => {
2563
+ loadOlder();
2564
+ },
2565
+ style: {
2566
+ width: DOT_HIT,
2567
+ height: DOT_HIT,
2568
+ flexShrink: 0,
2569
+ display: "flex",
2570
+ alignItems: "center",
2571
+ justifyContent: "center",
2572
+ background: "transparent",
2573
+ border: "none",
2574
+ padding: 0,
2575
+ cursor: loadingOlder ? "default" : "pointer",
2576
+ color: loadingOlder ? "#5a6375" : "#8b96ab",
2577
+ fontSize: 13,
2578
+ lineHeight: 1,
2579
+ letterSpacing: 1
2580
+ },
2581
+ children: "···"
1740
2582
  }),
1741
2583
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1742
2584
  type: "button",
1743
- "data-list-toggle": true,
1744
- "aria-label": listOpen ? t("list.close") : t("list.open"),
1745
- title: listOpen ? t("list.close") : t("list.open"),
1746
- "aria-pressed": listOpen,
1747
- onClick: () => setListOpen((v) => !v),
2585
+ "data-toolbar-expand": true,
2586
+ "aria-expanded": toolbarExpanded,
2587
+ "aria-label": toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2588
+ title: toolbarExpanded ? t("toolbar.collapse") : t("toolbar.expand"),
2589
+ onClick: () => setToolbarExpanded((v) => !v),
1748
2590
  style: {
1749
2591
  width: DOT_HIT,
1750
2592
  height: DOT_HIT,
@@ -1752,13 +2594,13 @@ window.__ModuleLoader__.load({
1752
2594
  display: "flex",
1753
2595
  alignItems: "center",
1754
2596
  justifyContent: "center",
1755
- background: listOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2597
+ background: "transparent",
1756
2598
  border: "none",
1757
2599
  padding: 0,
1758
2600
  cursor: "pointer",
1759
- color: listOpen ? "#9db8ff" : "#8b96ab"
2601
+ color: "#8b96ab"
1760
2602
  },
1761
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
2603
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1762
2604
  width: "16",
1763
2605
  height: "16",
1764
2606
  viewBox: "0 0 24 24",
@@ -1766,21 +2608,19 @@ window.__ModuleLoader__.load({
1766
2608
  stroke: "currentColor",
1767
2609
  strokeWidth: "2.5",
1768
2610
  strokeLinecap: "round",
2611
+ strokeLinejoin: "round",
1769
2612
  "aria-hidden": "true",
1770
- children: [
1771
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h18" }),
1772
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h18" }),
1773
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h18" })
1774
- ]
2613
+ children: toolbarExpanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m18 15-6-6-6 6" }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 9 6 6 6-6" })
1775
2614
  })
1776
2615
  }),
1777
2616
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
1778
2617
  type: "button",
1779
- "data-session-search-toggle": true,
1780
- "aria-label": crossOpen ? t("search.cross.close") : t("search.cross.open"),
1781
- title: crossOpen ? t("search.cross.close") : t("search.cross.open"),
1782
- "aria-pressed": crossOpen,
1783
- onClick: () => setCrossOpen((v) => !v),
2618
+ ref: settingsBtnRef,
2619
+ "data-toolbar-settings": true,
2620
+ "aria-expanded": settingsOpen,
2621
+ "aria-label": settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2622
+ title: settingsOpen ? t("toolbar.settings.close") : t("toolbar.settings.open"),
2623
+ onClick: () => setSettingsOpen((v) => !v),
1784
2624
  style: {
1785
2625
  width: DOT_HIT,
1786
2626
  height: DOT_HIT,
@@ -1788,11 +2628,11 @@ window.__ModuleLoader__.load({
1788
2628
  display: "flex",
1789
2629
  alignItems: "center",
1790
2630
  justifyContent: "center",
1791
- background: crossOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
2631
+ background: settingsOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
1792
2632
  border: "none",
1793
2633
  padding: 0,
1794
2634
  cursor: "pointer",
1795
- color: crossOpen ? "#9db8ff" : "#8b96ab"
2635
+ color: settingsOpen ? "#9db8ff" : "#8b96ab"
1796
2636
  },
1797
2637
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
1798
2638
  width: "16",
@@ -1804,34 +2644,326 @@ window.__ModuleLoader__.load({
1804
2644
  strokeLinecap: "round",
1805
2645
  strokeLinejoin: "round",
1806
2646
  "aria-hidden": "true",
2647
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
2648
+ cx: "12",
2649
+ cy: "12",
2650
+ r: "3"
2651
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" })]
2652
+ })
2653
+ }),
2654
+ toolbarFeatures.map((feature) => featureVisible(feature.id) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react.Fragment, { children: feature.render() }, feature.id) : null),
2655
+ settingsOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2656
+ ref: settingsRef,
2657
+ "data-toolbar-settings-panel": true,
2658
+ style: {
2659
+ position: "fixed",
2660
+ top: railBox.top,
2661
+ right: railBox.right + DOT_HIT + 8,
2662
+ width: "min(240px, calc(100vw - 48px))",
2663
+ padding: "10px 12px",
2664
+ background: "rgba(20, 24, 32, 0.97)",
2665
+ color: "#e6e8ee",
2666
+ borderRadius: 8,
2667
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
2668
+ zIndex: 104
2669
+ },
2670
+ children: [
2671
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `[data-toolbar-pin-toggle]:hover { background: rgba(77, 124, 254, 0.18); }` }),
2672
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2673
+ "data-toolbar-settings-title": true,
2674
+ style: {
2675
+ fontSize: 13,
2676
+ fontWeight: 600,
2677
+ color: "#e6e8ee",
2678
+ marginBottom: 2
2679
+ },
2680
+ children: t("settings.title")
2681
+ }),
2682
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2683
+ style: {
2684
+ fontSize: 12,
2685
+ color: "#8b96ab",
2686
+ marginBottom: 4,
2687
+ textAlign: "right"
2688
+ },
2689
+ children: t("settings.pin")
2690
+ }),
2691
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2692
+ role: "menu",
2693
+ "aria-label": t("settings.title"),
2694
+ style: {
2695
+ display: "flex",
2696
+ flexDirection: "column",
2697
+ gap: 2
2698
+ },
2699
+ children: toolbarFeatures.map((feature) => {
2700
+ const checked = pinned.includes(feature.id);
2701
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
2702
+ type: "button",
2703
+ role: "menuitemcheckbox",
2704
+ "data-toolbar-pin-toggle": true,
2705
+ "data-pin-id": feature.id,
2706
+ "aria-checked": checked,
2707
+ onClick: () => onTogglePin(feature.id),
2708
+ style: {
2709
+ display: "flex",
2710
+ alignItems: "center",
2711
+ justifyContent: "space-between",
2712
+ gap: 8,
2713
+ width: "100%",
2714
+ padding: "6px 8px",
2715
+ background: "transparent",
2716
+ border: "none",
2717
+ borderRadius: 6,
2718
+ cursor: "pointer",
2719
+ color: "#e6e8ee",
2720
+ fontSize: 13,
2721
+ textAlign: "left"
2722
+ },
2723
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(feature.labelKey) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2724
+ "aria-hidden": "true",
2725
+ style: {
2726
+ width: 14,
2727
+ height: 14,
2728
+ flexShrink: 0,
2729
+ borderRadius: 3,
2730
+ border: "1px solid rgba(255, 255, 255, 0.35)",
2731
+ background: checked ? "rgba(77, 124, 254, 0.9)" : "transparent",
2732
+ display: "flex",
2733
+ alignItems: "center",
2734
+ justifyContent: "center",
2735
+ color: "#ffffff",
2736
+ fontSize: 10,
2737
+ lineHeight: 1
2738
+ },
2739
+ children: checked ? "✓" : ""
2740
+ })]
2741
+ }, feature.id);
2742
+ })
2743
+ }),
2744
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2745
+ type: "button",
2746
+ "data-toolbar-settings-reset": true,
2747
+ onClick: onResetPins,
2748
+ style: {
2749
+ width: "100%",
2750
+ marginTop: 6,
2751
+ padding: "6px 8px",
2752
+ background: "transparent",
2753
+ border: "none",
2754
+ borderRadius: 6,
2755
+ cursor: "pointer",
2756
+ color: "#8b96ab",
2757
+ fontSize: 13,
2758
+ textAlign: "left"
2759
+ },
2760
+ children: t("settings.reset")
2761
+ }),
2762
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2763
+ "data-toolbar-settings-footer": true,
2764
+ style: {
2765
+ marginTop: 8,
2766
+ paddingTop: 8,
2767
+ borderTop: "1px solid rgba(255, 255, 255, 0.12)"
2768
+ },
2769
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2770
+ style: {
2771
+ fontSize: 12,
2772
+ color: "#8b96ab",
2773
+ marginBottom: 6
2774
+ },
2775
+ children: t("settings.support")
2776
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2777
+ style: {
2778
+ display: "flex",
2779
+ flexDirection: "column",
2780
+ gap: 4
2781
+ },
2782
+ children: [
2783
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2784
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
2785
+ target: "_blank",
2786
+ rel: "noreferrer",
2787
+ style: {
2788
+ fontSize: 12,
2789
+ color: "#9db8ff",
2790
+ textDecoration: "none"
2791
+ },
2792
+ children: t("settings.repo")
2793
+ }),
2794
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2795
+ href: "https://github.com/SnowCrescenter-tech/dsh-milestone",
2796
+ target: "_blank",
2797
+ rel: "noreferrer",
2798
+ style: {
2799
+ fontSize: 12,
2800
+ color: "#9db8ff",
2801
+ textDecoration: "none"
2802
+ },
2803
+ children: t("settings.star")
2804
+ }),
2805
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2806
+ href: `https://github.com/SnowCrescenter-tech/dsh-milestone/issues`,
2807
+ target: "_blank",
2808
+ rel: "noreferrer",
2809
+ style: {
2810
+ fontSize: 12,
2811
+ color: "#9db8ff",
2812
+ textDecoration: "none"
2813
+ },
2814
+ children: t("settings.issues")
2815
+ }),
2816
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2817
+ href: "https://www.npmjs.com/package/dsh-milestone",
2818
+ target: "_blank",
2819
+ rel: "noreferrer",
2820
+ style: {
2821
+ fontSize: 12,
2822
+ color: "#9db8ff",
2823
+ textDecoration: "none"
2824
+ },
2825
+ children: t("settings.npm")
2826
+ })
2827
+ ]
2828
+ })]
2829
+ })
2830
+ ]
2831
+ }),
2832
+ updateOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2833
+ ref: updatePanelRef,
2834
+ "data-update-panel": true,
2835
+ style: {
2836
+ position: "fixed",
2837
+ top: railBox.top,
2838
+ right: railBox.right + DOT_HIT + 8,
2839
+ width: "min(280px, calc(100vw - 48px))",
2840
+ padding: "10px 12px",
2841
+ background: "rgba(20, 24, 32, 0.97)",
2842
+ color: "#e6e8ee",
2843
+ borderRadius: 8,
2844
+ boxShadow: "0 6px 20px rgba(0, 0, 0, 0.4)",
2845
+ zIndex: 104
2846
+ },
2847
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2848
+ "data-update-title": true,
2849
+ style: {
2850
+ fontSize: 13,
2851
+ fontWeight: 600,
2852
+ color: "#e6e8ee",
2853
+ marginBottom: 8
2854
+ },
2855
+ children: t("update.title")
2856
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2857
+ style: {
2858
+ display: "flex",
2859
+ flexDirection: "column",
2860
+ gap: 6,
2861
+ fontSize: 12,
2862
+ lineHeight: 1.5
2863
+ },
1807
2864
  children: [
1808
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 6h9" }),
1809
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 12h9" }),
1810
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M3 18h9" }),
1811
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("circle", {
1812
- cx: "17",
1813
- cy: "7",
1814
- r: "3.5"
2865
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2866
+ style: { color: "#8b96ab" },
2867
+ children: [t("update.current"), ": "]
2868
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "0.6.1" })] }),
2869
+ updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2870
+ "data-update-latest": true,
2871
+ children: [
2872
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2873
+ style: { color: "#8b96ab" },
2874
+ children: [t("update.latest"), ": "]
2875
+ }),
2876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: updateCheck.latest }),
2877
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2878
+ style: { color: "#8b96ab" },
2879
+ children: [
2880
+ " (",
2881
+ updateCheck.source,
2882
+ ")"
2883
+ ]
2884
+ })
2885
+ ]
1815
2886
  }),
1816
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m19.5 9.5 2.5 2.5" })
2887
+ updateCheck.phase === "checking" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2888
+ "data-update-status": true,
2889
+ style: { color: "#8b96ab" },
2890
+ children: t("update.checking")
2891
+ }),
2892
+ updateCheck.phase === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2893
+ "data-update-failed": true,
2894
+ children: [
2895
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [t("update.failed"), ":"] }),
2896
+ " ",
2897
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2898
+ style: { color: "#8b96ab" },
2899
+ children: updateCheck.error
2900
+ }),
2901
+ " ",
2902
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2903
+ type: "button",
2904
+ "data-update-retry": true,
2905
+ onClick: runUpdateCheck,
2906
+ style: {
2907
+ background: "transparent",
2908
+ border: "none",
2909
+ padding: 0,
2910
+ cursor: "pointer",
2911
+ color: "#9db8ff",
2912
+ fontSize: 12,
2913
+ textDecoration: "underline"
2914
+ },
2915
+ children: t("update.retry")
2916
+ })
2917
+ ]
2918
+ }),
2919
+ updateCheck.phase === "ok" && updateCheck.latest !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2920
+ "data-update-conclusion": true,
2921
+ children: updateCheck.available ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
2922
+ t("update.available"),
2923
+ " v",
2924
+ updateCheck.latest,
2925
+ " → "
2926
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
2927
+ href: "https://www.npmjs.com/package/dsh-milestone",
2928
+ target: "_blank",
2929
+ rel: "noreferrer",
2930
+ style: {
2931
+ color: "#9db8ff",
2932
+ textDecoration: "none"
2933
+ },
2934
+ children: t("update.goNpm")
2935
+ })] }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2936
+ style: { color: "#7ee2a8" },
2937
+ children: t("update.upToDate")
2938
+ })
2939
+ }),
2940
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2941
+ "data-update-host-lines": true,
2942
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2943
+ style: { color: "#8b96ab" },
2944
+ children: [t("update.hostLines"), ": "]
2945
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: SUPPORTED_HOST_LINES.map(hostLineLabel).join("、") })]
2946
+ }),
2947
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2948
+ type: "button",
2949
+ "data-update-manual": true,
2950
+ disabled: updateCheck.phase === "checking",
2951
+ onClick: runUpdateCheck,
2952
+ style: {
2953
+ marginTop: 4,
2954
+ padding: "6px 10px",
2955
+ background: updateCheck.phase === "checking" ? "transparent" : "rgba(77, 124, 254, 0.18)",
2956
+ border: "none",
2957
+ borderRadius: 6,
2958
+ cursor: updateCheck.phase === "checking" ? "default" : "pointer",
2959
+ color: updateCheck.phase === "checking" ? "#5a6375" : "#9db8ff",
2960
+ fontSize: 12,
2961
+ alignSelf: "flex-start"
2962
+ },
2963
+ children: updateCheck.phase === "checking" ? t("update.checking") : t("update.check")
2964
+ })
1817
2965
  ]
1818
- })
1819
- }),
1820
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RailSearchUi, {
1821
- panelTop: railBox.top,
1822
- panelRight: railBox.right + DOT_HIT + 8,
1823
- query: search.query,
1824
- panelOpen: search.panelOpen,
1825
- matches: matches.length,
1826
- total: displayMarks.length,
1827
- onToggle: () => setSearch((s) => ({
1828
- ...s,
1829
- panelOpen: !s.panelOpen
1830
- })),
1831
- onQueryChange: updateQuery,
1832
- onSearchKeyDown,
1833
- onClear: clearSearch,
1834
- t
2966
+ })]
1835
2967
  }),
1836
2968
  listOpen && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MilestoneListPanel, {
1837
2969
  panelTop: railBox.top,
@@ -2194,7 +3326,53 @@ window.__ModuleLoader__.load({
2194
3326
  /** Cross-session search failure notice. */
2195
3327
  "search.error": "搜索失败,请重试",
2196
3328
  /** Cross-session search footer hint when the harness capped the result list. */
2197
- "search.more": "结果已截断,请细化关键词"
3329
+ "search.more": "结果已截断,请细化关键词",
3330
+ /** aria-label on the toolbar expand arrow while the toolbar is COLLAPSED (expand it). */
3331
+ "toolbar.expand": "展开工具栏",
3332
+ /** aria-label on the toolbar expand arrow while the toolbar is EXPANDED (collapse it). */
3333
+ "toolbar.collapse": "收起工具栏",
3334
+ /** aria-label on the toolbar settings gear while the settings menu is CLOSED. */
3335
+ "toolbar.settings.open": "打开设置",
3336
+ /** aria-label on the toolbar settings gear while the settings menu is OPEN. */
3337
+ "toolbar.settings.close": "关闭设置",
3338
+ /** Header title of the toolbar settings menu. */
3339
+ "settings.title": "设置",
3340
+ /** Per-feature toggle label inside the settings menu: keep visible while collapsed. */
3341
+ "settings.pin": "在折叠外显示",
3342
+ /** Settings action that clears every pinned feature. */
3343
+ "settings.reset": "恢复默认",
3344
+ /** Settings footer heading above the project links. */
3345
+ "settings.support": "支持我们",
3346
+ /** Settings footer link: the GitHub repository. */
3347
+ "settings.repo": "GitHub 仓库",
3348
+ /** Settings footer link: star the repository. */
3349
+ "settings.star": "欢迎 Star ★",
3350
+ /** Settings footer link: file an issue. */
3351
+ "settings.issues": "提交 Issue",
3352
+ /** Settings footer link: the npm install channel. */
3353
+ "settings.npm": "npm 安装渠道",
3354
+ /** B4 update-check: toolbar button label + title/aria-label. */
3355
+ "update.check": "检查更新",
3356
+ /** B4 update-check: popover title. */
3357
+ "update.title": "更新检测",
3358
+ /** B4 update-check: installed-version row label. */
3359
+ "update.current": "当前版本",
3360
+ /** B4 update-check: newest-published-version row label. */
3361
+ "update.latest": "最新版本",
3362
+ /** B4 update-check: conclusion when the installed version is current. */
3363
+ "update.upToDate": "已是最新版本",
3364
+ /** B4 update-check: conclusion when a newer version exists. */
3365
+ "update.available": "发现新版本",
3366
+ /** B4 update-check: link text for the npm upgrade channel. */
3367
+ "update.goNpm": "去 npm 升级",
3368
+ /** B4 update-check: supported-host-lines metadata row label. */
3369
+ "update.hostLines": "已适配官方版本线",
3370
+ /** B4 update-check: in-flight state of the manual check button. */
3371
+ "update.checking": "检查中…",
3372
+ /** B4 update-check: failed state heading. */
3373
+ "update.failed": "检查失败",
3374
+ /** B4 update-check: retry action inside the failed state. */
3375
+ "update.retry": "重试"
2198
3376
  };
2199
3377
  const en = {
2200
3378
  "jump.to": "Jump to message {n}",
@@ -2236,7 +3414,30 @@ window.__ModuleLoader__.load({
2236
3414
  "search.cross.close": "Close cross-session search",
2237
3415
  "search.untitled": "(untitled)",
2238
3416
  "search.error": "Search failed, retry",
2239
- "search.more": "Results truncated — refine your query"
3417
+ "search.more": "Results truncated — refine your query",
3418
+ "toolbar.expand": "Expand toolbar",
3419
+ "toolbar.collapse": "Collapse toolbar",
3420
+ "toolbar.settings.open": "Open settings",
3421
+ "toolbar.settings.close": "Close settings",
3422
+ "settings.title": "Settings",
3423
+ "settings.pin": "Show outside collapse",
3424
+ "settings.reset": "Restore defaults",
3425
+ "settings.support": "Support us",
3426
+ "settings.repo": "GitHub repo",
3427
+ "settings.star": "Give us a Star ★",
3428
+ "settings.issues": "Report an Issue",
3429
+ "settings.npm": "Install via npm",
3430
+ "update.check": "Check updates",
3431
+ "update.title": "Update check",
3432
+ "update.current": "Current version",
3433
+ "update.latest": "Latest version",
3434
+ "update.upToDate": "You are up to date",
3435
+ "update.available": "Update available",
3436
+ "update.goNpm": "Upgrade on npm",
3437
+ "update.hostLines": "Supported official version lines",
3438
+ "update.checking": "Checking…",
3439
+ "update.failed": "Check failed",
3440
+ "update.retry": "Retry"
2240
3441
  };
2241
3442
  //#endregion
2242
3443
  //#region src/client/index.ts