@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
|
@@ -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,22 @@ 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
|
+
/** Rest width of the narrow-layout tick strip (px) — fits the flow's left padding. */
|
|
78
|
+
const PANEL_NARROW_REST_WIDTH = 24;
|
|
79
|
+
/** Expanded width of the narrow-layout hover overlay (fraction of the scrollport). */
|
|
80
|
+
const NARROW_WIDE_RATIO = 0.6;
|
|
81
|
+
/**
|
|
82
|
+
* Frames an outline jump retries the DOM scroll after its row materializes in
|
|
83
|
+
* the chat store: the transcript renders the new page in the same commit or a
|
|
84
|
+
* near frame, and a half-second budget covers the skew without hanging.
|
|
85
|
+
*/
|
|
86
|
+
const JUMP_RETRY_FRAMES = 30;
|
|
37
87
|
/**
|
|
38
88
|
* The header-utilities entry: the portal timeline panel.
|
|
39
89
|
* @param props - composed props (see {@link TimelineRailProps}).
|
|
40
90
|
* @returns nothing visible in the seat; the floating panel while the chat view shows.
|
|
41
91
|
*/
|
|
42
|
-
export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t, ...standard }) {
|
|
92
|
+
export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPages, loadOlder, loadThrough, jumpTo, useRail, t, ...standard }) {
|
|
43
93
|
const rail = useRail(s => s);
|
|
44
94
|
// Chat data lives in the `useChat` standard prop on 0.1.2 (the rc-line
|
|
45
95
|
// session-snapshot seat is gone); the helper degrades to an empty slice
|
|
@@ -50,6 +100,13 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
50
100
|
const nodes = useChatSlice(c => c.nodes);
|
|
51
101
|
const hasMore = useSession(s => s.hasMore);
|
|
52
102
|
const loadingOlder = useSession(s => s.loadingOlder);
|
|
103
|
+
// The host projection seat exists on every supported host; the VALUE is
|
|
104
|
+
// present only where the assembly mounts the turn-outline unit. Both reads
|
|
105
|
+
// degrade to undefined, which keeps the window-paging list. The narrow is
|
|
106
|
+
// memoized on the projection's reference-stable value so unrelated renders
|
|
107
|
+
// do not churn the merged list (or re-run the jump effect).
|
|
108
|
+
const outlineValue = standard.useProjection?.('turnOutline');
|
|
109
|
+
const outline = useMemo(() => outlineEntries(outlineValue), [outlineValue]);
|
|
53
110
|
const items = useMemo(() => {
|
|
54
111
|
// The node store is an in-memory snapshot; take it once so the span fold
|
|
55
112
|
// and the message-tools append read the same snapshot.
|
|
@@ -74,7 +131,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
74
131
|
// ordinary session carries no span node, so the fold is empty and every
|
|
75
132
|
// order row still renders: this filter never alters the baseline.
|
|
76
133
|
const hidden = (node) => isSeqHidden(spans, node.anchorSeq);
|
|
77
|
-
const
|
|
134
|
+
const loaded = [];
|
|
78
135
|
const seen = new Set();
|
|
79
136
|
for (const key of order) {
|
|
80
137
|
const node = nodes.get(key);
|
|
@@ -84,7 +141,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
84
141
|
continue;
|
|
85
142
|
if (hidden(node))
|
|
86
143
|
continue;
|
|
87
|
-
|
|
144
|
+
loaded.push({ key, node });
|
|
88
145
|
seen.add(key);
|
|
89
146
|
}
|
|
90
147
|
// After a message-tools in-place edit the replacement materializes as a
|
|
@@ -105,7 +162,7 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
105
162
|
continue;
|
|
106
163
|
if (hidden(node))
|
|
107
164
|
continue;
|
|
108
|
-
|
|
165
|
+
loaded.push({ key: node.key, node });
|
|
109
166
|
seen.add(node.key);
|
|
110
167
|
}
|
|
111
168
|
// The order loop is seq-sorted, but the appended bubbles walked the store
|
|
@@ -116,38 +173,112 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
116
173
|
// ordinary session has no appended bubbles, so this never reorders the
|
|
117
174
|
// baseline (which is already seq-sorted). Dropped rows are gone before the
|
|
118
175
|
// sort, so a withdrawn original never resurfaces.
|
|
119
|
-
|
|
120
|
-
return
|
|
121
|
-
}, [order, nodes, includeSteering]);
|
|
176
|
+
loaded.sort((left, right) => left.node.anchorSeq - right.node.anchorSeq);
|
|
177
|
+
return mergeRows(loaded, outline);
|
|
178
|
+
}, [order, nodes, includeSteering, outline]);
|
|
122
179
|
// The lit row: the hover/arrow preselection while it moves, otherwise the
|
|
123
180
|
// live reading position the tracker publishes, defaulting to the latest
|
|
124
181
|
// message before the tracker answers. ArrowUp/Down or hovering moves a
|
|
125
|
-
// bold preselection; Enter or a click confirms it and jumps.
|
|
182
|
+
// bold preselection; Enter or a click confirms it and jumps. Loaded rows
|
|
183
|
+
// identify by their node key (the tracker's active marker speaks the same
|
|
184
|
+
// key); outline rows identify as `turn:<n>`.
|
|
126
185
|
const [focusKey, setFocusKey] = useState(null);
|
|
127
|
-
const current = focusKey ?? rail.activeKey ?? items.at(-1)?.
|
|
186
|
+
const current = focusKey ?? rail.activeKey ?? items.at(-1)?.id ?? null;
|
|
128
187
|
const panelRef = useRef(null);
|
|
188
|
+
const [pendingJump, setPendingJump] = useState(null);
|
|
189
|
+
// A jump never crosses sessions; the entry's session identity switches
|
|
190
|
+
// without a remount when the main view moves.
|
|
191
|
+
useEffect(() => { setPendingJump(null); }, [sessionId]);
|
|
192
|
+
// The window head: the first visible node's anchorSeq (ChatView's firstSeq
|
|
193
|
+
// derivation), the coverage line for the outline jump's re-request guard.
|
|
194
|
+
const firstSeq = useMemo(() => {
|
|
195
|
+
const firstKey = order[0];
|
|
196
|
+
if (firstKey === undefined)
|
|
197
|
+
return null;
|
|
198
|
+
return nodes.get(firstKey)?.anchorSeq ?? null;
|
|
199
|
+
}, [order, nodes]);
|
|
200
|
+
// The outline-jump state machine. Fire (click) only arms the jump; this
|
|
201
|
+
// effect owns every loadThrough request, the landing scroll, and the give-
|
|
202
|
+
// up fallback, re-running as the window, the paging flags, or the jump
|
|
203
|
+
// itself change. The loop converges because loadThrough pages until the
|
|
204
|
+
// window covers the target seq (or history runs out), and a settle that
|
|
205
|
+
// moved nothing falls through to the fallback instead of re-requesting.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (pendingJump === null || pendingJump.sessionId !== sessionId)
|
|
208
|
+
return;
|
|
209
|
+
// Scroll to a key once its transcript row renders (the same commit or a
|
|
210
|
+
// near frame); the returned cleanup cancels the retries.
|
|
211
|
+
const landOn = (key) => {
|
|
212
|
+
let cancelled = false;
|
|
213
|
+
let frames = 0;
|
|
214
|
+
const attempt = () => {
|
|
215
|
+
if (cancelled)
|
|
216
|
+
return;
|
|
217
|
+
frames += 1;
|
|
218
|
+
if (jumpTo(key) || frames >= JUMP_RETRY_FRAMES) {
|
|
219
|
+
setPendingJump(null);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
(typeof requestAnimationFrame === 'function' ? requestAnimationFrame : setTimeout)(attempt);
|
|
223
|
+
};
|
|
224
|
+
attempt();
|
|
225
|
+
return () => { cancelled = true; };
|
|
226
|
+
};
|
|
227
|
+
const landed = items.find((row) => row.kind === 'loaded' && row.turn === pendingJump.turn);
|
|
228
|
+
if (landed !== undefined)
|
|
229
|
+
return landOn(landed.key);
|
|
230
|
+
// No user-kind row for the turn: an automated turn (a restart/scheduled
|
|
231
|
+
// trigger renders as `turn-trigger`, not a user row) or a withdraw-hidden
|
|
232
|
+
// prompt. Land on the turn's first visible loaded node of any kind — the
|
|
233
|
+
// official rail's `user ?? loaded[0]` anchor.
|
|
234
|
+
const end = outline?.find(entry => entry.turn > pendingJump.turn)?.seq ?? Number.POSITIVE_INFINITY;
|
|
235
|
+
const anyKey = firstVisibleKeyInTurn(order, nodes, pendingJump.seq, end);
|
|
236
|
+
if (anyKey !== null)
|
|
237
|
+
return landOn(anyKey);
|
|
238
|
+
if (pendingJump.awaiting || loadingOlder)
|
|
239
|
+
return;
|
|
240
|
+
// Settled (or freshly armed) without any visible row for the turn: page
|
|
241
|
+
// again while the window head still moves and history remains; a stalled
|
|
242
|
+
// head lands on the nearest loaded row at/after the turn instead.
|
|
243
|
+
const uncovered = firstSeq === null || firstSeq > pendingJump.seq;
|
|
244
|
+
if (uncovered && hasMore && (!pendingJump.requested || pendingJump.fromHead !== firstSeq)) {
|
|
245
|
+
const jump = pendingJump;
|
|
246
|
+
setPendingJump({ ...jump, requested: true, awaiting: true, fromHead: firstSeq });
|
|
247
|
+
void loadThrough(jump.seq).then(() => {
|
|
248
|
+
setPendingJump(current => current !== null && current.sessionId === jump.sessionId && current.turn === jump.turn
|
|
249
|
+
? { ...current, awaiting: false }
|
|
250
|
+
: current);
|
|
251
|
+
});
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const fallback = items.find((row) => row.kind === 'loaded' && row.anchorSeq >= pendingJump.seq);
|
|
255
|
+
if (fallback !== undefined)
|
|
256
|
+
jumpTo(fallback.key);
|
|
257
|
+
setPendingJump(null);
|
|
258
|
+
}, [items, pendingJump, sessionId, loadingOlder, hasMore, firstSeq, jumpTo, loadThrough, order, nodes, outline]);
|
|
129
259
|
// The panel is a chat-view affordance: hide it while the session shows
|
|
130
260
|
// another tab (trajectory etc.), detected through ChatView's data-chat-flow
|
|
131
|
-
// marker.
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
261
|
+
// marker. With the outline the list starts whole; without it a session
|
|
262
|
+
// whose loaded event window holds no user message yet (a huge assistant
|
|
263
|
+
// turn pushed it past the first page) renders no rows but must still pull
|
|
264
|
+
// pages until one materializes — hence `active` splits from `visible`.
|
|
135
265
|
const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
|
|
136
266
|
const visible = active && items.length > 0;
|
|
137
|
-
// The panel must never cover the message flow: its width is the
|
|
138
|
-
// preferred width capped by the scrollport's left gutter — the
|
|
139
|
-
// flow's left edge minus the panel's left edge, less the panel's
|
|
140
|
-
// padding and the visible breathing gap. A gutter too small for the
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
267
|
+
// The panel must never cover the message flow AT REST: its width is the
|
|
268
|
+
// configured preferred width capped by the scrollport's left gutter — the
|
|
269
|
+
// message flow's left edge minus the panel's left edge, less the panel's
|
|
270
|
+
// right padding and the visible breathing gap. A gutter too small for the
|
|
271
|
+
// minimum width no longer hides the panel: it degrades to a tick strip in
|
|
272
|
+
// the flow's left padding and expands over the flow on hover/focus with a
|
|
273
|
+
// solid background (right sidebars and narrow windows keep the rail
|
|
274
|
+
// usable). When the flow probe is unanswered (official structure change),
|
|
275
|
+
// the width degrades to a fraction of the scrollport instead.
|
|
146
276
|
const gutter = rail.flowLeft === null ? null : rail.flowLeft - rail.left - PANEL_PADDING_X - PANEL_GAP;
|
|
277
|
+
const narrow = gutter !== null && gutter < PANEL_WIDTH_MIN;
|
|
147
278
|
const width = gutter === null
|
|
148
279
|
? Math.min(panelWidth, Math.max(PANEL_WIDTH_MIN, rail.scrollportWidth * DEGRADED_WIDTH_RATIO))
|
|
149
|
-
: Math.min(panelWidth,
|
|
150
|
-
const
|
|
280
|
+
: narrow ? PANEL_NARROW_REST_WIDTH : Math.min(panelWidth, gutter);
|
|
281
|
+
const wideWidth = Math.min(panelWidth, Math.max(PANEL_WIDTH_MIN, rail.scrollportWidth * NARROW_WIDE_RATIO));
|
|
151
282
|
// Keep the lit row in view: the panel follows the reading position (a new
|
|
152
283
|
// message scrolls its row in), and mouse browsing is never yanked because
|
|
153
284
|
// the hovered row is the current one and always visible under the pointer.
|
|
@@ -155,61 +286,92 @@ export function TimelineRail({ sessionId, includeSteering, panelWidth, initialPa
|
|
|
155
286
|
panelRef.current?.querySelector(`[data-item-key=${JSON.stringify(current)}]`)
|
|
156
287
|
?.scrollIntoView({ block: 'nearest' });
|
|
157
288
|
}, [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
|
|
289
|
+
// Fallback mode (no outline) prefetches history while the panel is on a
|
|
290
|
+
// chat view: keep pulling pages until the first user message materializes
|
|
291
|
+
// (bootstrap), then until initialPages pages arrived, so the list starts
|
|
292
|
+
// near-complete. Older pages load on demand when the panel scrolls to its
|
|
293
|
+
// top. The outline mode skips this entirely — the projection already names
|
|
294
|
+
// every turn, so no page has to arrive for the list to be complete.
|
|
162
295
|
const prefetchedPagesRef = useRef(0);
|
|
163
296
|
useEffect(() => {
|
|
164
297
|
if (!active) {
|
|
165
298
|
prefetchedPagesRef.current = 0;
|
|
166
299
|
return;
|
|
167
300
|
}
|
|
301
|
+
if (outline !== undefined)
|
|
302
|
+
return;
|
|
168
303
|
if (!hasMore || loadingOlder)
|
|
169
304
|
return;
|
|
170
305
|
if (items.length > 0 && prefetchedPagesRef.current >= initialPages)
|
|
171
306
|
return;
|
|
172
307
|
prefetchedPagesRef.current += 1;
|
|
173
308
|
void loadOlder();
|
|
174
|
-
}, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder]);
|
|
175
|
-
//
|
|
176
|
-
// view owns the load-older button, so the panel stays
|
|
309
|
+
}, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder, outline]);
|
|
310
|
+
// Fallback mode loads older history by scrolling the panel to its top; the
|
|
311
|
+
// official chat view owns the load-older button, so the panel stays
|
|
312
|
+
// chromeless. The outline mode's list is already complete.
|
|
177
313
|
const onPanelScroll = (event) => {
|
|
314
|
+
if (outline !== undefined)
|
|
315
|
+
return;
|
|
178
316
|
if (!hasMore || loadingOlder)
|
|
179
317
|
return;
|
|
180
318
|
if (event.currentTarget.scrollTop <= 8)
|
|
181
319
|
void loadOlder();
|
|
182
320
|
};
|
|
183
|
-
const confirm = (
|
|
321
|
+
const confirm = (row) => {
|
|
184
322
|
setFocusKey(null);
|
|
185
|
-
|
|
323
|
+
if (row.kind === 'loaded') {
|
|
324
|
+
// A direct jump supersedes any armed outline jump (its landing would
|
|
325
|
+
// otherwise yank the transcript later).
|
|
326
|
+
setPendingJump(null);
|
|
327
|
+
jumpTo(row.key);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// An outline row arms the jump; the effect above runs the paging and the
|
|
331
|
+
// landing scroll. Re-clicking another outline row retargets the jump.
|
|
332
|
+
setPendingJump({ sessionId, turn: row.turn, seq: row.seq, requested: false, awaiting: false, fromHead: null });
|
|
186
333
|
};
|
|
187
|
-
if (!visible
|
|
334
|
+
if (!visible)
|
|
188
335
|
return null;
|
|
189
|
-
return createPortal(_jsx("div", { ref: panelRef, className: css.panel
|
|
336
|
+
return createPortal(_jsx("div", { ref: panelRef, className: `${css.panel} ${narrow ? css.panelNarrow : ''}`, role: "navigation", "aria-label": t('rail.panel'), tabIndex: 0, "data-timeline-panel": "", style: {
|
|
337
|
+
// The box fits its rows (up to the rail height) and centers on the
|
|
338
|
+
// rail's midline, so the narrow-mode hover background wraps the list
|
|
339
|
+
// instead of painting the whole column; a longer list fills the rail
|
|
340
|
+
// and scrolls.
|
|
341
|
+
left: rail.left,
|
|
342
|
+
top: rail.top + rail.height / 2,
|
|
343
|
+
maxHeight: rail.height,
|
|
344
|
+
transform: 'translateY(-50%)',
|
|
345
|
+
width,
|
|
346
|
+
...(narrow ? { '--panel-wide': `${wideWidth}px` } : {}),
|
|
347
|
+
}, onScroll: onPanelScroll, onKeyDown: (event) => {
|
|
190
348
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
191
349
|
event.preventDefault();
|
|
192
|
-
const anchor = items.findIndex(item => item.
|
|
350
|
+
const anchor = items.findIndex(item => item.id === current);
|
|
193
351
|
const next = event.key === 'ArrowDown'
|
|
194
352
|
? items[Math.min(anchor + 1, items.length - 1)]
|
|
195
353
|
: items[Math.max(anchor - 1, 0)];
|
|
196
354
|
/* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
|
|
197
355
|
if (next !== undefined)
|
|
198
|
-
setFocusKey(next.
|
|
356
|
+
setFocusKey(next.id);
|
|
199
357
|
}
|
|
200
358
|
else if (event.key === 'Enter' || event.key === ' ') {
|
|
201
359
|
event.preventDefault();
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
confirm(
|
|
360
|
+
const row = items.find(item => item.id === current);
|
|
361
|
+
if (row !== undefined)
|
|
362
|
+
confirm(row);
|
|
205
363
|
}
|
|
206
|
-
}, onBlur: () => { setFocusKey(null); }, children: items.map((
|
|
207
|
-
const isCurrent =
|
|
208
|
-
const isFocused =
|
|
364
|
+
}, onBlur: () => { setFocusKey(null); }, children: items.map((row) => {
|
|
365
|
+
const isCurrent = row.id === current;
|
|
366
|
+
const isFocused = row.id === focusKey;
|
|
367
|
+
const isPending = row.kind === 'outline' && pendingJump !== null && pendingJump.turn === row.turn;
|
|
209
368
|
const className = isFocused
|
|
210
369
|
? `${css.item} ${css.itemFocused}`
|
|
211
370
|
: isCurrent ? `${css.item} ${css.itemCurrent}` : css.item;
|
|
212
|
-
|
|
371
|
+
// Outline rows share the plain styling; the pending pulse is the only
|
|
372
|
+
// transient cue that a row pages history in before landing.
|
|
373
|
+
const classNames = isPending ? `${className} ${css.itemPending}` : className;
|
|
374
|
+
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
375
|
}) }), document.body);
|
|
214
376
|
}
|
|
215
377
|
/** 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
|
+
}
|
|
@@ -25,7 +25,7 @@ import type { TimelineRailState } from './slots.ts';
|
|
|
25
25
|
* whole `[data-composer-seat]` top: dock cards (goal/todo/queue) sit above
|
|
26
26
|
* the input inside the seat, and counting them would push the timeline up off
|
|
27
27
|
* the conversation. Falls back to the seat top, then the column bottom, when
|
|
28
|
-
*
|
|
28
|
+
* no laid-out card is rendered (see {@link laidOutCard}).
|
|
29
29
|
* The scrollport's own width rides along for the width-cap fallback when the
|
|
30
30
|
* message-flow probe is unanswered.
|
|
31
31
|
* @param scrollport - the official conversation scrollport element.
|
|
@@ -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
|
}
|
|
@@ -14,6 +14,26 @@ const IDLE = {
|
|
|
14
14
|
sessionId: undefined, ready: false, left: 0, top: 0, height: 0, scrollportWidth: 0, flowLeft: null,
|
|
15
15
|
activeKey: null, chatView: false,
|
|
16
16
|
};
|
|
17
|
+
/**
|
|
18
|
+
* The rect of the first laid-out chat input card in the scrollport. A
|
|
19
|
+
* business composer (the Room composer) takes the `conversation.composer`
|
|
20
|
+
* chain as an overlay: the official input bar stays mounted under a
|
|
21
|
+
* `display: none` fallback wrapper, so its card is still the first marker in
|
|
22
|
+
* the DOM but measures as a zero rect at y=0 — ending the box there collapses
|
|
23
|
+
* the panel to zero height. Unlaid-out cards are skipped, so such a layout
|
|
24
|
+
* falls through to the seat top.
|
|
25
|
+
* @param scrollport - the official conversation scrollport element.
|
|
26
|
+
* @returns the card's rect, or null when no laid-out card is rendered.
|
|
27
|
+
*/
|
|
28
|
+
function laidOutCard(scrollport) {
|
|
29
|
+
for (const card of scrollport.querySelectorAll('[data-composer-card]')) {
|
|
30
|
+
const rect = card.getBoundingClientRect();
|
|
31
|
+
if (rect.width === 0 && rect.height === 0)
|
|
32
|
+
continue;
|
|
33
|
+
return rect;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
17
37
|
/**
|
|
18
38
|
* Measure the panel's viewport box from one scrollport: its rect inset by the
|
|
19
39
|
* panel padding, minus the chat input card at the bottom and the conversation
|
|
@@ -24,7 +44,7 @@ const IDLE = {
|
|
|
24
44
|
* whole `[data-composer-seat]` top: dock cards (goal/todo/queue) sit above
|
|
25
45
|
* the input inside the seat, and counting them would push the timeline up off
|
|
26
46
|
* the conversation. Falls back to the seat top, then the column bottom, when
|
|
27
|
-
*
|
|
47
|
+
* no laid-out card is rendered (see {@link laidOutCard}).
|
|
28
48
|
* The scrollport's own width rides along for the width-cap fallback when the
|
|
29
49
|
* message-flow probe is unanswered.
|
|
30
50
|
* @param scrollport - the official conversation scrollport element.
|
|
@@ -44,10 +64,10 @@ export function measureGeometry(scrollport) {
|
|
|
44
64
|
break;
|
|
45
65
|
}
|
|
46
66
|
}
|
|
47
|
-
const card = scrollport
|
|
67
|
+
const card = laidOutCard(scrollport);
|
|
48
68
|
const seat = scrollport.querySelector('[data-composer-seat]');
|
|
49
69
|
const bottom = card !== null
|
|
50
|
-
? card.
|
|
70
|
+
? card.top - RAIL_VERTICAL_PADDING
|
|
51
71
|
: seat !== null
|
|
52
72
|
? seat.getBoundingClientRect().top - RAIL_VERTICAL_PADDING
|
|
53
73
|
: rect.bottom - RAIL_VERTICAL_PADDING;
|
|
@@ -294,10 +314,7 @@ export function installRailTracker(ctx, includeSteering) {
|
|
|
294
314
|
return () => { listeners.delete(fn); };
|
|
295
315
|
},
|
|
296
316
|
},
|
|
297
|
-
jumpTo: (key) =>
|
|
298
|
-
if (scrollport !== null)
|
|
299
|
-
jumpRow(scrollport, key);
|
|
300
|
-
},
|
|
317
|
+
jumpTo: (key) => scrollport !== null && jumpRow(scrollport, key),
|
|
301
318
|
dispose: () => {
|
|
302
319
|
stopList();
|
|
303
320
|
stopProvide?.();
|