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.
- package/README.md +14 -0
- package/lib/client.js +1396 -195
- 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
|
-
/**
|
|
1511
|
-
const
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
-
*
|
|
1612
|
-
*
|
|
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
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
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
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
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-
|
|
1647
|
-
"
|
|
1648
|
-
title: t("
|
|
1649
|
-
"aria-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
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:
|
|
1665
|
-
color:
|
|
1666
|
-
fontSize: 13,
|
|
1667
|
-
lineHeight: 1,
|
|
1668
|
-
letterSpacing: 1
|
|
2259
|
+
cursor: "pointer",
|
|
2260
|
+
color: crossOpen ? "#9db8ff" : "#8b96ab"
|
|
1669
2261
|
},
|
|
1670
|
-
children: "
|
|
1671
|
-
|
|
1672
|
-
|
|
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
|
-
|
|
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-
|
|
1744
|
-
"aria-
|
|
1745
|
-
|
|
1746
|
-
"
|
|
1747
|
-
onClick: () =>
|
|
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:
|
|
2597
|
+
background: "transparent",
|
|
1756
2598
|
border: "none",
|
|
1757
2599
|
padding: 0,
|
|
1758
2600
|
cursor: "pointer",
|
|
1759
|
-
color:
|
|
2601
|
+
color: "#8b96ab"
|
|
1760
2602
|
},
|
|
1761
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.
|
|
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
|
-
|
|
1780
|
-
"
|
|
1781
|
-
|
|
1782
|
-
"aria-
|
|
1783
|
-
|
|
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:
|
|
2631
|
+
background: settingsOpen ? "rgba(77, 124, 254, 0.18)" : "transparent",
|
|
1792
2632
|
border: "none",
|
|
1793
2633
|
padding: 0,
|
|
1794
2634
|
cursor: "pointer",
|
|
1795
|
-
color:
|
|
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.
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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)("
|
|
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
|