@lament_z/dsh-client-ui-chat-timeline 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +1 -0
- package/README.md +25 -8
- package/README.zh.md +24 -8
- package/lib/client.js +228 -179
- package/lib/client.js.map +1 -1
- package/lib/types/client/directory.d.ts +4 -0
- package/lib/types/client/dom.d.ts +52 -19
- package/lib/types/client/index.d.ts +1 -1
- package/lib/types/client/source.d.ts +50 -15
- package/lib/types/client/styles.d.ts +3 -1
- package/package.json +8 -10
package/lib/client.js
CHANGED
|
@@ -7,87 +7,12 @@ window.__ModuleLoader__.load({
|
|
|
7
7
|
let react = require("react");
|
|
8
8
|
let react_dom = require("react-dom");
|
|
9
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
|
-
/** Human node kinds that anchor a tick. */
|
|
11
|
-
const HUMAN_KINDS = /* @__PURE__ */ new Set(["user", "steering"]);
|
|
12
|
-
/** Extract concatenated text from a ContentBlock-like list.
|
|
13
|
-
* dsh-llm content blocks switch on `type`; assistant blocks use `kind` — accept both. */
|
|
14
|
-
function blocksText(blocks) {
|
|
15
|
-
if (!Array.isArray(blocks)) return "";
|
|
16
|
-
const parts = [];
|
|
17
|
-
for (const block of blocks) if (block !== null && typeof block === "object") {
|
|
18
|
-
const record = block;
|
|
19
|
-
if (record.kind === "text" || record.type === "text") {
|
|
20
|
-
if (typeof record.text === "string" && record.text.length > 0) parts.push(record.text);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return parts.join("\n\n");
|
|
24
|
-
}
|
|
25
|
-
/** Collapse each paragraph's whitespace and drop empties (ZCode u5e parity). */
|
|
26
|
-
function collapseParagraphs(text, maxParagraphs) {
|
|
27
|
-
return text.trim().split(/\n\s*\n/u).map((paragraph) => paragraph.replace(/\s+/gu, " ").trim()).filter(Boolean).slice(0, Math.max(1, maxParagraphs));
|
|
28
|
-
}
|
|
29
|
-
/** Truncate to the budget with an ellipsis, never below the floor (ZCode d5e parity). */
|
|
30
|
-
function truncatePreview(text, maxChars, floor = 8) {
|
|
31
|
-
const budget = Math.max(floor, maxChars);
|
|
32
|
-
if (text.length <= budget) return text;
|
|
33
|
-
return `${text.slice(0, budget - 3).trimEnd()}...`;
|
|
34
|
-
}
|
|
35
|
-
/** Build one preview string from raw texts under the shared budget. */
|
|
36
|
-
function buildPreview(texts, fallback) {
|
|
37
|
-
const paragraphs = collapseParagraphs(texts.join("\n\n"), 2);
|
|
38
|
-
if (paragraphs.length === 0) return fallback;
|
|
39
|
-
return truncatePreview(paragraphs.join("\n"), 220);
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Derive the tick directory from the snapshot's ordered node list.
|
|
43
|
-
* @param nodes - conversation snapshot nodes in seq order.
|
|
44
|
-
* @param running - whether the session currently has a running turn.
|
|
45
|
-
* @param labels - localized preview fallbacks.
|
|
46
|
-
* @returns one item per human input, in order.
|
|
47
|
-
*/
|
|
48
|
-
function buildTimelineItems(nodes, running, labels) {
|
|
49
|
-
const drafts = [];
|
|
50
|
-
for (const node of nodes) {
|
|
51
|
-
if (HUMAN_KINDS.has(node.kind)) {
|
|
52
|
-
const text = blocksText(node.content);
|
|
53
|
-
drafts.push({
|
|
54
|
-
key: `n${node.seq}`,
|
|
55
|
-
seq: node.seq,
|
|
56
|
-
time: typeof node.time === "number" ? node.time : 0,
|
|
57
|
-
userTexts: text === "" ? [] : [text],
|
|
58
|
-
hasUserText: text !== "",
|
|
59
|
-
assistantTexts: []
|
|
60
|
-
});
|
|
61
|
-
continue;
|
|
62
|
-
}
|
|
63
|
-
if (node.kind !== "assistant" || drafts.length === 0) continue;
|
|
64
|
-
const text = blocksText(node.blocks);
|
|
65
|
-
if (text !== "") drafts[drafts.length - 1].assistantTexts.push(text);
|
|
66
|
-
}
|
|
67
|
-
const last = drafts.length - 1;
|
|
68
|
-
return drafts.map((draft, index) => {
|
|
69
|
-
const isLive = running && index === last;
|
|
70
|
-
const assistantTexts = draft.assistantTexts;
|
|
71
|
-
const assistantKind = assistantTexts.length > 0 ? "text" : isLive ? "running" : "empty";
|
|
72
|
-
return {
|
|
73
|
-
key: draft.key,
|
|
74
|
-
seq: draft.seq,
|
|
75
|
-
time: draft.time,
|
|
76
|
-
userPreview: buildPreview(draft.userTexts, labels.userFallback),
|
|
77
|
-
userFallback: !draft.hasUserText,
|
|
78
|
-
assistantPreview: buildPreview(assistantTexts, isLive ? labels.assistantRunning : labels.assistantEmpty),
|
|
79
|
-
assistantKind,
|
|
80
|
-
running: isLive
|
|
81
|
-
};
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
//#endregion
|
|
85
10
|
//#region src/client/dom.ts
|
|
86
11
|
/**
|
|
87
12
|
* Find the chat scroll container with a candidate chain.
|
|
88
13
|
* 1. the explicit stable hook `[data-conversation-scroll]`;
|
|
89
14
|
* 2. the closest scrollable ancestor of the flow column `[data-chat-flow]`;
|
|
90
|
-
* 3. the largest scrollable element that contains
|
|
15
|
+
* 3. the largest scrollable element that contains keyed flow items.
|
|
91
16
|
*/
|
|
92
17
|
function findChatContainer(doc) {
|
|
93
18
|
const direct = doc.querySelector("[data-conversation-scroll]");
|
|
@@ -99,7 +24,7 @@ window.__ModuleLoader__.load({
|
|
|
99
24
|
let bestHeight = 0;
|
|
100
25
|
for (const element of Array.from(doc.querySelectorAll("*"))) {
|
|
101
26
|
if (!isScrollable(element)) continue;
|
|
102
|
-
if (element.querySelector("[data-chat-
|
|
27
|
+
if (element.querySelector("[data-chat-turn]") === null) continue;
|
|
103
28
|
const height = element.clientHeight;
|
|
104
29
|
if (height > bestHeight) {
|
|
105
30
|
best = element;
|
|
@@ -122,70 +47,117 @@ window.__ModuleLoader__.load({
|
|
|
122
47
|
}
|
|
123
48
|
return null;
|
|
124
49
|
}
|
|
125
|
-
/**
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The rendered row of `turn` (native anchor attribute `data-chat-turn`),
|
|
52
|
+
* skipping hidden rows like the native `anchorElement`. Scoped to the chat
|
|
53
|
+
* container when it is already located (native scopes to its own list), so a
|
|
54
|
+
* stray second mount cannot hijack the anchor. Null when the turn's events
|
|
55
|
+
* are not in the loaded window yet.
|
|
56
|
+
*/
|
|
57
|
+
function findTurnRow(doc, turn) {
|
|
58
|
+
if (turn === void 0 || !Number.isSafeInteger(turn) || turn < 0) return null;
|
|
59
|
+
return (findChatContainer(doc) ?? doc).querySelector(`[data-chat-turn="${turn}"]:not([hidden])`);
|
|
60
|
+
}
|
|
61
|
+
/** Native `scrollerOf`: the conversation scrollport owning the row. */
|
|
62
|
+
function scrollerOfRow(doc, row) {
|
|
63
|
+
return row.closest("[data-conversation-scroll]") ?? findChatContainer(doc);
|
|
64
|
+
}
|
|
65
|
+
/** Row position in scrollport coordinates (native `flowTop`). */
|
|
66
|
+
function flowTop(row, scroller) {
|
|
67
|
+
return row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
|
|
129
68
|
}
|
|
130
69
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* "unit at viewport top" rule; clamps to the last row at scroll bottom.
|
|
70
|
+
* Native `landOnRow`: instant scroll so the row's top sits 24px below the
|
|
71
|
+
* scrollport top. Returns false when the scrollport cannot be resolved.
|
|
134
72
|
*/
|
|
135
|
-
function
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
else break;
|
|
141
|
-
if (container.scrollHeight - container.scrollTop - container.clientHeight <= 4) return rows.length - 1;
|
|
142
|
-
return active;
|
|
73
|
+
function landOnRow(doc, row) {
|
|
74
|
+
const el = scrollerOfRow(doc, row);
|
|
75
|
+
if (el === null) return false;
|
|
76
|
+
el.scrollTop += flowTop(row, el) - 24;
|
|
77
|
+
return true;
|
|
143
78
|
}
|
|
144
79
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* @returns false when the container or row is unavailable.
|
|
80
|
+
* Native `turnAtLine`: the turn owning the row at a scrollport line.
|
|
81
|
+
* Hit-tests the line first (elementsFromPoint at the container's horizontal
|
|
82
|
+
* center) and falls back to one row scan when layout cannot answer (jsdom,
|
|
83
|
+
* pre-paint). Returns null when no loaded row covers the line.
|
|
150
84
|
*/
|
|
151
|
-
function
|
|
152
|
-
const
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const target = container.scrollTop + delta;
|
|
158
|
-
if (behavior === "auto") {
|
|
159
|
-
container.scrollTo({
|
|
160
|
-
top: target,
|
|
161
|
-
behavior: "auto"
|
|
162
|
-
});
|
|
163
|
-
return true;
|
|
85
|
+
function turnAtLine(doc, container, line) {
|
|
86
|
+
const content = container.getBoundingClientRect();
|
|
87
|
+
if (typeof doc.elementsFromPoint === "function" && content.width > 0) for (const element of doc.elementsFromPoint(content.left + content.width / 2, line)) {
|
|
88
|
+
const row = element instanceof HTMLElement ? element.closest("[data-chat-turn]") : null;
|
|
89
|
+
const turn = Number(row?.dataset.chatTurn);
|
|
90
|
+
if (row !== null && container.contains(row) && Number.isSafeInteger(turn)) return turn;
|
|
164
91
|
}
|
|
165
|
-
|
|
166
|
-
container.
|
|
167
|
-
top
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
92
|
+
let found = null;
|
|
93
|
+
for (const row of Array.from(container.querySelectorAll("[data-chat-turn]"))) {
|
|
94
|
+
if (row.getBoundingClientRect().top > line) break;
|
|
95
|
+
const turn = Number(row.dataset.chatTurn);
|
|
96
|
+
if (Number.isSafeInteger(turn)) found = turn;
|
|
97
|
+
}
|
|
98
|
+
return found;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The active index (native `syncActiveTurn`): within 25px of the scroll
|
|
102
|
+
* bottom the last tick wins; otherwise the reading line sits at
|
|
103
|
+
* `top + min(96, height * 0.2)` and the active tick is the last one whose
|
|
104
|
+
* turn does not exceed the turn read at that line. When the line covers no
|
|
105
|
+
* loaded row, earlier ticks outside the window count as above the viewport
|
|
106
|
+
* (the window head's predecessor is active, matching 0.1.1 parity).
|
|
107
|
+
*/
|
|
108
|
+
function computeActiveIndex(container, doc, turns) {
|
|
109
|
+
if (turns.length === 0) return -1;
|
|
110
|
+
if (container.scrollHeight - container.scrollTop - container.clientHeight <= 25) return turns.length - 1;
|
|
111
|
+
const firstDefined = turns.findIndex((turn) => turn !== void 0);
|
|
112
|
+
if (firstDefined === -1) return -1;
|
|
113
|
+
const readTurn = turnAtLine(doc, container, container.getBoundingClientRect().top + Math.min(96, container.clientHeight * .2));
|
|
114
|
+
let active = -1;
|
|
115
|
+
if (readTurn !== null) for (let index = 0; index < turns.length; index += 1) {
|
|
116
|
+
const turn = turns[index];
|
|
117
|
+
if (turn === void 0 || turn > readTurn) continue;
|
|
118
|
+
active = index;
|
|
119
|
+
}
|
|
120
|
+
if (active !== -1) return active;
|
|
121
|
+
return firstDefined > 0 ? firstDefined - 1 : 0;
|
|
122
|
+
}
|
|
123
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
124
|
+
/** Landing poll budget after `loadThrough` (native settles via its effects; 5s here). */
|
|
125
|
+
const LAND_POLL_ATTEMPTS = 20;
|
|
126
|
+
const LAND_POLL_INTERVAL_MS = 250;
|
|
127
|
+
/**
|
|
128
|
+
* 跳到第 index 个刻度的完整落点流程(原生 navigateToTurn 的插件侧等价物):
|
|
129
|
+
* 1. 按 `data-chat-turn` 找行并原生落位(瞬时、行顶距视口顶 24px);
|
|
130
|
+
* 2. 行未挂载时先 `loadThrough(turn/start 的 seq)` 把事件窗口翻页到位;
|
|
131
|
+
* 3. 轮询等行渲染后落位。任何一步成功即返回 true。
|
|
132
|
+
*/
|
|
133
|
+
async function jumpToTurn(doc, index, turns, seqs, loadThrough) {
|
|
134
|
+
const turn = turns[index];
|
|
135
|
+
if (turn === void 0) return false;
|
|
136
|
+
if (landOnRowByTurn(doc, turn)) return true;
|
|
137
|
+
const seq = seqs[index];
|
|
138
|
+
if (seq !== void 0 && loadThrough !== void 0) try {
|
|
139
|
+
await loadThrough(seq);
|
|
140
|
+
} catch {}
|
|
141
|
+
for (let attempt = 0; attempt < LAND_POLL_ATTEMPTS; attempt += 1) {
|
|
142
|
+
await sleep(LAND_POLL_INTERVAL_MS);
|
|
143
|
+
if (landOnRowByTurn(doc, turn)) return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
function landOnRowByTurn(doc, turn) {
|
|
148
|
+
const row = findTurnRow(doc, turn);
|
|
149
|
+
if (row === null) return false;
|
|
150
|
+
return landOnRow(doc, row);
|
|
177
151
|
}
|
|
178
152
|
/** Create the probe against a document (injectable for tests). */
|
|
179
153
|
function probeChatDom(doc) {
|
|
180
154
|
return {
|
|
181
155
|
getContainer: () => findChatContainer(doc),
|
|
182
|
-
|
|
183
|
-
activeIndex: () => {
|
|
156
|
+
activeIndex: (turns) => {
|
|
184
157
|
const container = findChatContainer(doc);
|
|
185
158
|
if (container === null) return -1;
|
|
186
|
-
return computeActiveIndex(container,
|
|
187
|
-
}
|
|
188
|
-
jumpTo: (index, behavior) => jumpTo(index, behavior)
|
|
159
|
+
return computeActiveIndex(container, doc, turns);
|
|
160
|
+
}
|
|
189
161
|
};
|
|
190
162
|
}
|
|
191
163
|
//#endregion
|
|
@@ -213,7 +185,9 @@ window.__ModuleLoader__.load({
|
|
|
213
185
|
.dsh-tl-scroll {
|
|
214
186
|
position: absolute;
|
|
215
187
|
left: 12px;
|
|
216
|
-
|
|
188
|
+
/* 原生 TurnNavigator 定位(eGxaPq_frame):中心 = 内容顶 + (视口高 − 输入框高)/2,
|
|
189
|
+
即 --turn-rail-band 的中点;centerY 由 rail 按同一公式写入。 */
|
|
190
|
+
top: var(--dsh-tl-center-y, 50%);
|
|
217
191
|
transform: translateY(-50%);
|
|
218
192
|
width: 36px;
|
|
219
193
|
max-height: calc(100% - 96px);
|
|
@@ -272,9 +246,11 @@ window.__ModuleLoader__.load({
|
|
|
272
246
|
max-width: calc(100vw - 2rem);
|
|
273
247
|
padding: 12px;
|
|
274
248
|
border-radius: 10px;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
249
|
+
/* 原生 tooltip 配对(dsh-web-frontend _bubble_):灰底 + 静态白字,
|
|
250
|
+
主题切换由别名变量跟随;灰底与页面背景天然分层。 */
|
|
251
|
+
background: var(--dsw-alias-tooltip-bg, #43454a);
|
|
252
|
+
color: var(--dsw-static-neutral-bluish-00, #fff);
|
|
253
|
+
border: none;
|
|
278
254
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
279
255
|
pointer-events: none;
|
|
280
256
|
}
|
|
@@ -298,11 +274,18 @@ window.__ModuleLoader__.load({
|
|
|
298
274
|
white-space: pre-line;
|
|
299
275
|
font-size: 13px;
|
|
300
276
|
line-height: 20px;
|
|
301
|
-
|
|
277
|
+
color: var(--dsw-static-neutral-bluish-300, rgba(255, 255, 255, 0.8));
|
|
302
278
|
}
|
|
303
279
|
.dsh-tl-tip-assistant[data-kind="running"],
|
|
304
280
|
.dsh-tl-tip-assistant[data-kind="empty"] {
|
|
305
|
-
|
|
281
|
+
color: var(--dsw-static-neutral-bluish-400, rgba(255, 255, 255, 0.55));
|
|
282
|
+
}
|
|
283
|
+
/* 插件完全接管原生右侧 Turn 导航条:本样式表只随插件存在,插件卸载时
|
|
284
|
+
removeStyles 移除标签,原生导航条立即恢复。aria-label 跟随宿主语言
|
|
285
|
+
(英文 Turn navigation / 中文 轮次导航),两个都要盖住。 */
|
|
286
|
+
nav[aria-label='Turn navigation'],
|
|
287
|
+
nav[aria-label='轮次导航'] {
|
|
288
|
+
display: none !important;
|
|
306
289
|
}
|
|
307
290
|
`;
|
|
308
291
|
/** Inject the stylesheet once per document. */
|
|
@@ -315,6 +298,10 @@ window.__ModuleLoader__.load({
|
|
|
315
298
|
tag.textContent = TIMELINE_STYLES;
|
|
316
299
|
doc.head.appendChild(tag);
|
|
317
300
|
}
|
|
301
|
+
/** Remove the stylesheet (plugin teardown): restores the native turn navigator. */
|
|
302
|
+
function removeStyles(doc) {
|
|
303
|
+
doc.querySelector("style[data-plugin-css=\"chat-timeline\"]")?.remove();
|
|
304
|
+
}
|
|
318
305
|
//#endregion
|
|
319
306
|
//#region src/client/rail.tsx
|
|
320
307
|
/**
|
|
@@ -327,13 +314,11 @@ window.__ModuleLoader__.load({
|
|
|
327
314
|
*/
|
|
328
315
|
/** Slot height per tick (10px pitch, ZCode parity). */
|
|
329
316
|
const ITEM_PITCH = 10;
|
|
330
|
-
/** Container width below which the rail hides. ZCode uses 864px, but DSH's
|
|
331
|
-
* three-pane layout leaves the conversation narrower — the rail overlays the
|
|
332
|
-
* left edge without taking layout space, so only truly cramped panes hide. */
|
|
333
|
-
const MIN_CONTAINER_WIDTH = 560;
|
|
334
317
|
/** Preview-card hover delays (ms). */
|
|
335
318
|
const TIP_OPEN_DELAY = 120;
|
|
336
319
|
const TIP_CLOSE_DELAY = 80;
|
|
320
|
+
/** Default composer height when the host variable is absent (native fallback). */
|
|
321
|
+
const COMPOSER_HEIGHT_FALLBACK = 152;
|
|
337
322
|
/** Ripple visual per distance from the hovered tick (ZCode _5e parity).
|
|
338
323
|
* The ripple is interaction-only: at rest every tick is idle (scaleX 1),
|
|
339
324
|
* and the current position is signalled by color/opacity, not length. */
|
|
@@ -364,18 +349,6 @@ window.__ModuleLoader__.load({
|
|
|
364
349
|
* through system colors). */
|
|
365
350
|
const TICK_COLOR_FOREGROUND = "CanvasText";
|
|
366
351
|
const TICK_COLOR_SUBTLE = "color-mix(in srgb, CanvasText 42%, transparent)";
|
|
367
|
-
function useReducedMotion() {
|
|
368
|
-
const [reduced, setReduced] = (0, react.useState)(false);
|
|
369
|
-
(0, react.useEffect)(() => {
|
|
370
|
-
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
371
|
-
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
372
|
-
const update = () => setReduced(query.matches);
|
|
373
|
-
update();
|
|
374
|
-
query.addEventListener("change", update);
|
|
375
|
-
return () => query.removeEventListener("change", update);
|
|
376
|
-
}, []);
|
|
377
|
-
return reduced;
|
|
378
|
-
}
|
|
379
352
|
/**
|
|
380
353
|
* Render the question navigator rail.
|
|
381
354
|
* @param props - composed slot props.
|
|
@@ -383,7 +356,6 @@ window.__ModuleLoader__.load({
|
|
|
383
356
|
*/
|
|
384
357
|
function TimelineRail({ source, t }) {
|
|
385
358
|
const state = (0, react.useSyncExternalStore)(source.subscribe, source.getSnapshot);
|
|
386
|
-
const reducedMotion = useReducedMotion();
|
|
387
359
|
const probe = (0, react.useMemo)(() => typeof document === "undefined" ? null : probeChatDom(document), []);
|
|
388
360
|
const [containerVersion, setContainerVersion] = (0, react.useState)(0);
|
|
389
361
|
const [rect, setRect] = (0, react.useState)(null);
|
|
@@ -395,16 +367,22 @@ window.__ModuleLoader__.load({
|
|
|
395
367
|
close: void 0
|
|
396
368
|
});
|
|
397
369
|
const frame = (0, react.useRef)(0);
|
|
398
|
-
const labels = (0, react.useMemo)(() => ({
|
|
399
|
-
userFallback: t("preview.userFallback"),
|
|
400
|
-
assistantEmpty: t("preview.assistantEmpty"),
|
|
401
|
-
assistantRunning: t("preview.assistantRunning")
|
|
402
|
-
}), [t]);
|
|
403
370
|
const snapshot = state.snapshot;
|
|
404
|
-
const items = (0, react.useMemo)(() => snapshot === null ? [] :
|
|
371
|
+
const items = (0, react.useMemo)(() => snapshot === null ? [] : snapshot.items, [snapshot]);
|
|
372
|
+
const sourceRef = (0, react.useRef)(source);
|
|
373
|
+
sourceRef.current = source;
|
|
374
|
+
const itemsRef = (0, react.useRef)([]);
|
|
375
|
+
itemsRef.current = items;
|
|
376
|
+
const seqs = (0, react.useMemo)(() => items.map((item) => item.seq), [items]);
|
|
377
|
+
const seqsRef = (0, react.useRef)([]);
|
|
378
|
+
seqsRef.current = seqs;
|
|
379
|
+
const turns = (0, react.useMemo)(() => items.map((item) => item.turn), [items]);
|
|
380
|
+
const turnsRef = (0, react.useRef)([]);
|
|
381
|
+
turnsRef.current = turns;
|
|
405
382
|
(0, react.useEffect)(() => {
|
|
406
383
|
if (typeof document === "undefined") return;
|
|
407
384
|
ensureStyles(document);
|
|
385
|
+
return () => removeStyles(document);
|
|
408
386
|
}, []);
|
|
409
387
|
(0, react.useEffect)(() => {
|
|
410
388
|
if (probe === null) return;
|
|
@@ -435,13 +413,20 @@ window.__ModuleLoader__.load({
|
|
|
435
413
|
frame.current = requestAnimationFrame(() => {
|
|
436
414
|
frame.current = 0;
|
|
437
415
|
const box = container.getBoundingClientRect();
|
|
416
|
+
const style = getComputedStyle(container);
|
|
417
|
+
const composerHeight = Number.parseFloat(style.getPropertyValue("--dsh-composer-height")) || COMPOSER_HEIGHT_FALLBACK;
|
|
418
|
+
const viewportHeight = Number.parseFloat(style.getPropertyValue("--dsh-conversation-viewport-height")) || window.innerHeight;
|
|
419
|
+
const slot = document.querySelector("nav[aria-label=\"Turn navigation\"]")?.parentElement;
|
|
420
|
+
const slotTop = slot !== null && slot !== void 0 ? slot.getBoundingClientRect().top : box.top;
|
|
421
|
+
const bandCenter = (viewportHeight - composerHeight) / 2;
|
|
438
422
|
setRect({
|
|
439
423
|
left: box.left,
|
|
440
424
|
top: box.top,
|
|
441
425
|
height: box.height,
|
|
442
|
-
width: box.width
|
|
426
|
+
width: box.width,
|
|
427
|
+
centerY: Math.max(0, slotTop - box.top + bandCenter)
|
|
443
428
|
});
|
|
444
|
-
setActiveIndex(probe.activeIndex());
|
|
429
|
+
setActiveIndex(probe.activeIndex(turnsRef.current));
|
|
445
430
|
});
|
|
446
431
|
};
|
|
447
432
|
sync();
|
|
@@ -457,7 +442,16 @@ window.__ModuleLoader__.load({
|
|
|
457
442
|
frame.current = 0;
|
|
458
443
|
};
|
|
459
444
|
}, [probe, containerVersion]);
|
|
460
|
-
|
|
445
|
+
(0, react.useEffect)(() => {
|
|
446
|
+
if (probe === null || containerVersion === 0 || rect === null) return;
|
|
447
|
+
setActiveIndex(probe.activeIndex(turns));
|
|
448
|
+
}, [
|
|
449
|
+
probe,
|
|
450
|
+
containerVersion,
|
|
451
|
+
rect,
|
|
452
|
+
turns
|
|
453
|
+
]);
|
|
454
|
+
const visible = snapshot !== null && rect !== null;
|
|
461
455
|
const focusIndex = hoverIndex;
|
|
462
456
|
const scheduleTip = (0, react.useCallback)((index) => {
|
|
463
457
|
if (timers.current.open !== void 0) window.clearTimeout(timers.current.open);
|
|
@@ -473,8 +467,8 @@ window.__ModuleLoader__.load({
|
|
|
473
467
|
if (timers.current.close !== void 0) window.clearTimeout(timers.current.close);
|
|
474
468
|
}, []);
|
|
475
469
|
const jump = (0, react.useCallback)((index) => {
|
|
476
|
-
|
|
477
|
-
}, [
|
|
470
|
+
jumpToTurn(document, index, turnsRef.current, seqsRef.current, (seq) => sourceRef.current?.jumpThrough(seq) ?? Promise.resolve());
|
|
471
|
+
}, []);
|
|
478
472
|
if (!visible || probe === null || rect === null) return null;
|
|
479
473
|
const trackHeight = items.length * ITEM_PITCH;
|
|
480
474
|
const tip = tipIndex !== void 0 ? items[tipIndex] : void 0;
|
|
@@ -487,7 +481,8 @@ window.__ModuleLoader__.load({
|
|
|
487
481
|
style: {
|
|
488
482
|
left: rect.left,
|
|
489
483
|
top: rect.top,
|
|
490
|
-
height: rect.height
|
|
484
|
+
height: rect.height,
|
|
485
|
+
"--dsh-tl-center-y": `${rect.centerY}px`
|
|
491
486
|
},
|
|
492
487
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
493
488
|
className: "dsh-tl-scroll",
|
|
@@ -574,11 +569,11 @@ window.__ModuleLoader__.load({
|
|
|
574
569
|
role: "tooltip",
|
|
575
570
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
576
571
|
className: "dsh-tl-tip-user",
|
|
577
|
-
children: item.userPreview
|
|
572
|
+
children: item.userFallback ? t("preview.userFallback") : item.userPreview
|
|
578
573
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
579
574
|
className: "dsh-tl-tip-assistant",
|
|
580
575
|
"data-kind": item.assistantKind,
|
|
581
|
-
children: item.assistantPreview
|
|
576
|
+
children: item.assistantKind === "running" ? t("preview.assistantRunning") : item.assistantKind === "empty" ? t("preview.assistantEmpty") : item.assistantPreview
|
|
582
577
|
})]
|
|
583
578
|
});
|
|
584
579
|
}
|
|
@@ -588,20 +583,44 @@ window.__ModuleLoader__.load({
|
|
|
588
583
|
sessionId: void 0,
|
|
589
584
|
snapshot: null
|
|
590
585
|
};
|
|
586
|
+
/** Project one outline entry onto a tick (previews are host-bounded already). */
|
|
587
|
+
function itemFromOutline(entry, isLive, labels) {
|
|
588
|
+
const prompt = typeof entry.prompt === "string" ? entry.prompt : "";
|
|
589
|
+
const response = typeof entry.response === "string" ? entry.response : "";
|
|
590
|
+
const assistantKind = response ? "text" : isLive ? "running" : "empty";
|
|
591
|
+
return {
|
|
592
|
+
key: `t${entry.turn}`,
|
|
593
|
+
seq: entry.seq,
|
|
594
|
+
time: 0,
|
|
595
|
+
turn: entry.turn,
|
|
596
|
+
userPreview: prompt || labels.userFallback,
|
|
597
|
+
userFallback: !prompt,
|
|
598
|
+
assistantPreview: response || (isLive ? labels.assistantRunning : labels.assistantEmpty),
|
|
599
|
+
assistantKind,
|
|
600
|
+
running: isLive
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
/** Build the tick list from the outline plus the live running flag. */
|
|
604
|
+
function itemsFromOutline(outline, running, labels) {
|
|
605
|
+
const last = outline.length - 1;
|
|
606
|
+
return outline.map((entry, index) => itemFromOutline(entry, running && index === last, labels));
|
|
607
|
+
}
|
|
591
608
|
/**
|
|
592
609
|
* Create the source. Subscribes to the sessions list, rebinds to the current
|
|
593
|
-
* session's
|
|
594
|
-
*
|
|
610
|
+
* session's outline projection on selection change (polling while no session
|
|
611
|
+
* is selected — the list store need not notify on pure selection switches),
|
|
612
|
+
* and republishes a stable state whenever the outline or running flag moves.
|
|
595
613
|
* @param sessions - the sessions service face (pass ctx.sessions).
|
|
596
614
|
* @returns the source face.
|
|
597
615
|
*/
|
|
598
616
|
function createTimelineSource(sessions) {
|
|
599
617
|
let state = EMPTY_STATE;
|
|
600
618
|
const listeners = /* @__PURE__ */ new Set();
|
|
619
|
+
let subscribed = false;
|
|
601
620
|
let unbindSession = null;
|
|
602
|
-
let boundSessionId;
|
|
603
621
|
let retryTimer;
|
|
604
622
|
let retryAttempts = 0;
|
|
623
|
+
let noSessionPolls = 0;
|
|
605
624
|
const emit = () => {
|
|
606
625
|
for (const listener of listeners) listener();
|
|
607
626
|
};
|
|
@@ -616,22 +635,33 @@ window.__ModuleLoader__.load({
|
|
|
616
635
|
} catch {
|
|
617
636
|
current = void 0;
|
|
618
637
|
}
|
|
619
|
-
if (current === boundSessionId && retryAttempts === 0) return;
|
|
620
|
-
boundSessionId = current;
|
|
621
|
-
unbindSession?.();
|
|
622
|
-
unbindSession = null;
|
|
623
638
|
if (current === void 0) {
|
|
624
639
|
state = EMPTY_STATE;
|
|
625
640
|
emit();
|
|
641
|
+
if (subscribed && noSessionPolls < 60) {
|
|
642
|
+
noSessionPolls += 1;
|
|
643
|
+
retryTimer = setTimeout(rebind, 1e3);
|
|
644
|
+
}
|
|
626
645
|
return;
|
|
627
646
|
}
|
|
628
|
-
|
|
647
|
+
noSessionPolls = 0;
|
|
648
|
+
unbindSession?.();
|
|
649
|
+
unbindSession = null;
|
|
650
|
+
let bound;
|
|
629
651
|
try {
|
|
630
|
-
|
|
652
|
+
bound = sessions.binding(current);
|
|
631
653
|
} catch {
|
|
632
|
-
|
|
654
|
+
bound = void 0;
|
|
633
655
|
}
|
|
634
|
-
|
|
656
|
+
const session = bound?.session;
|
|
657
|
+
const outlineFace = (() => {
|
|
658
|
+
try {
|
|
659
|
+
return session?.projections?.faceOf?.("turnOutline");
|
|
660
|
+
} catch {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
})();
|
|
664
|
+
if (!session || !outlineFace) {
|
|
635
665
|
state = {
|
|
636
666
|
sessionId: current,
|
|
637
667
|
snapshot: null
|
|
@@ -646,13 +676,18 @@ window.__ModuleLoader__.load({
|
|
|
646
676
|
retryAttempts = 0;
|
|
647
677
|
const push = () => {
|
|
648
678
|
try {
|
|
649
|
-
const
|
|
679
|
+
const outline = outlineFace.getSnapshot();
|
|
680
|
+
const running = Boolean(session.getSnapshot()?.running);
|
|
650
681
|
state = {
|
|
651
682
|
sessionId: current,
|
|
652
683
|
snapshot: {
|
|
653
684
|
sessionId: current,
|
|
654
|
-
|
|
655
|
-
|
|
685
|
+
items: Array.isArray(outline) ? itemsFromOutline(outline, running, {
|
|
686
|
+
userFallback: "(无文本)",
|
|
687
|
+
assistantRunning: "(运行中)",
|
|
688
|
+
assistantEmpty: "(空)"
|
|
689
|
+
}) : [],
|
|
690
|
+
running
|
|
656
691
|
}
|
|
657
692
|
};
|
|
658
693
|
} catch {
|
|
@@ -665,6 +700,7 @@ window.__ModuleLoader__.load({
|
|
|
665
700
|
};
|
|
666
701
|
push();
|
|
667
702
|
try {
|
|
703
|
+
outlineFace.subscribe(push);
|
|
668
704
|
session.subscribe(push);
|
|
669
705
|
unbindSession = () => {};
|
|
670
706
|
} catch {
|
|
@@ -676,6 +712,7 @@ window.__ModuleLoader__.load({
|
|
|
676
712
|
const first = listeners.size === 0;
|
|
677
713
|
listeners.add(listener);
|
|
678
714
|
if (first) {
|
|
715
|
+
subscribed = true;
|
|
679
716
|
try {
|
|
680
717
|
sessions.list.subscribe(rebind);
|
|
681
718
|
} catch {}
|
|
@@ -687,6 +724,17 @@ window.__ModuleLoader__.load({
|
|
|
687
724
|
},
|
|
688
725
|
getSnapshot() {
|
|
689
726
|
return state;
|
|
727
|
+
},
|
|
728
|
+
jumpThrough(seq) {
|
|
729
|
+
let current;
|
|
730
|
+
try {
|
|
731
|
+
current = sessions.list.getSnapshot()?.current;
|
|
732
|
+
} catch {
|
|
733
|
+
current = void 0;
|
|
734
|
+
}
|
|
735
|
+
const session = current === void 0 ? void 0 : sessions.binding(current)?.session;
|
|
736
|
+
if (typeof session?.loadThrough !== "function") return Promise.resolve();
|
|
737
|
+
return session.loadThrough(seq).catch(() => {});
|
|
690
738
|
}
|
|
691
739
|
};
|
|
692
740
|
}
|
|
@@ -752,7 +800,8 @@ window.__ModuleLoader__.load({
|
|
|
752
800
|
locale: NS,
|
|
753
801
|
inject: () => ({ source: timelineSourceFromContext(ctx) })
|
|
754
802
|
}, TimelineRail);
|
|
755
|
-
} catch {
|
|
803
|
+
} catch (err) {
|
|
804
|
+
console.error("chat-timeline: shell.overlay register failed:", err);
|
|
756
805
|
return () => {};
|
|
757
806
|
}
|
|
758
807
|
});
|