@lament_z/dsh-client-ui-chat-timeline 0.1.0 → 0.2.0
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 +2 -0
- package/README.md +32 -8
- package/README.zh.md +32 -8
- package/lib/client.js +308 -259
- 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/rail.d.ts +5 -3
- package/lib/types/client/source.d.ts +60 -21
- package/lib/types/client/styles.d.ts +3 -1
- package/package.json +8 -9
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
|
|
@@ -202,6 +174,17 @@ window.__ModuleLoader__.load({
|
|
|
202
174
|
position: fixed;
|
|
203
175
|
z-index: 10;
|
|
204
176
|
width: 48px;
|
|
177
|
+
/* 水平:会话列左沿,由 rail 写入 --dsh-tl-left。这是唯一保留的测量值——
|
|
178
|
+
列是 grid 轨道,右侧栏打开时会被挤压,不贴窗口右沿,无法由窗口宽度推导。 */
|
|
179
|
+
left: var(--dsh-tl-left, 0px);
|
|
180
|
+
/* 垂直:原生 TurnNavigator 的 band 模型(eGxaPq_frame 的 --turn-rail-band)。
|
|
181
|
+
会话可视带 = 视口高 − 输入框高;其中点在视口坐标下为
|
|
182
|
+
100dvh − (会话可视高 + 输入框高) / 2
|
|
183
|
+
两个变量由宿主写在滚动容器上,本 seat 在滚动容器内可直接继承,故整个垂直
|
|
184
|
+
几何是纯 CSS:不做测量,也不依赖原生 nav 是否存在于 DOM。 */
|
|
185
|
+
--dsh-tl-band: calc(var(--dsh-conversation-viewport-height, 100dvh) - var(--dsh-composer-height, 152px));
|
|
186
|
+
top: calc(100dvh - (var(--dsh-conversation-viewport-height, 100dvh) + var(--dsh-composer-height, 152px)) / 2);
|
|
187
|
+
transform: translateY(-50%);
|
|
205
188
|
pointer-events: none;
|
|
206
189
|
opacity: 1;
|
|
207
190
|
transition: opacity 150ms ease-out;
|
|
@@ -211,12 +194,11 @@ window.__ModuleLoader__.load({
|
|
|
211
194
|
visibility: hidden;
|
|
212
195
|
}
|
|
213
196
|
.dsh-tl-scroll {
|
|
214
|
-
position:
|
|
197
|
+
position: relative;
|
|
215
198
|
left: 12px;
|
|
216
|
-
top: 50%;
|
|
217
|
-
transform: translateY(-50%);
|
|
218
199
|
width: 36px;
|
|
219
|
-
|
|
200
|
+
/* 原生 frame 的高度上限:band − 64px,再夹到 420px。 */
|
|
201
|
+
max-height: min(max(0px, calc(var(--dsh-tl-band) - 64px)), 420px);
|
|
220
202
|
overflow-x: hidden;
|
|
221
203
|
overflow-y: auto;
|
|
222
204
|
padding-block: 4px;
|
|
@@ -272,9 +254,11 @@ window.__ModuleLoader__.load({
|
|
|
272
254
|
max-width: calc(100vw - 2rem);
|
|
273
255
|
padding: 12px;
|
|
274
256
|
border-radius: 10px;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
257
|
+
/* 原生 tooltip 配对(dsh-web-frontend _bubble_):灰底 + 静态白字,
|
|
258
|
+
主题切换由别名变量跟随;灰底与页面背景天然分层。 */
|
|
259
|
+
background: var(--dsw-alias-tooltip-bg, #43454a);
|
|
260
|
+
color: var(--dsw-static-neutral-bluish-00, #fff);
|
|
261
|
+
border: none;
|
|
278
262
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
|
279
263
|
pointer-events: none;
|
|
280
264
|
}
|
|
@@ -298,11 +282,18 @@ window.__ModuleLoader__.load({
|
|
|
298
282
|
white-space: pre-line;
|
|
299
283
|
font-size: 13px;
|
|
300
284
|
line-height: 20px;
|
|
301
|
-
|
|
285
|
+
color: var(--dsw-static-neutral-bluish-300, rgba(255, 255, 255, 0.8));
|
|
302
286
|
}
|
|
303
287
|
.dsh-tl-tip-assistant[data-kind="running"],
|
|
304
288
|
.dsh-tl-tip-assistant[data-kind="empty"] {
|
|
305
|
-
|
|
289
|
+
color: var(--dsw-static-neutral-bluish-400, rgba(255, 255, 255, 0.55));
|
|
290
|
+
}
|
|
291
|
+
/* 插件完全接管原生右侧 Turn 导航条:本样式表只随插件存在,插件卸载时
|
|
292
|
+
removeStyles 移除标签,原生导航条立即恢复。aria-label 跟随宿主语言
|
|
293
|
+
(英文 Turn navigation / 中文 轮次导航),两个都要盖住。 */
|
|
294
|
+
nav[aria-label='Turn navigation'],
|
|
295
|
+
nav[aria-label='轮次导航'] {
|
|
296
|
+
display: none !important;
|
|
306
297
|
}
|
|
307
298
|
`;
|
|
308
299
|
/** Inject the stylesheet once per document. */
|
|
@@ -315,22 +306,28 @@ window.__ModuleLoader__.load({
|
|
|
315
306
|
tag.textContent = TIMELINE_STYLES;
|
|
316
307
|
doc.head.appendChild(tag);
|
|
317
308
|
}
|
|
309
|
+
/** Remove the stylesheet (plugin teardown): restores the native turn navigator. */
|
|
310
|
+
function removeStyles(doc) {
|
|
311
|
+
doc.querySelector("style[data-plugin-css=\"chat-timeline\"]")?.remove();
|
|
312
|
+
}
|
|
318
313
|
//#endregion
|
|
319
314
|
//#region src/client/rail.tsx
|
|
320
315
|
/**
|
|
321
|
-
* TimelineRail — the
|
|
322
|
-
* conversation, one tick per human question. Visual and interaction
|
|
323
|
-
* replicate the ZCode TurnNavigator (see .scratch/chat-timeline/spec.md):
|
|
316
|
+
* TimelineRail — the conversation.input.overlay entry: a left-edge tick rail
|
|
317
|
+
* over the conversation, one tick per human question. Visual and interaction
|
|
318
|
+
* constants replicate the ZCode TurnNavigator (see .scratch/chat-timeline/spec.md):
|
|
324
319
|
* hover ripple scaleX 2.6/1.7/1.25 with opacity 1/.86/.72/.58 over 150ms,
|
|
325
|
-
* 320px preview cards after a 120ms delay,
|
|
326
|
-
*
|
|
320
|
+
* 320px preview cards after a 120ms delay, prefers-reduced-motion fallback,
|
|
321
|
+
* full aria labelling.
|
|
322
|
+
*
|
|
323
|
+
* Geometry follows the native TurnNavigator model: the vertical band
|
|
324
|
+
* (--dsh-tl-band, the host's viewport-minus-composer rule) is computed in CSS
|
|
325
|
+
* from the host variables that the seat inherits, so no JS measures it. Only
|
|
326
|
+
* the horizontal left edge is measured — the conversation column is a grid
|
|
327
|
+
* track, so it is not flush to the window edge once the right sidebar opens.
|
|
327
328
|
*/
|
|
328
329
|
/** Slot height per tick (10px pitch, ZCode parity). */
|
|
329
330
|
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
331
|
/** Preview-card hover delays (ms). */
|
|
335
332
|
const TIP_OPEN_DELAY = 120;
|
|
336
333
|
const TIP_CLOSE_DELAY = 80;
|
|
@@ -364,29 +361,16 @@ window.__ModuleLoader__.load({
|
|
|
364
361
|
* through system colors). */
|
|
365
362
|
const TICK_COLOR_FOREGROUND = "CanvasText";
|
|
366
363
|
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
364
|
/**
|
|
380
365
|
* Render the question navigator rail.
|
|
381
366
|
* @param props - composed slot props.
|
|
382
367
|
* @returns the rail, or null when it should not render.
|
|
383
368
|
*/
|
|
384
|
-
function TimelineRail({ source, t }) {
|
|
369
|
+
function TimelineRail({ sessionId, source, t }) {
|
|
385
370
|
const state = (0, react.useSyncExternalStore)(source.subscribe, source.getSnapshot);
|
|
386
|
-
const reducedMotion = useReducedMotion();
|
|
387
371
|
const probe = (0, react.useMemo)(() => typeof document === "undefined" ? null : probeChatDom(document), []);
|
|
388
|
-
const [
|
|
389
|
-
const [
|
|
372
|
+
const [container, setContainer] = (0, react.useState)(null);
|
|
373
|
+
const [navEl, setNavEl] = (0, react.useState)(null);
|
|
390
374
|
const [activeIndex, setActiveIndex] = (0, react.useState)(-1);
|
|
391
375
|
const [hoverIndex, setHoverIndex] = (0, react.useState)(void 0);
|
|
392
376
|
const [tipIndex, setTipIndex] = (0, react.useState)(void 0);
|
|
@@ -395,24 +379,31 @@ window.__ModuleLoader__.load({
|
|
|
395
379
|
close: void 0
|
|
396
380
|
});
|
|
397
381
|
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
382
|
const snapshot = state.snapshot;
|
|
404
|
-
const items = (0, react.useMemo)(() => snapshot === null ? [] :
|
|
383
|
+
const items = (0, react.useMemo)(() => snapshot === null ? [] : snapshot.items, [snapshot]);
|
|
384
|
+
const sourceRef = (0, react.useRef)(source);
|
|
385
|
+
sourceRef.current = source;
|
|
386
|
+
const itemsRef = (0, react.useRef)([]);
|
|
387
|
+
itemsRef.current = items;
|
|
388
|
+
const seqs = (0, react.useMemo)(() => items.map((item) => item.seq), [items]);
|
|
389
|
+
const seqsRef = (0, react.useRef)([]);
|
|
390
|
+
seqsRef.current = seqs;
|
|
391
|
+
const turns = (0, react.useMemo)(() => items.map((item) => item.turn), [items]);
|
|
392
|
+
const turnsRef = (0, react.useRef)([]);
|
|
393
|
+
turnsRef.current = turns;
|
|
405
394
|
(0, react.useEffect)(() => {
|
|
406
395
|
if (typeof document === "undefined") return;
|
|
407
396
|
ensureStyles(document);
|
|
397
|
+
return () => removeStyles(document);
|
|
408
398
|
}, []);
|
|
409
399
|
(0, react.useEffect)(() => {
|
|
410
400
|
if (probe === null) return;
|
|
411
401
|
let attempts = 0;
|
|
412
402
|
let timer;
|
|
413
403
|
const tick = () => {
|
|
414
|
-
|
|
415
|
-
|
|
404
|
+
const found = probe.getContainer();
|
|
405
|
+
if (found !== null || attempts >= 10) {
|
|
406
|
+
setContainer(found);
|
|
416
407
|
return;
|
|
417
408
|
}
|
|
418
409
|
attempts += 1;
|
|
@@ -422,26 +413,28 @@ window.__ModuleLoader__.load({
|
|
|
422
413
|
return () => {
|
|
423
414
|
if (timer !== void 0) clearTimeout(timer);
|
|
424
415
|
};
|
|
425
|
-
}, [probe,
|
|
416
|
+
}, [probe, sessionId]);
|
|
426
417
|
(0, react.useEffect)(() => {
|
|
427
|
-
if (
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
418
|
+
if (container === null || navEl === null) return;
|
|
419
|
+
const write = () => {
|
|
420
|
+
navEl.style.setProperty("--dsh-tl-left", `${container.getBoundingClientRect().left}px`);
|
|
421
|
+
};
|
|
422
|
+
write();
|
|
423
|
+
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(write);
|
|
424
|
+
observer?.observe(container);
|
|
425
|
+
window.addEventListener("resize", write);
|
|
426
|
+
return () => {
|
|
427
|
+
observer?.disconnect();
|
|
428
|
+
window.removeEventListener("resize", write);
|
|
429
|
+
};
|
|
430
|
+
}, [container, navEl]);
|
|
431
|
+
(0, react.useEffect)(() => {
|
|
432
|
+
if (container === null || probe === null) return;
|
|
433
433
|
const sync = () => {
|
|
434
434
|
if (frame.current !== 0) return;
|
|
435
435
|
frame.current = requestAnimationFrame(() => {
|
|
436
436
|
frame.current = 0;
|
|
437
|
-
|
|
438
|
-
setRect({
|
|
439
|
-
left: box.left,
|
|
440
|
-
top: box.top,
|
|
441
|
-
height: box.height,
|
|
442
|
-
width: box.width
|
|
443
|
-
});
|
|
444
|
-
setActiveIndex(probe.activeIndex());
|
|
437
|
+
setActiveIndex(probe.activeIndex(turnsRef.current));
|
|
445
438
|
});
|
|
446
439
|
};
|
|
447
440
|
sync();
|
|
@@ -456,8 +449,16 @@ window.__ModuleLoader__.load({
|
|
|
456
449
|
if (frame.current !== 0) cancelAnimationFrame(frame.current);
|
|
457
450
|
frame.current = 0;
|
|
458
451
|
};
|
|
459
|
-
}, [
|
|
460
|
-
|
|
452
|
+
}, [container, probe]);
|
|
453
|
+
(0, react.useEffect)(() => {
|
|
454
|
+
if (container === null || probe === null) return;
|
|
455
|
+
setActiveIndex(probe.activeIndex(turns));
|
|
456
|
+
}, [
|
|
457
|
+
container,
|
|
458
|
+
probe,
|
|
459
|
+
turns
|
|
460
|
+
]);
|
|
461
|
+
const visible = snapshot !== null;
|
|
461
462
|
const focusIndex = hoverIndex;
|
|
462
463
|
const scheduleTip = (0, react.useCallback)((index) => {
|
|
463
464
|
if (timers.current.open !== void 0) window.clearTimeout(timers.current.open);
|
|
@@ -473,22 +474,18 @@ window.__ModuleLoader__.load({
|
|
|
473
474
|
if (timers.current.close !== void 0) window.clearTimeout(timers.current.close);
|
|
474
475
|
}, []);
|
|
475
476
|
const jump = (0, react.useCallback)((index) => {
|
|
476
|
-
|
|
477
|
-
}, [
|
|
478
|
-
if (!visible || probe === null
|
|
477
|
+
jumpToTurn(document, index, turnsRef.current, seqsRef.current, (seq) => sourceRef.current?.jumpThrough(seq) ?? Promise.resolve());
|
|
478
|
+
}, []);
|
|
479
|
+
if (!visible || probe === null) return null;
|
|
479
480
|
const trackHeight = items.length * ITEM_PITCH;
|
|
480
481
|
const tip = tipIndex !== void 0 ? items[tipIndex] : void 0;
|
|
481
482
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("nav", {
|
|
483
|
+
ref: setNavEl,
|
|
482
484
|
"aria-label": t("nav.label"),
|
|
483
485
|
className: "dsh-tl-nav",
|
|
484
486
|
"data-visible": visible ? "true" : "false",
|
|
485
487
|
"data-testid": "dsh-chat-timeline",
|
|
486
488
|
"data-item-count": items.length,
|
|
487
|
-
style: {
|
|
488
|
-
left: rect.left,
|
|
489
|
-
top: rect.top,
|
|
490
|
-
height: rect.height
|
|
491
|
-
},
|
|
492
489
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
493
490
|
className: "dsh-tl-scroll",
|
|
494
491
|
onPointerLeave: () => {
|
|
@@ -558,106 +555,134 @@ window.__ModuleLoader__.load({
|
|
|
558
555
|
}), tip !== void 0 && tipIndex !== void 0 ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(TipCard, {
|
|
559
556
|
item: tip,
|
|
560
557
|
index: tipIndex,
|
|
561
|
-
|
|
558
|
+
nav: navEl,
|
|
562
559
|
t
|
|
563
560
|
}), document.body) : null] });
|
|
564
561
|
}
|
|
565
|
-
function TipCard({ item, index,
|
|
566
|
-
const box =
|
|
562
|
+
function TipCard({ item, index, nav, t }) {
|
|
563
|
+
const box = (nav?.querySelector("[data-testid=\"dsh-chat-timeline-item\"][data-item-index=\"" + String(index) + "\"]"))?.getBoundingClientRect();
|
|
564
|
+
const railBox = nav?.getBoundingClientRect();
|
|
567
565
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
568
566
|
className: "dsh-tl-tip",
|
|
569
567
|
"data-testid": "dsh-chat-timeline-tip",
|
|
570
568
|
style: {
|
|
571
|
-
left: box
|
|
572
|
-
top: box
|
|
569
|
+
left: box !== void 0 ? Math.min(box.right + 8, window.innerWidth - 336) : railBox !== void 0 ? railBox.right + 8 : 8,
|
|
570
|
+
top: box !== void 0 ? Math.max(8, Math.min(box.top - 4, window.innerHeight - 160)) : 8
|
|
573
571
|
},
|
|
574
572
|
role: "tooltip",
|
|
575
573
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
576
574
|
className: "dsh-tl-tip-user",
|
|
577
|
-
children: item.userPreview
|
|
575
|
+
children: item.userFallback ? t("preview.userFallback") : item.userPreview
|
|
578
576
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
579
577
|
className: "dsh-tl-tip-assistant",
|
|
580
578
|
"data-kind": item.assistantKind,
|
|
581
|
-
children: item.assistantPreview
|
|
579
|
+
children: item.assistantKind === "running" ? t("preview.assistantRunning") : item.assistantKind === "empty" ? t("preview.assistantEmpty") : item.assistantPreview
|
|
582
580
|
})]
|
|
583
581
|
});
|
|
584
582
|
}
|
|
585
583
|
//#endregion
|
|
586
584
|
//#region src/client/source.ts
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
585
|
+
/** Project one outline entry onto a tick (previews are host-bounded already). */
|
|
586
|
+
function itemFromOutline(entry, isLive, labels) {
|
|
587
|
+
const prompt = typeof entry.prompt === "string" ? entry.prompt : "";
|
|
588
|
+
const response = typeof entry.response === "string" ? entry.response : "";
|
|
589
|
+
const assistantKind = response ? "text" : isLive ? "running" : "empty";
|
|
590
|
+
return {
|
|
591
|
+
key: `t${entry.turn}`,
|
|
592
|
+
seq: entry.seq,
|
|
593
|
+
time: 0,
|
|
594
|
+
turn: entry.turn,
|
|
595
|
+
userPreview: prompt || labels.userFallback,
|
|
596
|
+
userFallback: !prompt,
|
|
597
|
+
assistantPreview: response || (isLive ? labels.assistantRunning : labels.assistantEmpty),
|
|
598
|
+
assistantKind,
|
|
599
|
+
running: isLive
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
/** Build the tick list from the outline plus the live running flag. */
|
|
603
|
+
function itemsFromOutline(outline, running, labels) {
|
|
604
|
+
const last = outline.length - 1;
|
|
605
|
+
return outline.map((entry, index) => itemFromOutline(entry, running && index === last, labels));
|
|
606
|
+
}
|
|
607
|
+
/** Fallback preview labels when the outline entry carries no text. */
|
|
608
|
+
const PREVIEW_LABELS = {
|
|
609
|
+
userFallback: "(无文本)",
|
|
610
|
+
assistantRunning: "(运行中)",
|
|
611
|
+
assistantEmpty: "(空)"
|
|
590
612
|
};
|
|
591
613
|
/**
|
|
592
|
-
* Create the source.
|
|
593
|
-
* session
|
|
594
|
-
*
|
|
614
|
+
* Create the source for one session. The id is supplied by the host — the
|
|
615
|
+
* session-scoped seat hands it to `inject` — so this no longer guesses which
|
|
616
|
+
* session is open and no longer watches the sessions list: it binds straight
|
|
617
|
+
* to that session's outline projection and republishes whenever the outline or
|
|
618
|
+
* the running flag moves.
|
|
619
|
+
*
|
|
620
|
+
* A bounded retry covers the materialization window (the seat can render a
|
|
621
|
+
* beat before `binding(id)` answers); after that the rail stays hidden.
|
|
595
622
|
* @param sessions - the sessions service face (pass ctx.sessions).
|
|
623
|
+
* @param sessionId - the session this rail belongs to.
|
|
596
624
|
* @returns the source face.
|
|
597
625
|
*/
|
|
598
|
-
function createTimelineSource(sessions) {
|
|
599
|
-
let state =
|
|
626
|
+
function createTimelineSource(sessions, sessionId) {
|
|
627
|
+
let state = {
|
|
628
|
+
sessionId,
|
|
629
|
+
snapshot: null
|
|
630
|
+
};
|
|
600
631
|
const listeners = /* @__PURE__ */ new Set();
|
|
601
|
-
let
|
|
602
|
-
let boundSessionId;
|
|
632
|
+
let subscribed = false;
|
|
603
633
|
let retryTimer;
|
|
604
634
|
let retryAttempts = 0;
|
|
605
635
|
const emit = () => {
|
|
606
636
|
for (const listener of listeners) listener();
|
|
607
637
|
};
|
|
608
|
-
const
|
|
609
|
-
if (retryTimer !== void 0) {
|
|
610
|
-
clearTimeout(retryTimer);
|
|
611
|
-
retryTimer = void 0;
|
|
612
|
-
}
|
|
613
|
-
let current;
|
|
638
|
+
const readBinding = () => {
|
|
614
639
|
try {
|
|
615
|
-
|
|
640
|
+
return sessions.binding(sessionId);
|
|
616
641
|
} catch {
|
|
617
|
-
current = void 0;
|
|
618
|
-
}
|
|
619
|
-
if (current === boundSessionId && retryAttempts === 0) return;
|
|
620
|
-
boundSessionId = current;
|
|
621
|
-
unbindSession?.();
|
|
622
|
-
unbindSession = null;
|
|
623
|
-
if (current === void 0) {
|
|
624
|
-
state = EMPTY_STATE;
|
|
625
|
-
emit();
|
|
626
642
|
return;
|
|
627
643
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
644
|
+
};
|
|
645
|
+
const bind = () => {
|
|
646
|
+
if (retryTimer !== void 0) {
|
|
647
|
+
clearTimeout(retryTimer);
|
|
648
|
+
retryTimer = void 0;
|
|
633
649
|
}
|
|
634
|
-
|
|
650
|
+
const session = readBinding()?.session;
|
|
651
|
+
const outlineFace = (() => {
|
|
652
|
+
try {
|
|
653
|
+
return session?.projections?.faceOf?.("turnOutline");
|
|
654
|
+
} catch {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
})();
|
|
658
|
+
if (!session || !outlineFace) {
|
|
635
659
|
state = {
|
|
636
|
-
sessionId
|
|
660
|
+
sessionId,
|
|
637
661
|
snapshot: null
|
|
638
662
|
};
|
|
639
|
-
|
|
663
|
+
emit();
|
|
664
|
+
if (subscribed && retryAttempts < 10) {
|
|
640
665
|
retryAttempts += 1;
|
|
641
|
-
retryTimer = setTimeout(
|
|
666
|
+
retryTimer = setTimeout(bind, 250 * retryAttempts);
|
|
642
667
|
}
|
|
643
|
-
emit();
|
|
644
668
|
return;
|
|
645
669
|
}
|
|
646
670
|
retryAttempts = 0;
|
|
647
671
|
const push = () => {
|
|
648
672
|
try {
|
|
649
|
-
const
|
|
673
|
+
const outline = outlineFace.getSnapshot();
|
|
674
|
+
const running = Boolean(session.getSnapshot()?.running);
|
|
650
675
|
state = {
|
|
651
|
-
sessionId
|
|
676
|
+
sessionId,
|
|
652
677
|
snapshot: {
|
|
653
|
-
sessionId
|
|
654
|
-
|
|
655
|
-
running
|
|
678
|
+
sessionId,
|
|
679
|
+
items: Array.isArray(outline) ? itemsFromOutline(outline, running, PREVIEW_LABELS) : [],
|
|
680
|
+
running
|
|
656
681
|
}
|
|
657
682
|
};
|
|
658
683
|
} catch {
|
|
659
684
|
state = {
|
|
660
|
-
sessionId
|
|
685
|
+
sessionId,
|
|
661
686
|
snapshot: null
|
|
662
687
|
};
|
|
663
688
|
}
|
|
@@ -665,21 +690,17 @@ window.__ModuleLoader__.load({
|
|
|
665
690
|
};
|
|
666
691
|
push();
|
|
667
692
|
try {
|
|
693
|
+
outlineFace.subscribe(push);
|
|
668
694
|
session.subscribe(push);
|
|
669
|
-
|
|
670
|
-
} catch {
|
|
671
|
-
unbindSession = null;
|
|
672
|
-
}
|
|
695
|
+
} catch {}
|
|
673
696
|
};
|
|
674
697
|
return {
|
|
675
698
|
subscribe(listener) {
|
|
676
699
|
const first = listeners.size === 0;
|
|
677
700
|
listeners.add(listener);
|
|
678
701
|
if (first) {
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
} catch {}
|
|
682
|
-
rebind();
|
|
702
|
+
subscribed = true;
|
|
703
|
+
bind();
|
|
683
704
|
}
|
|
684
705
|
return () => {
|
|
685
706
|
listeners.delete(listener);
|
|
@@ -687,12 +708,17 @@ window.__ModuleLoader__.load({
|
|
|
687
708
|
},
|
|
688
709
|
getSnapshot() {
|
|
689
710
|
return state;
|
|
711
|
+
},
|
|
712
|
+
jumpThrough(seq) {
|
|
713
|
+
const session = readBinding()?.session;
|
|
714
|
+
if (typeof session?.loadThrough !== "function") return Promise.resolve();
|
|
715
|
+
return session.loadThrough(seq).catch(() => {});
|
|
690
716
|
}
|
|
691
717
|
};
|
|
692
718
|
}
|
|
693
|
-
/** Build the source from the plugin client context. */
|
|
694
|
-
function timelineSourceFromContext(ctx) {
|
|
695
|
-
return createTimelineSource(ctx.sessions);
|
|
719
|
+
/** Build the source from the plugin client context and the seat's session id. */
|
|
720
|
+
function timelineSourceFromContext(ctx, sessionId) {
|
|
721
|
+
return createTimelineSource(ctx.sessions, sessionId);
|
|
696
722
|
}
|
|
697
723
|
//#endregion
|
|
698
724
|
//#region src/client/locales.ts
|
|
@@ -721,7 +747,25 @@ window.__ModuleLoader__.load({
|
|
|
721
747
|
//#region src/client/index.ts
|
|
722
748
|
/** Dictionary namespace owned by this plugin. */
|
|
723
749
|
const NS = "chat-timeline";
|
|
724
|
-
/**
|
|
750
|
+
/**
|
|
751
|
+
* Session-scoped seat the rail occupies.
|
|
752
|
+
*
|
|
753
|
+
* DSH 0.1.7 moved "which session is open" out of the sessions service: the list
|
|
754
|
+
* snapshot no longer carries `current`, `binding(id)` only borrows an already
|
|
755
|
+
* retained scope, and session-controller states outright that view selection
|
|
756
|
+
* stays outside the Controller. The only supported way to learn the open
|
|
757
|
+
* session is a session-scoped seat, whose `inject` receives the scope
|
|
758
|
+
* binding's key — the session id. A root-scope seat (shell.overlay) cannot be
|
|
759
|
+
* told, which is why the rail no longer lives there.
|
|
760
|
+
*
|
|
761
|
+
* `conversation.input.overlay` is rendered for every open session, and unlike
|
|
762
|
+
* `conversation.view` it is not enumerated into the conversation's view tabs.
|
|
763
|
+
* It also sits inside the conversation scroller, so the host's geometry
|
|
764
|
+
* variables (--dsh-conversation-viewport-height / --dsh-composer-height)
|
|
765
|
+
* inherit to the rail.
|
|
766
|
+
*/
|
|
767
|
+
const SEAT = "conversation.input.overlay";
|
|
768
|
+
/** Unique occupant id inside the shared conversation.input.overlay list slot. */
|
|
725
769
|
const ENTRY_ID = "chat-timeline";
|
|
726
770
|
/** Services required by this plugin. */
|
|
727
771
|
const inject = [
|
|
@@ -744,15 +788,20 @@ window.__ModuleLoader__.load({
|
|
|
744
788
|
return () => {};
|
|
745
789
|
}
|
|
746
790
|
}, "chat-timeline: dictionaries");
|
|
747
|
-
ctx.slots.inject(
|
|
791
|
+
ctx.slots.inject(SEAT, () => {
|
|
748
792
|
try {
|
|
749
793
|
return ctx.slots.register({
|
|
750
|
-
name:
|
|
794
|
+
name: SEAT,
|
|
751
795
|
id: ENTRY_ID,
|
|
796
|
+
order: 0,
|
|
752
797
|
locale: NS,
|
|
753
|
-
inject: () => ({
|
|
798
|
+
inject: (sessionId) => ({
|
|
799
|
+
sessionId,
|
|
800
|
+
source: timelineSourceFromContext(ctx, sessionId)
|
|
801
|
+
})
|
|
754
802
|
}, TimelineRail);
|
|
755
|
-
} catch {
|
|
803
|
+
} catch (err) {
|
|
804
|
+
console.error(`chat-timeline: ${SEAT} register failed:`, err);
|
|
756
805
|
return () => {};
|
|
757
806
|
}
|
|
758
807
|
});
|