@khorsheed/dsh-message-timeline 0.2.3 → 0.2.5
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 +14 -0
- package/README.en.md +16 -12
- package/README.i18n.yaml +2 -2
- package/README.md +16 -12
- package/lib/client.js +305 -46
- package/lib/types/client/TimelineRail.d.ts +1 -1
- package/lib/types/client/TimelineRail.js +179 -33
- 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 +5 -2
- package/lib/types/client/rail-tracker.js +1 -4
- 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
|
@@ -3,19 +3,30 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
* Message timeline panel, browser half. One entry in the official
|
|
4
4
|
* conversation.session.header.utilities seat anchors the plugin into the
|
|
5
5
|
* session scope and renders, through a body portal, the flat floating
|
|
6
|
-
* timeline over the left edge of the chat scrollport: one row per
|
|
7
|
-
*
|
|
6
|
+
* timeline over the left edge of the chat scrollport: one row per user
|
|
7
|
+
* message — a tick plus an ellipsized one-line preview, no frame and no
|
|
8
8
|
* visible scrollbar. At rest only the dimmed ticks show, the reading
|
|
9
9
|
* position's tick in blue (the latest message until the tracker answers);
|
|
10
10
|
* hovering or keyboard-focusing the panel reveals the row texts with the
|
|
11
11
|
* blue row on top, and clicking a row jumps the transcript to that message.
|
|
12
12
|
* The panel is always on while the chat view shows; the `enabled` config is
|
|
13
13
|
* the off switch.
|
|
14
|
+
*
|
|
15
|
+
* The row list is whole-session from the moment the session opens: the host
|
|
16
|
+
* `turnOutline` projection names every started turn, and the loaded chat
|
|
17
|
+
* window overlays richer previews (plus steering rows and message-tools
|
|
18
|
+
* bubbles) for the turns it covers. A turn outside the window renders as a
|
|
19
|
+
* dimmed outline row; confirming it pages history back through the turn's
|
|
20
|
+
* `turn/start` seq (the session jump loader) and lands on the freshly loaded
|
|
21
|
+
* row. When the host serves no turnOutline value the panel degrades to the
|
|
22
|
+
* loaded window alone, paged by prefetch and panel-top scrolls.
|
|
14
23
|
*/
|
|
15
24
|
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
|
16
25
|
import { createPortal } from 'react-dom';
|
|
17
26
|
import { PANEL_WIDTH_MIN } from './config.js';
|
|
18
27
|
import { chatHookOf } from './chat-hook.js';
|
|
28
|
+
import { outlineEntries } from './outline.js';
|
|
29
|
+
import { mergeRows, firstVisibleKeyInTurn } from './rows.js';
|
|
19
30
|
import { previewText } from './preview.js';
|
|
20
31
|
import { foldHiddenSpans, isSeqHidden } from './hidden-spans.js';
|
|
21
32
|
import { isTimelineRowKind, EDIT_KIND, RESTORED_KIND } from './timeline-kinds.js';
|
|
@@ -24,6 +35,35 @@ import css from './TimelineRail.module.css';
|
|
|
24
35
|
function nodeContent(node) {
|
|
25
36
|
return node.data.content ?? [];
|
|
26
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* One row's display text: the loaded row's joined text blocks, or the outline
|
|
40
|
+
* entry's bounded first-prompt preview. `null` means the message carries no
|
|
41
|
+
* text (an image/attachment-only input, or an automated turn with no human
|
|
42
|
+
* prompt) — the row renders the `rail.empty` label instead.
|
|
43
|
+
*/
|
|
44
|
+
function rowPreview(row) {
|
|
45
|
+
return row.kind === 'loaded'
|
|
46
|
+
? previewText(nodeContent(row.node))
|
|
47
|
+
: (row.prompt !== '' ? row.prompt : null);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The row's display label: the text preview when there is one, else a
|
|
51
|
+
* kind-aware fallback — the turn number for an outline-only turn, and an
|
|
52
|
+
* image/attachment label for a loaded message with no text blocks.
|
|
53
|
+
*/
|
|
54
|
+
function rowLabel(row, t) {
|
|
55
|
+
const preview = rowPreview(row);
|
|
56
|
+
if (preview !== null)
|
|
57
|
+
return preview;
|
|
58
|
+
if (row.kind === 'outline')
|
|
59
|
+
return t('rail.turn', { turn: row.turn });
|
|
60
|
+
const types = new Set(nodeContent(row.node).map(block => block.type));
|
|
61
|
+
if (types.has('image'))
|
|
62
|
+
return t('rail.image');
|
|
63
|
+
if (types.has('file'))
|
|
64
|
+
return t('rail.attachment');
|
|
65
|
+
return t('rail.empty');
|
|
66
|
+
}
|
|
27
67
|
/**
|
|
28
68
|
* Horizontal panel padding, matching `.panel`'s `padding: 4px 8px`: the text
|
|
29
69
|
* band sits this far inside the box, so the width budget must give it back
|
|
@@ -34,12 +74,18 @@ const PANEL_PADDING_X = 8;
|
|
|
34
74
|
const PANEL_GAP = 16;
|
|
35
75
|
/** Degraded width cap (fraction of the scrollport) while the flow probe is unanswered. */
|
|
36
76
|
const DEGRADED_WIDTH_RATIO = 0.4;
|
|
77
|
+
/**
|
|
78
|
+
* Frames an outline jump retries the DOM scroll after its row materializes in
|
|
79
|
+
* the chat store: the transcript renders the new page in the same commit or a
|
|
80
|
+
* near frame, and a half-second budget covers the skew without hanging.
|
|
81
|
+
*/
|
|
82
|
+
const JUMP_RETRY_FRAMES = 30;
|
|
37
83
|
/**
|
|
38
84
|
* The header-utilities entry: the portal timeline panel.
|
|
39
85
|
* @param props - composed props (see {@link TimelineRailProps}).
|
|
40
86
|
* @returns nothing visible in the seat; the floating panel while the chat view shows.
|
|
41
87
|
*/
|
|
42
|
-
export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
|
|
88
|
+
export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }) {
|
|
43
89
|
const rail = useRail(s => s);
|
|
44
90
|
// Chat data lives in the `useChat` standard prop on 0.1.2 (the rc-line
|
|
45
91
|
// session-snapshot seat is gone); the helper degrades to an empty slice
|
|
@@ -50,6 +96,13 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
50
96
|
const nodes = useChatSlice(c => c.nodes);
|
|
51
97
|
const hasMore = useSession(s => s.hasMore);
|
|
52
98
|
const loadingOlder = useSession(s => s.loadingOlder);
|
|
99
|
+
// The host projection seat exists on every supported host; the VALUE is
|
|
100
|
+
// present only where the assembly mounts the turn-outline unit. Both reads
|
|
101
|
+
// degrade to undefined, which keeps the window-paging list. The narrow is
|
|
102
|
+
// memoized on the projection's reference-stable value so unrelated renders
|
|
103
|
+
// do not churn the merged list (or re-run the jump effect).
|
|
104
|
+
const outlineValue = standard.useProjection?.('turnOutline');
|
|
105
|
+
const outline = useMemo(() => outlineEntries(outlineValue), [outlineValue]);
|
|
53
106
|
const items = useMemo(() => {
|
|
54
107
|
// The node store is an in-memory snapshot; take it once so the span fold
|
|
55
108
|
// and the message-tools append read the same snapshot.
|
|
@@ -74,7 +127,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
74
127
|
// ordinary session carries no span node, so the fold is empty and every
|
|
75
128
|
// order row still renders: this filter never alters the baseline.
|
|
76
129
|
const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
|
|
77
|
-
const
|
|
130
|
+
const loaded = [];
|
|
78
131
|
const seen = new Set();
|
|
79
132
|
for (const key of order) {
|
|
80
133
|
const node = nodes.get(key);
|
|
@@ -84,7 +137,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
84
137
|
continue;
|
|
85
138
|
if (hidden(node))
|
|
86
139
|
continue;
|
|
87
|
-
|
|
140
|
+
loaded.push({ key, node });
|
|
88
141
|
seen.add(key);
|
|
89
142
|
}
|
|
90
143
|
// After a message-tools in-place edit the replacement materializes as a
|
|
@@ -105,7 +158,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
105
158
|
continue;
|
|
106
159
|
if (hidden(node))
|
|
107
160
|
continue;
|
|
108
|
-
|
|
161
|
+
loaded.push({ key: node.key, node });
|
|
109
162
|
seen.add(node.key);
|
|
110
163
|
}
|
|
111
164
|
// The order loop is seq-sorted, but the appended bubbles walked the store
|
|
@@ -116,22 +169,95 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
116
169
|
// ordinary session has no appended bubbles, so this never reorders the
|
|
117
170
|
// baseline (which is already seq-sorted). Dropped rows are gone before the
|
|
118
171
|
// sort, so a withdrawn original never resurfaces.
|
|
119
|
-
|
|
120
|
-
return
|
|
121
|
-
}, [order, nodes, includeSteering]);
|
|
172
|
+
loaded.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
|
|
173
|
+
return mergeRows(loaded, outline);
|
|
174
|
+
}, [order, nodes, includeSteering, outline]);
|
|
122
175
|
// The lit row: the hover/arrow preselection while it moves, otherwise the
|
|
123
176
|
// live reading position the tracker publishes, defaulting to the latest
|
|
124
177
|
// message before the tracker answers. ArrowUp/Down or hovering moves a
|
|
125
|
-
// bold preselection; Enter or a click confirms it and jumps.
|
|
178
|
+
// bold preselection; Enter or a click confirms it and jumps. Loaded rows
|
|
179
|
+
// identify by their node key (the tracker's active marker speaks the same
|
|
180
|
+
// key); outline rows identify as `turn:<n>`.
|
|
126
181
|
const [focusKey, setFocusKey] = useState(null);
|
|
127
|
-
const current = focusKey ?? rail.activeKey ?? items.at(-1)?.
|
|
182
|
+
const current = focusKey ?? rail.activeKey ?? items.at(-1)?.id ?? null;
|
|
128
183
|
const panelRef = useRef(null);
|
|
184
|
+
const [pendingJump, setPendingJump] = useState(null);
|
|
185
|
+
// A jump never crosses sessions; the entry's session identity switches
|
|
186
|
+
// without a remount when the main view moves.
|
|
187
|
+
useEffect(() => { setPendingJump(null); }, [sessionId]);
|
|
188
|
+
// The window head: the first visible node's anchorSeq (ChatView's firstSeq
|
|
189
|
+
// derivation), the coverage line for the outline jump's re-request guard.
|
|
190
|
+
const firstSeq = useMemo(() => {
|
|
191
|
+
const firstKey = order[0];
|
|
192
|
+
if (firstKey === undefined)
|
|
193
|
+
return null;
|
|
194
|
+
return nodes.get(firstKey)?.anchorSeq ?? null;
|
|
195
|
+
}, [order, nodes]);
|
|
196
|
+
// The outline-jump state machine. Fire (click) only arms the jump; this
|
|
197
|
+
// effect owns every loadThrough request, the landing scroll, and the give-
|
|
198
|
+
// up fallback, re-running as the window, the paging flags, or the jump
|
|
199
|
+
// itself change. The loop converges because loadThrough pages until the
|
|
200
|
+
// window covers the target seq (or history runs out), and a settle that
|
|
201
|
+
// moved nothing falls through to the fallback instead of re-requesting.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (pendingJump === null || pendingJump.sessionId !== sessionId)
|
|
204
|
+
return;
|
|
205
|
+
// Scroll to a key once its transcript row renders (the same commit or a
|
|
206
|
+
// near frame); the returned cleanup cancels the retries.
|
|
207
|
+
const landOn = (key) => {
|
|
208
|
+
let cancelled = false;
|
|
209
|
+
let frames = 0;
|
|
210
|
+
const attempt = () => {
|
|
211
|
+
if (cancelled)
|
|
212
|
+
return;
|
|
213
|
+
frames += 1;
|
|
214
|
+
if (jumpTo(key) || frames >= JUMP_RETRY_FRAMES) {
|
|
215
|
+
setPendingJump(null);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
(typeof requestAnimationFrame === 'function' ? requestAnimationFrame : setTimeout)(attempt);
|
|
219
|
+
};
|
|
220
|
+
attempt();
|
|
221
|
+
return () => { cancelled = true; };
|
|
222
|
+
};
|
|
223
|
+
const landed = items.find((row) => row.kind === 'loaded' && row.turn === pendingJump.turn);
|
|
224
|
+
if (landed !== undefined)
|
|
225
|
+
return landOn(landed.key);
|
|
226
|
+
// No user-kind row for the turn: an automated turn (a restart/scheduled
|
|
227
|
+
// trigger renders as `turn-trigger`, not a user row) or a withdraw-hidden
|
|
228
|
+
// prompt. Land on the turn's first visible loaded node of any kind — the
|
|
229
|
+
// official rail's `user ?? loaded[0]` anchor.
|
|
230
|
+
const end = outline?.find(entry => entry.turn > pendingJump.turn)?.seq ?? Number.POSITIVE_INFINITY;
|
|
231
|
+
const anyKey = firstVisibleKeyInTurn(order, nodes, pendingJump.seq, end);
|
|
232
|
+
if (anyKey !== null)
|
|
233
|
+
return landOn(anyKey);
|
|
234
|
+
if (pendingJump.awaiting || loadingOlder)
|
|
235
|
+
return;
|
|
236
|
+
// Settled (or freshly armed) without any visible row for the turn: page
|
|
237
|
+
// again while the window head still moves and history remains; a stalled
|
|
238
|
+
// head lands on the nearest loaded row at/after the turn instead.
|
|
239
|
+
const uncovered = firstSeq === null || firstSeq > pendingJump.seq;
|
|
240
|
+
if (uncovered && hasMore && (!pendingJump.requested || pendingJump.fromHead !== firstSeq)) {
|
|
241
|
+
const jump = pendingJump;
|
|
242
|
+
setPendingJump({ ...jump, requested: true, awaiting: true, fromHead: firstSeq });
|
|
243
|
+
void loadThrough(jump.seq).then(() => {
|
|
244
|
+
setPendingJump(current => current !== null && current.sessionId === jump.sessionId && current.turn === jump.turn
|
|
245
|
+
? { ...current, awaiting: false }
|
|
246
|
+
: current);
|
|
247
|
+
});
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const fallback = items.find((row) => row.kind === 'loaded' && row.anchorSeq >= pendingJump.seq);
|
|
251
|
+
if (fallback !== undefined)
|
|
252
|
+
jumpTo(fallback.key);
|
|
253
|
+
setPendingJump(null);
|
|
254
|
+
}, [items, pendingJump, sessionId, loadingOlder, hasMore, firstSeq, jumpTo, loadThrough, order, nodes, outline]);
|
|
129
255
|
// The panel is a chat-view affordance: hide it while the session shows
|
|
130
256
|
// another tab (trajectory etc.), detected through ChatView's data-chat-flow
|
|
131
|
-
// marker.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
257
|
+
// marker. With the outline the list starts whole; without it a session
|
|
258
|
+
// whose loaded event window holds no user message yet (a huge assistant
|
|
259
|
+
// turn pushed it past the first page) renders no rows but must still pull
|
|
260
|
+
// pages until one materializes — hence `active` splits from `visible`.
|
|
135
261
|
const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
|
|
136
262
|
const visible = active && items.length > 0;
|
|
137
263
|
// The panel must never cover the message flow: its width is the configured
|
|
@@ -155,61 +281,81 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
155
281
|
panelRef.current?.querySelector(`[data-item-key=${JSON.stringify(current)}]`)
|
|
156
282
|
?.scrollIntoView({ block: 'nearest' });
|
|
157
283
|
}, [current]);
|
|
158
|
-
//
|
|
159
|
-
// until the first user message materializes
|
|
160
|
-
// initialPages pages arrived, so the list starts
|
|
161
|
-
// pages load on demand when the panel scrolls to its
|
|
284
|
+
// Fallback mode (no outline) prefetches history while the panel is on a
|
|
285
|
+
// chat view: keep pulling pages until the first user message materializes
|
|
286
|
+
// (bootstrap), then until initialPages pages arrived, so the list starts
|
|
287
|
+
// near-complete. Older pages load on demand when the panel scrolls to its
|
|
288
|
+
// top. The outline mode skips this entirely — the projection already names
|
|
289
|
+
// every turn, so no page has to arrive for the list to be complete.
|
|
162
290
|
const prefetchedPagesRef = useRef(0);
|
|
163
291
|
useEffect(() => {
|
|
164
292
|
if (!active) {
|
|
165
293
|
prefetchedPagesRef.current = 0;
|
|
166
294
|
return;
|
|
167
295
|
}
|
|
296
|
+
if (outline !== undefined)
|
|
297
|
+
return;
|
|
168
298
|
if (!hasMore || loadingOlder)
|
|
169
299
|
return;
|
|
170
300
|
if (items.length > 0 && prefetchedPagesRef.current >= initialPages)
|
|
171
301
|
return;
|
|
172
302
|
prefetchedPagesRef.current += 1;
|
|
173
303
|
void loadOlder();
|
|
174
|
-
}, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder]);
|
|
175
|
-
//
|
|
176
|
-
// view owns the load-older button, so the panel stays
|
|
304
|
+
}, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder, outline]);
|
|
305
|
+
// Fallback mode loads older history by scrolling the panel to its top; the
|
|
306
|
+
// official chat view owns the load-older button, so the panel stays
|
|
307
|
+
// chromeless. The outline mode's list is already complete.
|
|
177
308
|
const onPanelScroll = (event) => {
|
|
309
|
+
if (outline !== undefined)
|
|
310
|
+
return;
|
|
178
311
|
if (!hasMore || loadingOlder)
|
|
179
312
|
return;
|
|
180
313
|
if (event.currentTarget.scrollTop <= 8)
|
|
181
314
|
void loadOlder();
|
|
182
315
|
};
|
|
183
|
-
const confirm = (
|
|
316
|
+
const confirm = (row) => {
|
|
184
317
|
setFocusKey(null);
|
|
185
|
-
|
|
318
|
+
if (row.kind === 'loaded') {
|
|
319
|
+
// A direct jump supersedes any armed outline jump (its landing would
|
|
320
|
+
// otherwise yank the transcript later).
|
|
321
|
+
setPendingJump(null);
|
|
322
|
+
jumpTo(row.key);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// An outline row arms the jump; the effect above runs the paging and the
|
|
326
|
+
// landing scroll. Re-clicking another outline row retargets the jump.
|
|
327
|
+
setPendingJump({ sessionId, turn: row.turn, seq: row.seq, requested: false, awaiting: false, fromHead: null });
|
|
186
328
|
};
|
|
187
329
|
if (!visible || tooNarrow)
|
|
188
330
|
return null;
|
|
189
331
|
return createPortal(_jsx("div", { ref: panelRef, className: css.panel, role: "navigation", "aria-label": t('rail.panel'), tabIndex: 0, "data-timeline-panel": "", style: { left: rail.left, top: rail.top, height: rail.height, width }, onScroll: onPanelScroll, onKeyDown: (event) => {
|
|
190
332
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
191
333
|
event.preventDefault();
|
|
192
|
-
const anchor = items.findIndex(item => item.
|
|
334
|
+
const anchor = items.findIndex(item => item.id === current);
|
|
193
335
|
const next = event.key === 'ArrowDown'
|
|
194
336
|
? items[Math.min(anchor + 1, items.length - 1)]
|
|
195
337
|
: items[Math.max(anchor - 1, 0)];
|
|
196
338
|
/* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
|
|
197
339
|
if (next !== undefined)
|
|
198
|
-
setFocusKey(next.
|
|
340
|
+
setFocusKey(next.id);
|
|
199
341
|
}
|
|
200
342
|
else if (event.key === 'Enter' || event.key === ' ') {
|
|
201
343
|
event.preventDefault();
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
confirm(
|
|
344
|
+
const row = items.find(item => item.id === current);
|
|
345
|
+
if (row !== undefined)
|
|
346
|
+
confirm(row);
|
|
205
347
|
}
|
|
206
|
-
}, onBlur: () => { setFocusKey(null); }, children: items.map((
|
|
207
|
-
const isCurrent =
|
|
208
|
-
const isFocused =
|
|
348
|
+
}, onBlur: () => { setFocusKey(null); }, children: items.map((row) => {
|
|
349
|
+
const isCurrent = row.id === current;
|
|
350
|
+
const isFocused = row.id === focusKey;
|
|
351
|
+
const isPending = row.kind === 'outline' && pendingJump !== null && pendingJump.turn === row.turn;
|
|
209
352
|
const className = isFocused
|
|
210
353
|
? `${css.item} ${css.itemFocused}`
|
|
211
354
|
: isCurrent ? `${css.item} ${css.itemCurrent}` : css.item;
|
|
212
|
-
|
|
355
|
+
// Outline rows share the plain styling; the pending pulse is the only
|
|
356
|
+
// transient cue that a row pages history in before landing.
|
|
357
|
+
const classNames = isPending ? `${className} ${css.itemPending}` : className;
|
|
358
|
+
return (_jsxs("button", { type: "button", "data-item-key": row.id, "aria-current": isCurrent || undefined, "aria-busy": isPending || undefined, className: classNames, onClick: () => { confirm(row); }, onMouseEnter: () => { setFocusKey(row.id); }, onMouseLeave: () => { setFocusKey(key => key === row.id ? null : key); }, children: [_jsx("span", { className: isCurrent ? `${css.tick} ${css.tickCurrent}` : css.tick, "aria-hidden": "true" }), _jsx("span", { className: css.itemText, children: rowLabel(row, t) })] }, row.id));
|
|
213
359
|
}) }), document.body);
|
|
214
360
|
}
|
|
215
361
|
/** Memoized export for the slot machinery (stable component identity). */
|
|
@@ -20,8 +20,11 @@ export interface TimelineConfig {
|
|
|
20
20
|
*/
|
|
21
21
|
panelWidth: number;
|
|
22
22
|
/**
|
|
23
|
-
* History pages to prefetch when the rail opens (50 events each)
|
|
24
|
-
*
|
|
23
|
+
* History pages to prefetch when the rail opens (50 events each), in the
|
|
24
|
+
* window-paging fallback only: when the host serves the turnOutline
|
|
25
|
+
* projection the list starts whole and nothing prefetches. In the fallback,
|
|
26
|
+
* older pages load on demand when the rail or the panel is scrolled to its
|
|
27
|
+
* top.
|
|
25
28
|
*/
|
|
26
29
|
initialPages: number;
|
|
27
30
|
}
|
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
* official `conversation.session.header.utilities` seat (a right-aligned
|
|
4
4
|
* optional-utility slot) that anchors the plugin into the session scope; the
|
|
5
5
|
* component itself renders only through a body portal: the flat floating
|
|
6
|
-
* timeline panel over the chat scrollport's left edge — one row per
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* timeline panel over the chat scrollport's left edge — one row per user
|
|
7
|
+
* message of the whole session, no frame, no visible scrollbar. The row list
|
|
8
|
+
* merges the host `turnOutline` projection (every started turn, served whole)
|
|
9
|
+
* with the loaded chat window; confirming a not-yet-loaded row pages history
|
|
10
|
+
* through the session's jump loader (`loadThrough`) before scrolling. When
|
|
11
|
+
* the projection is absent the panel reads the loaded window alone, paged
|
|
12
|
+
* through the session conversation service, jumps through the official row
|
|
13
|
+
* anchor attributes, and degrades to a hidden panel when those attributes
|
|
14
|
+
* change — no official code is modified.
|
|
11
15
|
* @module @khorsheed/dsh-message-timeline/client
|
|
12
16
|
*/
|
|
13
17
|
import type { Context } from '@deepseek-ai/cordis';
|
|
@@ -36,12 +36,16 @@ export function apply(ctx, config) {
|
|
|
36
36
|
const conversation = actx.get('conversation');
|
|
37
37
|
if (conversation === undefined)
|
|
38
38
|
throw new Error('message-timeline: conversation service unavailable');
|
|
39
|
+
const binding = ctx.sessions.binding(sessionId);
|
|
40
|
+
if (binding === undefined)
|
|
41
|
+
throw new Error('message-timeline: session resolved no binding');
|
|
39
42
|
return {
|
|
40
43
|
includeSteering: options.includeSteering,
|
|
41
44
|
panelWidth: options.panelWidth,
|
|
42
45
|
initialPages: options.initialPages,
|
|
43
46
|
loadOlder: () => conversation.loadOlder(),
|
|
44
|
-
|
|
47
|
+
loadThrough: seq => binding.session.loadThrough(seq),
|
|
48
|
+
jumpTo: (key) => tracker.jumpTo(key),
|
|
45
49
|
hooks: { rail: tracker.state },
|
|
46
50
|
};
|
|
47
51
|
},
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
export declare const zh: {
|
|
4
4
|
'rail.panel': string;
|
|
5
5
|
'rail.empty': string;
|
|
6
|
+
'rail.turn': string;
|
|
7
|
+
'rail.image': string;
|
|
8
|
+
'rail.attachment': string;
|
|
6
9
|
};
|
|
7
10
|
/** The message-timeline namespace key union. */
|
|
8
11
|
export type TimelineKey = keyof typeof zh;
|
|
@@ -16,5 +19,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
16
19
|
export declare const en: {
|
|
17
20
|
'rail.panel': string;
|
|
18
21
|
'rail.empty': string;
|
|
22
|
+
'rail.turn': string;
|
|
23
|
+
'rail.image': string;
|
|
24
|
+
'rail.attachment': string;
|
|
19
25
|
};
|
|
20
26
|
//# sourceMappingURL=locales.d.ts.map
|
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
3
3
|
export const zh = {
|
|
4
4
|
'rail.panel': '消息导览',
|
|
5
|
-
'rail.empty': '
|
|
5
|
+
'rail.empty': '无文本预览',
|
|
6
|
+
'rail.turn': '第 {turn} 轮',
|
|
7
|
+
'rail.image': '图片消息',
|
|
8
|
+
'rail.attachment': '附件消息',
|
|
6
9
|
};
|
|
7
10
|
/** English dictionary, checked complete against the zh key set. */
|
|
8
11
|
export const en = {
|
|
9
12
|
'rail.panel': 'Message timeline',
|
|
10
|
-
'rail.empty': 'No
|
|
13
|
+
'rail.empty': 'No text preview',
|
|
14
|
+
'rail.turn': 'Turn {turn}',
|
|
15
|
+
'rail.image': 'Image message',
|
|
16
|
+
'rail.attachment': 'Attachment message',
|
|
11
17
|
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TurnOutlineEntry } from '@deepseek-ai/dsh-session-turn-outline/client';
|
|
2
|
+
export type { TurnOutlineEntry };
|
|
3
|
+
/**
|
|
4
|
+
* Narrow one wire projection value to outline entries. `turn` and `seq` are
|
|
5
|
+
* load-bearing — a row cannot exist or jump without them — so damage there
|
|
6
|
+
* drops the entry; the previews are decorative and degrade to ''. A non-array
|
|
7
|
+
* value (absent unit included) narrows to undefined.
|
|
8
|
+
* @param value - the raw `useProjection('turnOutline')` read.
|
|
9
|
+
* @returns the surviving entries in wire order, or undefined when absent.
|
|
10
|
+
*/
|
|
11
|
+
export declare function outlineEntries(value: unknown): readonly TurnOutlineEntry[] | undefined;
|
|
12
|
+
//# sourceMappingURL=outline.d.ts.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow one wire projection value to outline entries. `turn` and `seq` are
|
|
3
|
+
* load-bearing — a row cannot exist or jump without them — so damage there
|
|
4
|
+
* drops the entry; the previews are decorative and degrade to ''. A non-array
|
|
5
|
+
* value (absent unit included) narrows to undefined.
|
|
6
|
+
* @param value - the raw `useProjection('turnOutline')` read.
|
|
7
|
+
* @returns the surviving entries in wire order, or undefined when absent.
|
|
8
|
+
*/
|
|
9
|
+
export function outlineEntries(value) {
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return undefined;
|
|
12
|
+
const entries = [];
|
|
13
|
+
for (const raw of value) {
|
|
14
|
+
const entry = outlineEntry(raw);
|
|
15
|
+
if (entry !== undefined)
|
|
16
|
+
entries.push(entry);
|
|
17
|
+
}
|
|
18
|
+
return entries;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Narrow one wire entry. `turn` and `seq` must be non-negative safe integers
|
|
22
|
+
* (seq additionally rejects -0); the previews default to '' when malformed.
|
|
23
|
+
* @param value - one raw wire entry.
|
|
24
|
+
* @returns the entry, or undefined when a load-bearing field is damaged.
|
|
25
|
+
*/
|
|
26
|
+
function outlineEntry(value) {
|
|
27
|
+
if (typeof value !== 'object' || value === null)
|
|
28
|
+
return undefined;
|
|
29
|
+
const entry = value;
|
|
30
|
+
if (typeof entry.turn !== 'number' || !Number.isSafeInteger(entry.turn) || entry.turn < 0)
|
|
31
|
+
return undefined;
|
|
32
|
+
if (typeof entry.seq !== 'number' || !Number.isSafeInteger(entry.seq) || entry.seq < 0 || Object.is(entry.seq, -0))
|
|
33
|
+
return undefined;
|
|
34
|
+
return {
|
|
35
|
+
turn: entry.turn,
|
|
36
|
+
seq: entry.seq,
|
|
37
|
+
prompt: typeof entry.prompt === 'string' ? entry.prompt : '',
|
|
38
|
+
response: typeof entry.response === 'string' ? entry.response : '',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -78,8 +78,11 @@ export declare function jumpRow(scrollport: HTMLElement, key: string): boolean;
|
|
|
78
78
|
export interface RailTracker {
|
|
79
79
|
/** Live rail geometry and active marker for the current session. */
|
|
80
80
|
readonly state: HostObservable<TimelineRailState>;
|
|
81
|
-
/**
|
|
82
|
-
|
|
81
|
+
/**
|
|
82
|
+
* Scroll the transcript to the message addressed by `key`.
|
|
83
|
+
* @returns whether the row was found and scrolled (false while unbound).
|
|
84
|
+
*/
|
|
85
|
+
jumpTo(key: string): boolean;
|
|
83
86
|
/** Unbind every listener and observer. */
|
|
84
87
|
dispose(): void;
|
|
85
88
|
}
|
|
@@ -294,10 +294,7 @@ export function installRailTracker(ctx, includeSteering) {
|
|
|
294
294
|
return () => { listeners.delete(fn); };
|
|
295
295
|
},
|
|
296
296
|
},
|
|
297
|
-
jumpTo: (key) =>
|
|
298
|
-
if (scrollport !== null)
|
|
299
|
-
jumpRow(scrollport, key);
|
|
300
|
-
},
|
|
297
|
+
jumpTo: (key) => scrollport !== null && jumpRow(scrollport, key),
|
|
301
298
|
dispose: () => {
|
|
302
299
|
stopList();
|
|
303
300
|
stopProvide?.();
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rail row assembly: the merge of the loaded chat window's user rows with the
|
|
3
|
+
* host turnOutline projection into the full timeline. The outline names every
|
|
4
|
+
* started turn (its `turn/start` seq plus a bounded first-prompt preview); the
|
|
5
|
+
* loaded window supplies richer previews, steering rows, and message-tools
|
|
6
|
+
* bubbles for the turns it covers. A turn seen in the window renders its
|
|
7
|
+
* loaded rows and drops out of the outline; a turn outside the window renders
|
|
8
|
+
* as an outline row that pages history in on click.
|
|
9
|
+
* @module @khorsheed/dsh-message-timeline/client/rows
|
|
10
|
+
*/
|
|
11
|
+
import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
12
|
+
import type { TurnOutlineEntry } from './outline.ts';
|
|
13
|
+
import type { TimelineItem } from './slots.ts';
|
|
14
|
+
/** One loaded-window row: jumps straight to its transcript anchor. */
|
|
15
|
+
export interface LoadedRailRow {
|
|
16
|
+
readonly kind: 'loaded';
|
|
17
|
+
/** Row identity: the node key, which the tracker's active marker also speaks. */
|
|
18
|
+
readonly id: string;
|
|
19
|
+
/** The chat node key (the `data-chat-anchor-key` the jump targets). */
|
|
20
|
+
readonly key: string;
|
|
21
|
+
/** The underlying chat node, for content preview. */
|
|
22
|
+
readonly node: ChatConversationViewNode;
|
|
23
|
+
/** Transcript position (the node's anchor seq) — the merge key. */
|
|
24
|
+
readonly anchorSeq: number;
|
|
25
|
+
/** The owning outline turn, or undefined outside every outline range (a stale outline). */
|
|
26
|
+
readonly turn: number | undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One outline-only row: the turn's events are outside the loaded window, so
|
|
30
|
+
* the row previews from the wire entry and confirming it pages history back
|
|
31
|
+
* through the turn's `turn/start` seq before landing.
|
|
32
|
+
*/
|
|
33
|
+
export interface OutlineRailRow {
|
|
34
|
+
readonly kind: 'outline';
|
|
35
|
+
/** Row identity (`turn:<n>` — the prefix cannot collide with a node key). */
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly turn: number;
|
|
38
|
+
/** The turn's `turn/start` seq — the loadThrough target. */
|
|
39
|
+
readonly seq: TurnOutlineEntry['seq'];
|
|
40
|
+
/** Same position as `seq`, as the merge key. */
|
|
41
|
+
readonly anchorSeq: number;
|
|
42
|
+
/** Bounded first-prompt preview ('' for a textless prompt). */
|
|
43
|
+
readonly prompt: string;
|
|
44
|
+
}
|
|
45
|
+
/** One timeline row. */
|
|
46
|
+
export type RailRow = LoadedRailRow | OutlineRailRow;
|
|
47
|
+
/**
|
|
48
|
+
* The first visible loaded row of a turn, of any node kind: the landing
|
|
49
|
+
* target for a turn with no user-kind row (an automated turn renders a
|
|
50
|
+
* `turn-trigger` card, not a user bubble), mirroring the official rail's
|
|
51
|
+
* `user ?? loaded[0]` anchor. Only nodes in the host's visible `order`
|
|
52
|
+
* qualify — a store-only or hidden node is not a jump target.
|
|
53
|
+
* @param order - visible chat node keys in render order.
|
|
54
|
+
* @param nodes - the live node store.
|
|
55
|
+
* @param seq - the turn's `turn/start` seq (inclusive).
|
|
56
|
+
* @param end - the next turn's start seq (exclusive), or Infinity for the last turn.
|
|
57
|
+
* @returns the anchor key, or null when the turn has no visible loaded row.
|
|
58
|
+
*/
|
|
59
|
+
export declare function firstVisibleKeyInTurn(order: readonly string[], nodes: {
|
|
60
|
+
get(key: string): ChatConversationViewNode | undefined;
|
|
61
|
+
}, seq: number, end: number): string | null;
|
|
62
|
+
/**
|
|
63
|
+
* Merge loaded rows with the outline into the full row list. Both inputs are
|
|
64
|
+
* transcript-ordered (loaded rows anchorSeq-ascending, the outline strictly
|
|
65
|
+
* increasing by turn), so one pass interleaves them without a final sort: an
|
|
66
|
+
* outline entry whose seq range holds at least one loaded row is covered and
|
|
67
|
+
* emits nothing; every other entry emits its outline row at the range's
|
|
68
|
+
* position. A loaded row belongs to the last entry whose start it follows —
|
|
69
|
+
* when the window runs ahead of the projection feed, the newest rows read as
|
|
70
|
+
* the last named turn until the feed catches up (suppressing its outline row
|
|
71
|
+
* beats duplicating the turn).
|
|
72
|
+
* @param loaded - the window's user rows, anchorSeq-ascending.
|
|
73
|
+
* @param outline - narrowed outline entries, or undefined (capability absent).
|
|
74
|
+
* @returns the merged row list, transcript-ordered.
|
|
75
|
+
*/
|
|
76
|
+
export declare function mergeRows(loaded: readonly TimelineItem[], outline: readonly TurnOutlineEntry[] | undefined): readonly RailRow[];
|
|
77
|
+
//# sourceMappingURL=rows.d.ts.map
|