@khorsheed/dsh-message-timeline 0.2.4 → 0.2.6
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/CHANGELOG.md +17 -0
- package/README.en.md +15 -11
- package/README.i18n.yaml +2 -2
- package/README.md +15 -11
- package/lib/client.js +339 -53
- package/lib/types/client/TimelineRail.d.ts +1 -1
- package/lib/types/client/TimelineRail.js +208 -46
- package/lib/types/client/config.d.ts +5 -2
- package/lib/types/client/index.d.ts +9 -5
- package/lib/types/client/index.js +5 -1
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/locales.js +8 -2
- package/lib/types/client/outline.d.ts +12 -0
- package/lib/types/client/outline.js +40 -0
- package/lib/types/client/rail-tracker.d.ts +6 -3
- package/lib/types/client/rail-tracker.js +24 -7
- package/lib/types/client/rows.d.ts +77 -0
- package/lib/types/client/rows.js +86 -0
- package/lib/types/client/slots.d.ts +17 -4
- package/lib/types/client/timeline-kinds.d.ts +7 -2
- package/lib/types/client/timeline-kinds.js +8 -3
- package/package.json +16 -11
- package/lib/invariant.js +0 -28
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.js +0 -26
package/lib/client.js
CHANGED
|
@@ -33,23 +33,32 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
34
34
|
const zh = {
|
|
35
35
|
"rail.panel": "消息导览",
|
|
36
|
-
"rail.empty": "
|
|
36
|
+
"rail.empty": "无文本预览",
|
|
37
|
+
"rail.turn": "第 {turn} 轮",
|
|
38
|
+
"rail.image": "图片消息",
|
|
39
|
+
"rail.attachment": "附件消息"
|
|
37
40
|
};
|
|
38
41
|
/** English dictionary, checked complete against the zh key set. */
|
|
39
42
|
const en = {
|
|
40
43
|
"rail.panel": "Message timeline",
|
|
41
|
-
"rail.empty": "No
|
|
44
|
+
"rail.empty": "No text preview",
|
|
45
|
+
"rail.turn": "Turn {turn}",
|
|
46
|
+
"rail.image": "Image message",
|
|
47
|
+
"rail.attachment": "Attachment message"
|
|
42
48
|
};
|
|
43
49
|
/**
|
|
44
50
|
* Whether a node kind renders as a timeline row. The rail lists user messages
|
|
45
|
-
* (and optionally steering),
|
|
46
|
-
*
|
|
51
|
+
* (and optionally steering), the message-tools edited/restored bubbles that
|
|
52
|
+
* the host `order` does not always surface, and automated-turn trigger cards
|
|
53
|
+
* (whose turns have no human prompt but real transcript content). The same
|
|
54
|
+
* set drives the reading-position anchor, so landing on a trigger card keeps
|
|
55
|
+
* the selection on its turn instead of moving to the next user row.
|
|
47
56
|
* @param kind - the node's kind string.
|
|
48
57
|
* @param includeSteering - whether steering rows count as user messages.
|
|
49
58
|
* @returns true when the kind belongs on the timeline.
|
|
50
59
|
*/
|
|
51
60
|
function isTimelineRowKind(kind, includeSteering) {
|
|
52
|
-
return kind === "user" || includeSteering && kind === "steering" || kind === "message-tools-edited" || kind === "message-tools-restored";
|
|
61
|
+
return kind === "user" || includeSteering && kind === "steering" || kind === "message-tools-edited" || kind === "message-tools-restored" || kind === "turn-trigger";
|
|
53
62
|
}
|
|
54
63
|
/**
|
|
55
64
|
* Whether a node kind carries a hidden span (`{ hiddenStartSeq, seq }`) that
|
|
@@ -86,6 +95,25 @@ window.__ModuleLoader__.load({
|
|
|
86
95
|
chatView: false
|
|
87
96
|
};
|
|
88
97
|
/**
|
|
98
|
+
* The rect of the first laid-out chat input card in the scrollport. A
|
|
99
|
+
* business composer (the Room composer) takes the `conversation.composer`
|
|
100
|
+
* chain as an overlay: the official input bar stays mounted under a
|
|
101
|
+
* `display: none` fallback wrapper, so its card is still the first marker in
|
|
102
|
+
* the DOM but measures as a zero rect at y=0 — ending the box there collapses
|
|
103
|
+
* the panel to zero height. Unlaid-out cards are skipped, so such a layout
|
|
104
|
+
* falls through to the seat top.
|
|
105
|
+
* @param scrollport - the official conversation scrollport element.
|
|
106
|
+
* @returns the card's rect, or null when no laid-out card is rendered.
|
|
107
|
+
*/
|
|
108
|
+
function laidOutCard(scrollport) {
|
|
109
|
+
for (const card of scrollport.querySelectorAll("[data-composer-card]")) {
|
|
110
|
+
const rect = card.getBoundingClientRect();
|
|
111
|
+
if (rect.width === 0 && rect.height === 0) continue;
|
|
112
|
+
return rect;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
89
117
|
* Measure the panel's viewport box from one scrollport: its rect inset by the
|
|
90
118
|
* panel padding, minus the chat input card at the bottom and the conversation
|
|
91
119
|
* tab strip at the top. The tabs render just above the scrollport, but
|
|
@@ -95,7 +123,7 @@ window.__ModuleLoader__.load({
|
|
|
95
123
|
* whole `[data-composer-seat]` top: dock cards (goal/todo/queue) sit above
|
|
96
124
|
* the input inside the seat, and counting them would push the timeline up off
|
|
97
125
|
* the conversation. Falls back to the seat top, then the column bottom, when
|
|
98
|
-
*
|
|
126
|
+
* no laid-out card is rendered (see {@link laidOutCard}).
|
|
99
127
|
* The scrollport's own width rides along for the width-cap fallback when the
|
|
100
128
|
* message-flow probe is unanswered.
|
|
101
129
|
* @param scrollport - the official conversation scrollport element.
|
|
@@ -113,9 +141,9 @@ window.__ModuleLoader__.load({
|
|
|
113
141
|
break;
|
|
114
142
|
}
|
|
115
143
|
}
|
|
116
|
-
const card = scrollport
|
|
144
|
+
const card = laidOutCard(scrollport);
|
|
117
145
|
const seat = scrollport.querySelector("[data-composer-seat]");
|
|
118
|
-
const bottom = card !== null ? card.
|
|
146
|
+
const bottom = card !== null ? card.top - RAIL_VERTICAL_PADDING : seat !== null ? seat.getBoundingClientRect().top - RAIL_VERTICAL_PADDING : rect.bottom - RAIL_VERTICAL_PADDING;
|
|
119
147
|
return {
|
|
120
148
|
left: rect.left + RAIL_LEFT_INSET,
|
|
121
149
|
top: rect.top + topInset,
|
|
@@ -319,9 +347,7 @@ window.__ModuleLoader__.load({
|
|
|
319
347
|
};
|
|
320
348
|
}
|
|
321
349
|
},
|
|
322
|
-
jumpTo: (key) =>
|
|
323
|
-
if (scrollport !== null) jumpRow(scrollport, key);
|
|
324
|
-
},
|
|
350
|
+
jumpTo: (key) => scrollport !== null && jumpRow(scrollport, key),
|
|
325
351
|
dispose: () => {
|
|
326
352
|
stopList();
|
|
327
353
|
stopProvide?.();
|
|
@@ -352,6 +378,129 @@ window.__ModuleLoader__.load({
|
|
|
352
378
|
return props.useChat ?? EMPTY_HOOK;
|
|
353
379
|
}
|
|
354
380
|
//#endregion
|
|
381
|
+
//#region src/client/outline.ts
|
|
382
|
+
/**
|
|
383
|
+
* Narrow one wire projection value to outline entries. `turn` and `seq` are
|
|
384
|
+
* load-bearing — a row cannot exist or jump without them — so damage there
|
|
385
|
+
* drops the entry; the previews are decorative and degrade to ''. A non-array
|
|
386
|
+
* value (absent unit included) narrows to undefined.
|
|
387
|
+
* @param value - the raw `useProjection('turnOutline')` read.
|
|
388
|
+
* @returns the surviving entries in wire order, or undefined when absent.
|
|
389
|
+
*/
|
|
390
|
+
function outlineEntries(value) {
|
|
391
|
+
if (!Array.isArray(value)) return void 0;
|
|
392
|
+
const entries = [];
|
|
393
|
+
for (const raw of value) {
|
|
394
|
+
const entry = outlineEntry(raw);
|
|
395
|
+
if (entry !== void 0) entries.push(entry);
|
|
396
|
+
}
|
|
397
|
+
return entries;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Narrow one wire entry. `turn` and `seq` must be non-negative safe integers
|
|
401
|
+
* (seq additionally rejects -0); the previews default to '' when malformed.
|
|
402
|
+
* @param value - one raw wire entry.
|
|
403
|
+
* @returns the entry, or undefined when a load-bearing field is damaged.
|
|
404
|
+
*/
|
|
405
|
+
function outlineEntry(value) {
|
|
406
|
+
if (typeof value !== "object" || value === null) return void 0;
|
|
407
|
+
const entry = value;
|
|
408
|
+
if (typeof entry.turn !== "number" || !Number.isSafeInteger(entry.turn) || entry.turn < 0) return void 0;
|
|
409
|
+
if (typeof entry.seq !== "number" || !Number.isSafeInteger(entry.seq) || entry.seq < 0 || Object.is(entry.seq, -0)) return void 0;
|
|
410
|
+
return {
|
|
411
|
+
turn: entry.turn,
|
|
412
|
+
seq: entry.seq,
|
|
413
|
+
prompt: typeof entry.prompt === "string" ? entry.prompt : "",
|
|
414
|
+
response: typeof entry.response === "string" ? entry.response : ""
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
//#endregion
|
|
418
|
+
//#region src/client/rows.ts
|
|
419
|
+
/**
|
|
420
|
+
* The first visible loaded row of a turn, of any node kind: the landing
|
|
421
|
+
* target for a turn with no user-kind row (an automated turn renders a
|
|
422
|
+
* `turn-trigger` card, not a user bubble), mirroring the official rail's
|
|
423
|
+
* `user ?? loaded[0]` anchor. Only nodes in the host's visible `order`
|
|
424
|
+
* qualify — a store-only or hidden node is not a jump target.
|
|
425
|
+
* @param order - visible chat node keys in render order.
|
|
426
|
+
* @param nodes - the live node store.
|
|
427
|
+
* @param seq - the turn's `turn/start` seq (inclusive).
|
|
428
|
+
* @param end - the next turn's start seq (exclusive), or Infinity for the last turn.
|
|
429
|
+
* @returns the anchor key, or null when the turn has no visible loaded row.
|
|
430
|
+
*/
|
|
431
|
+
function firstVisibleKeyInTurn(order, nodes, seq, end) {
|
|
432
|
+
for (const key of order) {
|
|
433
|
+
const node = nodes.get(key);
|
|
434
|
+
if (node === void 0) continue;
|
|
435
|
+
if (node.anchorSeq < seq) continue;
|
|
436
|
+
if (node.anchorSeq >= end) break;
|
|
437
|
+
if (node.visibility === "hidden") continue;
|
|
438
|
+
return key;
|
|
439
|
+
}
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Wrap one loaded item as a rail row.
|
|
444
|
+
* @param item - the window row.
|
|
445
|
+
* @param turn - its owning outline turn, or undefined.
|
|
446
|
+
* @returns the loaded rail row.
|
|
447
|
+
*/
|
|
448
|
+
function toLoadedRow(item, turn) {
|
|
449
|
+
return {
|
|
450
|
+
kind: "loaded",
|
|
451
|
+
id: item.key,
|
|
452
|
+
key: item.key,
|
|
453
|
+
node: item.node,
|
|
454
|
+
anchorSeq: item.node.anchorSeq,
|
|
455
|
+
turn
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Merge loaded rows with the outline into the full row list. Both inputs are
|
|
460
|
+
* transcript-ordered (loaded rows anchorSeq-ascending, the outline strictly
|
|
461
|
+
* increasing by turn), so one pass interleaves them without a final sort: an
|
|
462
|
+
* outline entry whose seq range holds at least one loaded row is covered and
|
|
463
|
+
* emits nothing; every other entry emits its outline row at the range's
|
|
464
|
+
* position. A loaded row belongs to the last entry whose start it follows —
|
|
465
|
+
* when the window runs ahead of the projection feed, the newest rows read as
|
|
466
|
+
* the last named turn until the feed catches up (suppressing its outline row
|
|
467
|
+
* beats duplicating the turn).
|
|
468
|
+
* @param loaded - the window's user rows, anchorSeq-ascending.
|
|
469
|
+
* @param outline - narrowed outline entries, or undefined (capability absent).
|
|
470
|
+
* @returns the merged row list, transcript-ordered.
|
|
471
|
+
*/
|
|
472
|
+
function mergeRows(loaded, outline) {
|
|
473
|
+
if (outline === void 0) return loaded.map((item) => toLoadedRow(item, void 0));
|
|
474
|
+
const rows = [];
|
|
475
|
+
let cursor = 0;
|
|
476
|
+
for (const [index, entry] of outline.entries()) {
|
|
477
|
+
const end = outline.at(index + 1)?.seq ?? Number.POSITIVE_INFINITY;
|
|
478
|
+
let covered = false;
|
|
479
|
+
for (;;) {
|
|
480
|
+
const item = loaded[cursor];
|
|
481
|
+
if (item === void 0 || item.node.anchorSeq >= end) break;
|
|
482
|
+
rows.push(toLoadedRow(item, item.node.anchorSeq >= entry.seq ? entry.turn : void 0));
|
|
483
|
+
covered = covered || item.node.anchorSeq >= entry.seq;
|
|
484
|
+
cursor += 1;
|
|
485
|
+
}
|
|
486
|
+
if (!covered) rows.push({
|
|
487
|
+
kind: "outline",
|
|
488
|
+
id: `turn:${entry.turn}`,
|
|
489
|
+
turn: entry.turn,
|
|
490
|
+
seq: entry.seq,
|
|
491
|
+
anchorSeq: entry.seq,
|
|
492
|
+
prompt: entry.prompt
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
for (;;) {
|
|
496
|
+
const item = loaded[cursor];
|
|
497
|
+
if (item === void 0) break;
|
|
498
|
+
rows.push(toLoadedRow(item, void 0));
|
|
499
|
+
cursor += 1;
|
|
500
|
+
}
|
|
501
|
+
return rows;
|
|
502
|
+
}
|
|
503
|
+
//#endregion
|
|
355
504
|
//#region src/client/preview.ts
|
|
356
505
|
/**
|
|
357
506
|
* Join the text blocks of one message into a single preview string.
|
|
@@ -402,7 +551,7 @@ window.__ModuleLoader__.load({
|
|
|
402
551
|
}
|
|
403
552
|
//#endregion
|
|
404
553
|
//#region \0dsh-css:src/client/TimelineRail.module.css.mjs
|
|
405
|
-
const css = "._8u17xW_panel{scrollbar-width:none;z-index:100;pointer-events:none;flex-direction:column;padding:4px 8px;display:flex;position:fixed;overflow-y:auto}._8u17xW_panel
|
|
554
|
+
const css = "._8u17xW_panel{scrollbar-width:none;z-index:100;pointer-events:none;flex-direction:column;padding:4px 8px;display:flex;position:fixed;overflow-y:auto}._8u17xW_panel::-webkit-scrollbar{display:none}._8u17xW_item{width:100%;max-width:20px;color:var(--dsw-alias-label-tertiary);text-align:left;cursor:pointer;-webkit-user-select:none;user-select:none;pointer-events:auto;opacity:.55;background:0 0;border:none;flex:none;align-items:center;gap:8px;padding:5px 0;font-size:13px;line-height:1.5;transition:opacity .12s;display:flex}._8u17xW_panel:hover ._8u17xW_item,._8u17xW_panel:focus-within ._8u17xW_item{opacity:1;max-width:100%}._8u17xW_tick{background:var(--dsw-alias-label-tertiary);border-radius:2px;flex:none;width:3px;height:14px}._8u17xW_itemCurrent{color:var(--dsw-static-deepseek-500);opacity:1;font-weight:600}._8u17xW_tickCurrent{background:var(--dsw-static-deepseek-500);height:18px}._8u17xW_itemText{white-space:nowrap;text-overflow:ellipsis;opacity:0;transition:opacity .12s;overflow:hidden}._8u17xW_panel:hover ._8u17xW_itemText,._8u17xW_panel:focus-within ._8u17xW_itemText{opacity:1;transition-delay:90ms}._8u17xW_itemFocused{color:var(--dsw-alias-label-primary);font-weight:600}._8u17xW_item:focus:not(:focus-visible){outline:none}._8u17xW_item:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:1px}._8u17xW_itemPending ._8u17xW_tick{background:var(--dsw-static-deepseek-400);animation:1s ease-in-out infinite _8u17xW_railPending}@keyframes _8u17xW_railPending{50%{opacity:.2}}@media (prefers-reduced-motion:reduce){._8u17xW_itemPending ._8u17xW_tick{animation:none}}._8u17xW_panelNarrow{z-index:101}._8u17xW_panelNarrow:hover,._8u17xW_panelNarrow:focus-within{background:var(--dsw-alias-bg-layer-1);border-radius:8px;box-shadow:0 4px 16px #0000002e;width:var(--panel-wide)!important}";
|
|
406
555
|
const tagId = "@khorsheed/dsh-message-timeline/TimelineRail.module.css";
|
|
407
556
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
408
557
|
const tag = document.createElement("style");
|
|
@@ -412,13 +561,16 @@ window.__ModuleLoader__.load({
|
|
|
412
561
|
document.head.appendChild(tag);
|
|
413
562
|
}
|
|
414
563
|
var TimelineRail_module_css_default = {
|
|
415
|
-
"
|
|
564
|
+
"panelNarrow": "_8u17xW_panelNarrow",
|
|
565
|
+
"itemText": "_8u17xW_itemText",
|
|
416
566
|
"panel": "_8u17xW_panel",
|
|
417
|
-
"
|
|
418
|
-
"item": "_8u17xW_item",
|
|
567
|
+
"itemPending": "_8u17xW_itemPending",
|
|
419
568
|
"tickCurrent": "_8u17xW_tickCurrent",
|
|
569
|
+
"item": "_8u17xW_item",
|
|
570
|
+
"itemCurrent": "_8u17xW_itemCurrent",
|
|
420
571
|
"itemFocused": "_8u17xW_itemFocused",
|
|
421
|
-
"
|
|
572
|
+
"tick": "_8u17xW_tick",
|
|
573
|
+
"railPending": "_8u17xW_railPending"
|
|
422
574
|
};
|
|
423
575
|
//#endregion
|
|
424
576
|
//#region src/client/TimelineRail.tsx
|
|
@@ -426,20 +578,52 @@ window.__ModuleLoader__.load({
|
|
|
426
578
|
* Message timeline panel, browser half. One entry in the official
|
|
427
579
|
* conversation.session.header.utilities seat anchors the plugin into the
|
|
428
580
|
* session scope and renders, through a body portal, the flat floating
|
|
429
|
-
* timeline over the left edge of the chat scrollport: one row per
|
|
430
|
-
*
|
|
581
|
+
* timeline over the left edge of the chat scrollport: one row per user
|
|
582
|
+
* message — a tick plus an ellipsized one-line preview, no frame and no
|
|
431
583
|
* visible scrollbar. At rest only the dimmed ticks show, the reading
|
|
432
584
|
* position's tick in blue (the latest message until the tracker answers);
|
|
433
585
|
* hovering or keyboard-focusing the panel reveals the row texts with the
|
|
434
586
|
* blue row on top, and clicking a row jumps the transcript to that message.
|
|
435
587
|
* The panel is always on while the chat view shows; the `enabled` config is
|
|
436
588
|
* the off switch.
|
|
589
|
+
*
|
|
590
|
+
* The row list is whole-session from the moment the session opens: the host
|
|
591
|
+
* `turnOutline` projection names every started turn, and the loaded chat
|
|
592
|
+
* window overlays richer previews (plus steering rows and message-tools
|
|
593
|
+
* bubbles) for the turns it covers. A turn outside the window renders as a
|
|
594
|
+
* dimmed outline row; confirming it pages history back through the turn's
|
|
595
|
+
* `turn/start` seq (the session jump loader) and lands on the freshly loaded
|
|
596
|
+
* row. When the host serves no turnOutline value the panel degrades to the
|
|
597
|
+
* loaded window alone, paged by prefetch and panel-top scrolls.
|
|
437
598
|
*/
|
|
438
599
|
/** Extract the preview blocks of one user/steering node (kind-checked by the caller). */
|
|
439
600
|
function nodeContent(node) {
|
|
440
601
|
return node.data.content ?? [];
|
|
441
602
|
}
|
|
442
603
|
/**
|
|
604
|
+
* One row's display text: the loaded row's joined text blocks, or the outline
|
|
605
|
+
* entry's bounded first-prompt preview. `null` means the message carries no
|
|
606
|
+
* text (an image/attachment-only input, or an automated turn with no human
|
|
607
|
+
* prompt) — the row renders the `rail.empty` label instead.
|
|
608
|
+
*/
|
|
609
|
+
function rowPreview(row) {
|
|
610
|
+
return row.kind === "loaded" ? previewText(nodeContent(row.node)) : row.prompt !== "" ? row.prompt : null;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* The row's display label: the text preview when there is one, else a
|
|
614
|
+
* kind-aware fallback — the turn number for an outline-only turn, and an
|
|
615
|
+
* image/attachment label for a loaded message with no text blocks.
|
|
616
|
+
*/
|
|
617
|
+
function rowLabel(row, t) {
|
|
618
|
+
const preview = rowPreview(row);
|
|
619
|
+
if (preview !== null) return preview;
|
|
620
|
+
if (row.kind === "outline") return t("rail.turn", { turn: row.turn });
|
|
621
|
+
const types = new Set(nodeContent(row.node).map((block) => block.type));
|
|
622
|
+
if (types.has("image")) return t("rail.image");
|
|
623
|
+
if (types.has("file")) return t("rail.attachment");
|
|
624
|
+
return t("rail.empty");
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
443
627
|
* Horizontal panel padding, matching `.panel`'s `padding: 4px 8px`: the text
|
|
444
628
|
* band sits this far inside the box, so the width budget must give it back
|
|
445
629
|
* for {@link PANEL_GAP} to be the visible gap to the message flow.
|
|
@@ -449,12 +633,22 @@ window.__ModuleLoader__.load({
|
|
|
449
633
|
const PANEL_GAP = 16;
|
|
450
634
|
/** Degraded width cap (fraction of the scrollport) while the flow probe is unanswered. */
|
|
451
635
|
const DEGRADED_WIDTH_RATIO = .4;
|
|
636
|
+
/** Rest width of the narrow-layout tick strip (px) — fits the flow's left padding. */
|
|
637
|
+
const PANEL_NARROW_REST_WIDTH = 24;
|
|
638
|
+
/** Expanded width of the narrow-layout hover overlay (fraction of the scrollport). */
|
|
639
|
+
const NARROW_WIDE_RATIO = .6;
|
|
640
|
+
/**
|
|
641
|
+
* Frames an outline jump retries the DOM scroll after its row materializes in
|
|
642
|
+
* the chat store: the transcript renders the new page in the same commit or a
|
|
643
|
+
* near frame, and a half-second budget covers the skew without hanging.
|
|
644
|
+
*/
|
|
645
|
+
const JUMP_RETRY_FRAMES = 30;
|
|
452
646
|
/**
|
|
453
647
|
* The header-utilities entry: the portal timeline panel.
|
|
454
648
|
* @param props - composed props (see {@link TimelineRailProps}).
|
|
455
649
|
* @returns nothing visible in the seat; the floating panel while the chat view shows.
|
|
456
650
|
*/
|
|
457
|
-
function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
|
|
651
|
+
function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }) {
|
|
458
652
|
const rail = useRail((s) => s);
|
|
459
653
|
const useChatSlice = chatHookOf(standard);
|
|
460
654
|
const useSession = standard.useSession;
|
|
@@ -462,6 +656,8 @@ window.__ModuleLoader__.load({
|
|
|
462
656
|
const nodes = useChatSlice((c) => c.nodes);
|
|
463
657
|
const hasMore = useSession((s) => s.hasMore);
|
|
464
658
|
const loadingOlder = useSession((s) => s.loadingOlder);
|
|
659
|
+
const outlineValue = standard.useProjection?.("turnOutline");
|
|
660
|
+
const outline = (0, react.useMemo)(() => outlineEntries(outlineValue), [outlineValue]);
|
|
465
661
|
const items = (0, react.useMemo)(() => {
|
|
466
662
|
let store = [];
|
|
467
663
|
let spans = [];
|
|
@@ -470,14 +666,14 @@ window.__ModuleLoader__.load({
|
|
|
470
666
|
spans = foldHiddenSpans(store);
|
|
471
667
|
} catch {}
|
|
472
668
|
const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
|
|
473
|
-
const
|
|
669
|
+
const loaded = [];
|
|
474
670
|
const seen = /* @__PURE__ */ new Set();
|
|
475
671
|
for (const key of order) {
|
|
476
672
|
const node = nodes.get(key);
|
|
477
673
|
if (node === void 0) continue;
|
|
478
674
|
if (!isTimelineRowKind(node.kind, includeSteering)) continue;
|
|
479
675
|
if (hidden(node)) continue;
|
|
480
|
-
|
|
676
|
+
loaded.push({
|
|
481
677
|
key,
|
|
482
678
|
node
|
|
483
679
|
});
|
|
@@ -488,27 +684,95 @@ window.__ModuleLoader__.load({
|
|
|
488
684
|
if (node.visibility === "hidden") continue;
|
|
489
685
|
if (seen.has(node.key)) continue;
|
|
490
686
|
if (hidden(node)) continue;
|
|
491
|
-
|
|
687
|
+
loaded.push({
|
|
492
688
|
key: node.key,
|
|
493
689
|
node
|
|
494
690
|
});
|
|
495
691
|
seen.add(node.key);
|
|
496
692
|
}
|
|
497
|
-
|
|
498
|
-
return
|
|
693
|
+
loaded.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
|
|
694
|
+
return mergeRows(loaded, outline);
|
|
499
695
|
}, [
|
|
500
696
|
order,
|
|
501
697
|
nodes,
|
|
502
|
-
includeSteering
|
|
698
|
+
includeSteering,
|
|
699
|
+
outline
|
|
503
700
|
]);
|
|
504
701
|
const [focusKey, setFocusKey] = (0, react.useState)(null);
|
|
505
|
-
const current = focusKey ?? rail.activeKey ?? items.at(-1)?.
|
|
702
|
+
const current = focusKey ?? rail.activeKey ?? items.at(-1)?.id ?? null;
|
|
506
703
|
const panelRef = (0, react.useRef)(null);
|
|
704
|
+
const [pendingJump, setPendingJump] = (0, react.useState)(null);
|
|
705
|
+
(0, react.useEffect)(() => {
|
|
706
|
+
setPendingJump(null);
|
|
707
|
+
}, [sessionId]);
|
|
708
|
+
const firstSeq = (0, react.useMemo)(() => {
|
|
709
|
+
const firstKey = order[0];
|
|
710
|
+
if (firstKey === void 0) return null;
|
|
711
|
+
return nodes.get(firstKey)?.anchorSeq ?? null;
|
|
712
|
+
}, [order, nodes]);
|
|
713
|
+
(0, react.useEffect)(() => {
|
|
714
|
+
if (pendingJump === null || pendingJump.sessionId !== sessionId) return;
|
|
715
|
+
const landOn = (key) => {
|
|
716
|
+
let cancelled = false;
|
|
717
|
+
let frames = 0;
|
|
718
|
+
const attempt = () => {
|
|
719
|
+
if (cancelled) return;
|
|
720
|
+
frames += 1;
|
|
721
|
+
if (jumpTo(key) || frames >= JUMP_RETRY_FRAMES) {
|
|
722
|
+
setPendingJump(null);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
(typeof requestAnimationFrame === "function" ? requestAnimationFrame : setTimeout)(attempt);
|
|
726
|
+
};
|
|
727
|
+
attempt();
|
|
728
|
+
return () => {
|
|
729
|
+
cancelled = true;
|
|
730
|
+
};
|
|
731
|
+
};
|
|
732
|
+
const landed = items.find((row) => row.kind === "loaded" && row.turn === pendingJump.turn);
|
|
733
|
+
if (landed !== void 0) return landOn(landed.key);
|
|
734
|
+
const end = outline?.find((entry) => entry.turn > pendingJump.turn)?.seq ?? Number.POSITIVE_INFINITY;
|
|
735
|
+
const anyKey = firstVisibleKeyInTurn(order, nodes, pendingJump.seq, end);
|
|
736
|
+
if (anyKey !== null) return landOn(anyKey);
|
|
737
|
+
if (pendingJump.awaiting || loadingOlder) return;
|
|
738
|
+
if ((firstSeq === null || firstSeq > pendingJump.seq) && hasMore && (!pendingJump.requested || pendingJump.fromHead !== firstSeq)) {
|
|
739
|
+
const jump = pendingJump;
|
|
740
|
+
setPendingJump({
|
|
741
|
+
...jump,
|
|
742
|
+
requested: true,
|
|
743
|
+
awaiting: true,
|
|
744
|
+
fromHead: firstSeq
|
|
745
|
+
});
|
|
746
|
+
loadThrough(jump.seq).then(() => {
|
|
747
|
+
setPendingJump((current) => current !== null && current.sessionId === jump.sessionId && current.turn === jump.turn ? {
|
|
748
|
+
...current,
|
|
749
|
+
awaiting: false
|
|
750
|
+
} : current);
|
|
751
|
+
});
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const fallback = items.find((row) => row.kind === "loaded" && row.anchorSeq >= pendingJump.seq);
|
|
755
|
+
if (fallback !== void 0) jumpTo(fallback.key);
|
|
756
|
+
setPendingJump(null);
|
|
757
|
+
}, [
|
|
758
|
+
items,
|
|
759
|
+
pendingJump,
|
|
760
|
+
sessionId,
|
|
761
|
+
loadingOlder,
|
|
762
|
+
hasMore,
|
|
763
|
+
firstSeq,
|
|
764
|
+
jumpTo,
|
|
765
|
+
loadThrough,
|
|
766
|
+
order,
|
|
767
|
+
nodes,
|
|
768
|
+
outline
|
|
769
|
+
]);
|
|
507
770
|
const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
|
|
508
771
|
const visible = active && items.length > 0;
|
|
509
772
|
const gutter = rail.flowLeft === null ? null : rail.flowLeft - rail.left - PANEL_PADDING_X - PANEL_GAP;
|
|
510
|
-
const
|
|
511
|
-
const
|
|
773
|
+
const narrow = gutter !== null && gutter < 120;
|
|
774
|
+
const width = gutter === null ? Math.min(panelWidth, Math.max(120, rail.scrollportWidth * DEGRADED_WIDTH_RATIO)) : narrow ? PANEL_NARROW_REST_WIDTH : Math.min(panelWidth, gutter);
|
|
775
|
+
const wideWidth = Math.min(panelWidth, Math.max(120, rail.scrollportWidth * NARROW_WIDE_RATIO));
|
|
512
776
|
(0, react.useEffect)(() => {
|
|
513
777
|
panelRef.current?.querySelector(`[data-item-key=${JSON.stringify(current)}]`)?.scrollIntoView({ block: "nearest" });
|
|
514
778
|
}, [current]);
|
|
@@ -518,6 +782,7 @@ window.__ModuleLoader__.load({
|
|
|
518
782
|
prefetchedPagesRef.current = 0;
|
|
519
783
|
return;
|
|
520
784
|
}
|
|
785
|
+
if (outline !== void 0) return;
|
|
521
786
|
if (!hasMore || loadingOlder) return;
|
|
522
787
|
if (items.length > 0 && prefetchedPagesRef.current >= initialPages) return;
|
|
523
788
|
prefetchedPagesRef.current += 1;
|
|
@@ -528,72 +793,92 @@ window.__ModuleLoader__.load({
|
|
|
528
793
|
loadingOlder,
|
|
529
794
|
items.length,
|
|
530
795
|
initialPages,
|
|
531
|
-
loadOlder
|
|
796
|
+
loadOlder,
|
|
797
|
+
outline
|
|
532
798
|
]);
|
|
533
799
|
const onPanelScroll = (event) => {
|
|
800
|
+
if (outline !== void 0) return;
|
|
534
801
|
if (!hasMore || loadingOlder) return;
|
|
535
802
|
if (event.currentTarget.scrollTop <= 8) loadOlder();
|
|
536
803
|
};
|
|
537
|
-
const confirm = (
|
|
804
|
+
const confirm = (row) => {
|
|
538
805
|
setFocusKey(null);
|
|
539
|
-
|
|
806
|
+
if (row.kind === "loaded") {
|
|
807
|
+
setPendingJump(null);
|
|
808
|
+
jumpTo(row.key);
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
setPendingJump({
|
|
812
|
+
sessionId,
|
|
813
|
+
turn: row.turn,
|
|
814
|
+
seq: row.seq,
|
|
815
|
+
requested: false,
|
|
816
|
+
awaiting: false,
|
|
817
|
+
fromHead: null
|
|
818
|
+
});
|
|
540
819
|
};
|
|
541
|
-
if (!visible
|
|
820
|
+
if (!visible) return null;
|
|
542
821
|
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
543
822
|
ref: panelRef,
|
|
544
|
-
className: TimelineRail_module_css_default.panel
|
|
823
|
+
className: `${TimelineRail_module_css_default.panel} ${narrow ? TimelineRail_module_css_default.panelNarrow : ""}`,
|
|
545
824
|
role: "navigation",
|
|
546
825
|
"aria-label": t("rail.panel"),
|
|
547
826
|
tabIndex: 0,
|
|
548
827
|
"data-timeline-panel": "",
|
|
549
828
|
style: {
|
|
550
829
|
left: rail.left,
|
|
551
|
-
top: rail.top,
|
|
552
|
-
|
|
553
|
-
|
|
830
|
+
top: rail.top + rail.height / 2,
|
|
831
|
+
maxHeight: rail.height,
|
|
832
|
+
transform: "translateY(-50%)",
|
|
833
|
+
width,
|
|
834
|
+
...narrow ? { "--panel-wide": `${wideWidth}px` } : {}
|
|
554
835
|
},
|
|
555
836
|
onScroll: onPanelScroll,
|
|
556
837
|
onKeyDown: (event) => {
|
|
557
838
|
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
558
839
|
event.preventDefault();
|
|
559
|
-
const anchor = items.findIndex((item) => item.
|
|
840
|
+
const anchor = items.findIndex((item) => item.id === current);
|
|
560
841
|
const next = event.key === "ArrowDown" ? items[Math.min(anchor + 1, items.length - 1)] : items[Math.max(anchor - 1, 0)];
|
|
561
842
|
/* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
|
|
562
|
-
if (next !== void 0) setFocusKey(next.
|
|
843
|
+
if (next !== void 0) setFocusKey(next.id);
|
|
563
844
|
} else if (event.key === "Enter" || event.key === " ") {
|
|
564
845
|
event.preventDefault();
|
|
565
|
-
|
|
566
|
-
if (
|
|
846
|
+
const row = items.find((item) => item.id === current);
|
|
847
|
+
if (row !== void 0) confirm(row);
|
|
567
848
|
}
|
|
568
849
|
},
|
|
569
850
|
onBlur: () => {
|
|
570
851
|
setFocusKey(null);
|
|
571
852
|
},
|
|
572
|
-
children: items.map((
|
|
573
|
-
const isCurrent =
|
|
574
|
-
const
|
|
853
|
+
children: items.map((row) => {
|
|
854
|
+
const isCurrent = row.id === current;
|
|
855
|
+
const isFocused = row.id === focusKey;
|
|
856
|
+
const isPending = row.kind === "outline" && pendingJump !== null && pendingJump.turn === row.turn;
|
|
857
|
+
const className = isFocused ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemFocused}` : isCurrent ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemCurrent}` : TimelineRail_module_css_default.item;
|
|
858
|
+
const classNames = isPending ? `${className} ${TimelineRail_module_css_default.itemPending}` : className;
|
|
575
859
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
576
860
|
type: "button",
|
|
577
|
-
"data-item-key":
|
|
861
|
+
"data-item-key": row.id,
|
|
578
862
|
"aria-current": isCurrent || void 0,
|
|
579
|
-
|
|
863
|
+
"aria-busy": isPending || void 0,
|
|
864
|
+
className: classNames,
|
|
580
865
|
onClick: () => {
|
|
581
|
-
confirm(
|
|
866
|
+
confirm(row);
|
|
582
867
|
},
|
|
583
868
|
onMouseEnter: () => {
|
|
584
|
-
setFocusKey(
|
|
869
|
+
setFocusKey(row.id);
|
|
585
870
|
},
|
|
586
871
|
onMouseLeave: () => {
|
|
587
|
-
setFocusKey((key) => key ===
|
|
872
|
+
setFocusKey((key) => key === row.id ? null : key);
|
|
588
873
|
},
|
|
589
874
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
590
875
|
className: isCurrent ? `${TimelineRail_module_css_default.tick} ${TimelineRail_module_css_default.tickCurrent}` : TimelineRail_module_css_default.tick,
|
|
591
876
|
"aria-hidden": "true"
|
|
592
877
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
593
878
|
className: TimelineRail_module_css_default.itemText,
|
|
594
|
-
children:
|
|
879
|
+
children: rowLabel(row, t)
|
|
595
880
|
})]
|
|
596
|
-
},
|
|
881
|
+
}, row.id);
|
|
597
882
|
})
|
|
598
883
|
}), document.body);
|
|
599
884
|
}
|
|
@@ -636,14 +921,15 @@ window.__ModuleLoader__.load({
|
|
|
636
921
|
if (actx === void 0) throw new Error("message-timeline: session resolved no scope");
|
|
637
922
|
const conversation = actx.get("conversation");
|
|
638
923
|
if (conversation === void 0) throw new Error("message-timeline: conversation service unavailable");
|
|
924
|
+
const binding = ctx.sessions.binding(sessionId);
|
|
925
|
+
if (binding === void 0) throw new Error("message-timeline: session resolved no binding");
|
|
639
926
|
return {
|
|
640
927
|
includeSteering: options.includeSteering,
|
|
641
928
|
panelWidth: options.panelWidth,
|
|
642
929
|
initialPages: options.initialPages,
|
|
643
930
|
loadOlder: () => conversation.loadOlder(),
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
},
|
|
931
|
+
loadThrough: (seq) => binding.session.loadThrough(seq),
|
|
932
|
+
jumpTo: (key) => tracker.jumpTo(key),
|
|
647
933
|
hooks: { rail: tracker.state }
|
|
648
934
|
};
|
|
649
935
|
}
|
|
@@ -4,7 +4,7 @@ import type { TimelineRailProps } from './slots.ts';
|
|
|
4
4
|
* @param props - composed props (see {@link TimelineRailProps}).
|
|
5
5
|
* @returns nothing visible in the seat; the floating panel while the chat view shows.
|
|
6
6
|
*/
|
|
7
|
-
export declare function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }: TimelineRailProps): import("react").ReactPortal | null;
|
|
7
|
+
export declare function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }: TimelineRailProps): import("react").ReactPortal | null;
|
|
8
8
|
/** Memoized export for the slot machinery (stable component identity). */
|
|
9
9
|
export declare const TimelineRailEntry: import("react").MemoExoticComponent<typeof TimelineRail>;
|
|
10
10
|
//# sourceMappingURL=TimelineRail.d.ts.map
|