@khorsheed/dsh-message-timeline 0.1.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/CHANGELOG.md +11 -0
- package/README.en.md +79 -0
- package/README.i18n.yaml +6 -0
- package/README.md +79 -0
- package/cordis.patch.yml +6 -0
- package/lib/client.js +521 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +11 -0
- package/lib/invariant.js +28 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/lib/types/client/TimelineRail.d.ts +10 -0
- package/lib/types/client/TimelineRail.d.ts.map +1 -0
- package/lib/types/client/TimelineRail.js +152 -0
- package/lib/types/client/config.d.ts +39 -0
- package/lib/types/client/config.d.ts.map +1 -0
- package/lib/types/client/config.js +30 -0
- package/lib/types/client/index.d.ts +29 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +49 -0
- package/lib/types/client/locales.d.ts +20 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +11 -0
- package/lib/types/client/preview.d.ts +13 -0
- package/lib/types/client/preview.d.ts.map +1 -0
- package/lib/types/client/preview.js +13 -0
- package/lib/types/client/rail-tracker.d.ts +84 -0
- package/lib/types/client/rail-tracker.d.ts.map +1 -0
- package/lib/types/client/rail-tracker.js +240 -0
- package/lib/types/client/slots.d.ts +62 -0
- package/lib/types/client/slots.d.ts.map +1 -0
- package/lib/types/client/slots.js +1 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +8 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/invariant.d.ts.map +1 -0
- package/lib/types/invariant.js +26 -0
- package/package.json +79 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@khorsheed/dsh-message-timeline",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let react = require("react");
|
|
8
|
+
let react_dom = require("react-dom");
|
|
9
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
|
+
const INITIAL_PAGES_MIN = 1;
|
|
11
|
+
const INITIAL_PAGES_MAX = 20;
|
|
12
|
+
function clamp(value, min, max) {
|
|
13
|
+
return Math.min(max, Math.max(min, value));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Normalize the entry config into the full {@link TimelineConfig}: every
|
|
17
|
+
* omitted field takes its documented default and every numeric field is
|
|
18
|
+
* clamped into its legal range.
|
|
19
|
+
* @param config - the unvalidated entry config, when the runner passes one.
|
|
20
|
+
* @returns the effective rail behavior.
|
|
21
|
+
*/
|
|
22
|
+
function resolveConfig(config) {
|
|
23
|
+
return {
|
|
24
|
+
enabled: config?.enabled ?? true,
|
|
25
|
+
includeSteering: config?.includeSteering ?? true,
|
|
26
|
+
panelWidth: clamp(config?.panelWidth ?? 360, 120, 640),
|
|
27
|
+
initialPages: clamp(config?.initialPages ?? 5, INITIAL_PAGES_MIN, INITIAL_PAGES_MAX)
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/client/locales.ts
|
|
32
|
+
/** `message-timeline` namespace dictionaries. */
|
|
33
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
34
|
+
const zh = {
|
|
35
|
+
"rail.panel": "消息导览",
|
|
36
|
+
"rail.empty": "暂无用户消息"
|
|
37
|
+
};
|
|
38
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
39
|
+
const en = {
|
|
40
|
+
"rail.panel": "Message timeline",
|
|
41
|
+
"rail.empty": "No user messages"
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
//#region src/client/rail-tracker.ts
|
|
45
|
+
/** Horizontal inset of the rail from the scrollport's left edge (px). */
|
|
46
|
+
const RAIL_LEFT_INSET = 6;
|
|
47
|
+
/** Vertical padding above and below the rail inside the scrollport (px). */
|
|
48
|
+
const RAIL_VERTICAL_PADDING = 8;
|
|
49
|
+
/** Keep the target row this far below the scrollport top after a jump (px). */
|
|
50
|
+
const JUMP_OFFSET = 16;
|
|
51
|
+
/** Idle state published before any session binds or while none is current. */
|
|
52
|
+
const IDLE = {
|
|
53
|
+
sessionId: void 0,
|
|
54
|
+
ready: false,
|
|
55
|
+
left: 0,
|
|
56
|
+
top: 0,
|
|
57
|
+
height: 0,
|
|
58
|
+
scrollportWidth: 0,
|
|
59
|
+
flowLeft: null,
|
|
60
|
+
activeKey: null,
|
|
61
|
+
chatView: false
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Measure the panel's viewport box from one scrollport: its rect inset by the
|
|
65
|
+
* panel padding, minus the sticky composer seat at the bottom and the
|
|
66
|
+
* conversation tab strip at the top. The tabs render just above the
|
|
67
|
+
* scrollport, but centering reads against the whole window, so the strip
|
|
68
|
+
* height leaves the box either way — otherwise the list sits visibly high.
|
|
69
|
+
* The scrollport's own width rides along for the width-cap fallback when the
|
|
70
|
+
* message-flow probe is unanswered.
|
|
71
|
+
* @param scrollport - the official conversation scrollport element.
|
|
72
|
+
* @returns the panel box plus the scrollport width, or null while the
|
|
73
|
+
* scrollport has no laid-out size.
|
|
74
|
+
*/
|
|
75
|
+
function measureGeometry(scrollport) {
|
|
76
|
+
const rect = scrollport.getBoundingClientRect();
|
|
77
|
+
if (rect.width === 0 && rect.height === 0) return null;
|
|
78
|
+
const composerHeight = scrollport.querySelector("[data-composer-seat]")?.getBoundingClientRect().height ?? 0;
|
|
79
|
+
let topInset = RAIL_VERTICAL_PADDING;
|
|
80
|
+
for (const tabs of scrollport.ownerDocument.querySelectorAll("[role=\"tablist\"]")) {
|
|
81
|
+
const tabsRect = tabs.getBoundingClientRect();
|
|
82
|
+
if (tabsRect.height > 0 && Math.abs(tabsRect.bottom - rect.top) <= 80) {
|
|
83
|
+
topInset += tabsRect.height;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
left: rect.left + RAIL_LEFT_INSET,
|
|
89
|
+
top: rect.top + topInset,
|
|
90
|
+
height: Math.max(0, rect.height - composerHeight - topInset - RAIL_VERTICAL_PADDING),
|
|
91
|
+
width: rect.width
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The viewport x of the message flow's left edge: the left of the first
|
|
96
|
+
* rendered `[data-chat-flow-kind]` row, which sits flush inside the official
|
|
97
|
+
* centered content column (max 748px, `margin: 0 auto`). Every flow row
|
|
98
|
+
* shares that edge, so the first one found suffices. The panel's right edge
|
|
99
|
+
* stays left of it — the panel may only occupy the scrollport's left gutter.
|
|
100
|
+
* @param scrollport - the official conversation scrollport element.
|
|
101
|
+
* @returns the flow's left edge, or null while no flow row is rendered.
|
|
102
|
+
*/
|
|
103
|
+
function flowLeftX(scrollport) {
|
|
104
|
+
const row = scrollport.querySelector("[data-chat-flow-kind]");
|
|
105
|
+
return row === null ? null : row.getBoundingClientRect().left;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Resolve the key of the user-message row the reading position belongs to:
|
|
109
|
+
* the first matching row whose bottom is still inside the viewport, or —
|
|
110
|
+
* while the reader sits inside a long assistant answer with no user row
|
|
111
|
+
* visible — the nearest user row above the viewport, so the lit tick stays
|
|
112
|
+
* anchored to the question being answered instead of jumping to the
|
|
113
|
+
* session's latest message.
|
|
114
|
+
* @param scrollport - the official conversation scrollport element.
|
|
115
|
+
* @param includeSteering - whether steering rows count as user messages.
|
|
116
|
+
* @returns the row's anchor key, or null when no user row is rendered.
|
|
117
|
+
*/
|
|
118
|
+
function activeRowKey(scrollport, includeSteering) {
|
|
119
|
+
const viewTop = scrollport.getBoundingClientRect().top;
|
|
120
|
+
let lastAbove = null;
|
|
121
|
+
for (const row of scrollport.querySelectorAll("[data-chat-flow-kind]")) {
|
|
122
|
+
const kind = row.dataset.chatFlowKind;
|
|
123
|
+
if (kind !== "user" && !(includeSteering && kind === "steering")) continue;
|
|
124
|
+
if (row.getBoundingClientRect().bottom <= viewTop) {
|
|
125
|
+
lastAbove = row.dataset.chatAnchorKey ?? lastAbove;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
return row.dataset.chatAnchorKey ?? null;
|
|
129
|
+
}
|
|
130
|
+
return lastAbove;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Scroll one user-message row to the top of the transcript scrollport.
|
|
134
|
+
* @param scrollport - the official conversation scrollport element.
|
|
135
|
+
* @param key - the target node's anchor key.
|
|
136
|
+
* @returns whether the row was found and scrolled.
|
|
137
|
+
*/
|
|
138
|
+
function jumpRow(scrollport, key) {
|
|
139
|
+
const row = scrollport.querySelector(`[data-chat-anchor-key=${JSON.stringify(key)}]`);
|
|
140
|
+
if (row === null) return false;
|
|
141
|
+
const target = row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top + scrollport.scrollTop - JUMP_OFFSET;
|
|
142
|
+
scrollport.scrollTop = Math.max(0, target);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Install the rail tracker: bind the current session's scrollport, publish
|
|
147
|
+
* geometry/active updates on scroll and resize, and answer jump requests.
|
|
148
|
+
* Follows the current session through the sessions list and provide channels
|
|
149
|
+
* (the interface renders one conversation at a time, so one tracker suffices).
|
|
150
|
+
* @param ctx - client root context (sessions service).
|
|
151
|
+
* @param includeSteering - whether steering rows count as user dots.
|
|
152
|
+
* @returns the tracker face.
|
|
153
|
+
*/
|
|
154
|
+
function installRailTracker(ctx, includeSteering) {
|
|
155
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
156
|
+
let state = IDLE;
|
|
157
|
+
let activeSession;
|
|
158
|
+
let scrollport = null;
|
|
159
|
+
let resizeObserver;
|
|
160
|
+
let rafPending = false;
|
|
161
|
+
let warned = false;
|
|
162
|
+
const nextFrame = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (callback) => {
|
|
163
|
+
setTimeout(callback, 16);
|
|
164
|
+
};
|
|
165
|
+
const same = (left, right) => left.sessionId === right.sessionId && left.ready === right.ready && left.left === right.left && left.top === right.top && left.height === right.height && left.scrollportWidth === right.scrollportWidth && left.flowLeft === right.flowLeft && left.activeKey === right.activeKey && left.chatView === right.chatView;
|
|
166
|
+
const publish = (next) => {
|
|
167
|
+
if (same(state, next)) return;
|
|
168
|
+
state = next;
|
|
169
|
+
for (const fn of [...listeners]) fn();
|
|
170
|
+
};
|
|
171
|
+
const update = () => {
|
|
172
|
+
if (scrollport === null) return;
|
|
173
|
+
const geometry = measureGeometry(scrollport);
|
|
174
|
+
if (geometry === null) return;
|
|
175
|
+
publish({
|
|
176
|
+
sessionId: activeSession,
|
|
177
|
+
ready: true,
|
|
178
|
+
left: geometry.left,
|
|
179
|
+
top: geometry.top,
|
|
180
|
+
height: geometry.height,
|
|
181
|
+
scrollportWidth: geometry.width,
|
|
182
|
+
flowLeft: flowLeftX(scrollport),
|
|
183
|
+
activeKey: activeRowKey(scrollport, includeSteering),
|
|
184
|
+
chatView: scrollport.querySelector("[data-chat-flow]") !== null
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
const scheduleUpdate = () => {
|
|
188
|
+
if (rafPending) return;
|
|
189
|
+
rafPending = true;
|
|
190
|
+
nextFrame(() => {
|
|
191
|
+
rafPending = false;
|
|
192
|
+
update();
|
|
193
|
+
});
|
|
194
|
+
};
|
|
195
|
+
const onScroll = () => {
|
|
196
|
+
scheduleUpdate();
|
|
197
|
+
};
|
|
198
|
+
const teardownBind = () => {
|
|
199
|
+
if (scrollport !== null) scrollport.removeEventListener("scroll", onScroll);
|
|
200
|
+
scrollport = null;
|
|
201
|
+
resizeObserver?.disconnect();
|
|
202
|
+
resizeObserver = void 0;
|
|
203
|
+
rafPending = false;
|
|
204
|
+
};
|
|
205
|
+
const bind = (sessionId) => {
|
|
206
|
+
if (sessionId === activeSession && scrollport !== null) return;
|
|
207
|
+
teardownBind();
|
|
208
|
+
activeSession = sessionId;
|
|
209
|
+
if (sessionId === void 0) {
|
|
210
|
+
publish(IDLE);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
nextFrame(() => {
|
|
214
|
+
const found = document.querySelector("[data-conversation-scroll]");
|
|
215
|
+
if (found === null) {
|
|
216
|
+
if (!warned) {
|
|
217
|
+
warned = true;
|
|
218
|
+
console.warn("message-timeline: [data-conversation-scroll] not found; the rail stays hidden");
|
|
219
|
+
}
|
|
220
|
+
publish(IDLE);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
scrollport = found;
|
|
224
|
+
scrollport.addEventListener("scroll", onScroll, { passive: true });
|
|
225
|
+
if (typeof ResizeObserver === "function") {
|
|
226
|
+
resizeObserver = new ResizeObserver(scheduleUpdate);
|
|
227
|
+
resizeObserver.observe(scrollport);
|
|
228
|
+
const composer = scrollport.querySelector("[data-composer-seat]");
|
|
229
|
+
if (composer !== null) resizeObserver.observe(composer);
|
|
230
|
+
}
|
|
231
|
+
update();
|
|
232
|
+
});
|
|
233
|
+
};
|
|
234
|
+
const bindCurrent = () => {
|
|
235
|
+
bind(ctx.sessions.list.getSnapshot().current);
|
|
236
|
+
};
|
|
237
|
+
const stopList = ctx.sessions.list.subscribe(bindCurrent);
|
|
238
|
+
const stopProvide = ctx.sessions.currentProvideInfo.subscribe(bindCurrent);
|
|
239
|
+
bindCurrent();
|
|
240
|
+
const onWindowResize = () => {
|
|
241
|
+
scheduleUpdate();
|
|
242
|
+
};
|
|
243
|
+
if (typeof window !== "undefined") window.addEventListener("resize", onWindowResize);
|
|
244
|
+
let mutationObserver;
|
|
245
|
+
if (typeof MutationObserver === "function" && typeof document !== "undefined") {
|
|
246
|
+
mutationObserver = new MutationObserver(scheduleUpdate);
|
|
247
|
+
mutationObserver.observe(document.body, {
|
|
248
|
+
childList: true,
|
|
249
|
+
subtree: true
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
state: {
|
|
254
|
+
getSnapshot: () => state,
|
|
255
|
+
subscribe: (fn) => {
|
|
256
|
+
listeners.add(fn);
|
|
257
|
+
return () => {
|
|
258
|
+
listeners.delete(fn);
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
},
|
|
262
|
+
jumpTo: (key) => {
|
|
263
|
+
if (scrollport !== null) jumpRow(scrollport, key);
|
|
264
|
+
},
|
|
265
|
+
dispose: () => {
|
|
266
|
+
stopList();
|
|
267
|
+
stopProvide();
|
|
268
|
+
if (typeof window !== "undefined") window.removeEventListener("resize", onWindowResize);
|
|
269
|
+
mutationObserver?.disconnect();
|
|
270
|
+
teardownBind();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
//#endregion
|
|
275
|
+
//#region src/client/preview.ts
|
|
276
|
+
/**
|
|
277
|
+
* Join the text blocks of one message into a single preview string.
|
|
278
|
+
* @param content - the message's content block list.
|
|
279
|
+
* @returns the concatenated text, or null when the message carries none.
|
|
280
|
+
*/
|
|
281
|
+
function previewText(content) {
|
|
282
|
+
const parts = [];
|
|
283
|
+
for (const block of content) if (block.type === "text" && block.text !== "") parts.push(block.text);
|
|
284
|
+
return parts.length === 0 ? null : parts.join("\n");
|
|
285
|
+
}
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region \0dsh-css:/Users/zhuyudan/code/dsh-plugins/packages/message-timeline/src/client/TimelineRail.module.css.mjs
|
|
288
|
+
const css = "._8u17xW_panel{scrollbar-width:none;z-index:100;pointer-events:none;flex-direction:column;padding:4px 8px;display:flex;position:fixed;overflow-y:auto}._8u17xW_panel:before,._8u17xW_panel:after{content:\"\";margin:auto}._8u17xW_panel::-webkit-scrollbar{display:none}._8u17xW_item{width:100%;max-width:20px;color:var(--dsw-alias-label-tertiary);text-align:left;cursor:pointer;-webkit-user-select:none;user-select:none;pointer-events:auto;opacity:.55;background:0 0;border:none;flex:none;align-items:center;gap:8px;padding:5px 0;font-size:13px;line-height:1.5;transition:opacity .12s;display:flex}._8u17xW_panel:hover ._8u17xW_item,._8u17xW_panel:focus-within ._8u17xW_item{opacity:1;max-width:100%}._8u17xW_tick{background:var(--dsw-static-deepseek-400);border-radius:2px;flex:none;width:3px;height:14px}._8u17xW_itemCurrent{color:var(--dsw-static-deepseek-500);opacity:.75}._8u17xW_tickCurrent{background:var(--dsw-static-deepseek-500)}._8u17xW_itemText{white-space:nowrap;text-overflow:ellipsis;opacity:0;transition:opacity .12s;overflow:hidden}._8u17xW_panel:hover ._8u17xW_itemText,._8u17xW_panel:focus-within ._8u17xW_itemText{opacity:1;transition-delay:90ms}._8u17xW_itemFocused{color:var(--dsw-alias-label-primary);font-weight:600}._8u17xW_item:focus:not(:focus-visible){outline:none}._8u17xW_item:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:1px}";
|
|
289
|
+
const tagId = "@khorsheed/dsh-message-timeline/TimelineRail.module.css";
|
|
290
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
291
|
+
const tag = document.createElement("style");
|
|
292
|
+
tag.dataset.plugin = "@khorsheed/dsh-message-timeline";
|
|
293
|
+
tag.dataset.pluginCss = tagId;
|
|
294
|
+
tag.textContent = css;
|
|
295
|
+
document.head.appendChild(tag);
|
|
296
|
+
}
|
|
297
|
+
var TimelineRail_module_css_default = {
|
|
298
|
+
"panel": "_8u17xW_panel",
|
|
299
|
+
"itemText": "_8u17xW_itemText",
|
|
300
|
+
"tick": "_8u17xW_tick",
|
|
301
|
+
"itemCurrent": "_8u17xW_itemCurrent",
|
|
302
|
+
"itemFocused": "_8u17xW_itemFocused",
|
|
303
|
+
"tickCurrent": "_8u17xW_tickCurrent",
|
|
304
|
+
"item": "_8u17xW_item"
|
|
305
|
+
};
|
|
306
|
+
//#endregion
|
|
307
|
+
//#region src/client/TimelineRail.tsx
|
|
308
|
+
/**
|
|
309
|
+
* Message timeline panel, browser half. One entry in the official
|
|
310
|
+
* conversation.session.header.utilities seat anchors the plugin into the
|
|
311
|
+
* session scope and renders, through a body portal, the flat floating
|
|
312
|
+
* timeline over the left edge of the chat scrollport: one row per loaded
|
|
313
|
+
* user message — a tick plus an ellipsized one-line preview, no frame and no
|
|
314
|
+
* visible scrollbar. At rest only the dimmed ticks show, the reading
|
|
315
|
+
* position's tick in blue (the latest message until the tracker answers);
|
|
316
|
+
* hovering or keyboard-focusing the panel reveals the row texts with the
|
|
317
|
+
* blue row on top, and clicking a row jumps the transcript to that message.
|
|
318
|
+
* The panel is always on while the chat view shows; the `enabled` config is
|
|
319
|
+
* the off switch.
|
|
320
|
+
*/
|
|
321
|
+
/** Extract the preview blocks of one user/steering node (kind-checked by the caller). */
|
|
322
|
+
function nodeContent(node) {
|
|
323
|
+
return node.data.content ?? [];
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Horizontal panel padding, matching `.panel`'s `padding: 4px 8px`: the text
|
|
327
|
+
* band sits this far inside the box, so the width budget must give it back
|
|
328
|
+
* for {@link PANEL_GAP} to be the visible gap to the message flow.
|
|
329
|
+
*/
|
|
330
|
+
const PANEL_PADDING_X = 8;
|
|
331
|
+
/** Visible breathing gap between the timeline text and the message flow (px). */
|
|
332
|
+
const PANEL_GAP = 16;
|
|
333
|
+
/** Degraded width cap (fraction of the scrollport) while the flow probe is unanswered. */
|
|
334
|
+
const DEGRADED_WIDTH_RATIO = .4;
|
|
335
|
+
/**
|
|
336
|
+
* The header-utilities entry: the portal timeline panel.
|
|
337
|
+
* @param props - composed props (see {@link TimelineRailProps}).
|
|
338
|
+
* @returns nothing visible in the seat; the floating panel while the chat view shows.
|
|
339
|
+
*/
|
|
340
|
+
function TimelineRail({ useSession, sessionId, includeSteering, panelWidth, initialPages, loadOlder, jumpTo, useRail, t }) {
|
|
341
|
+
const rail = useRail((s) => s);
|
|
342
|
+
const order = useSession((s) => s.chat.order);
|
|
343
|
+
const nodes = useSession((s) => s.chat.nodes);
|
|
344
|
+
const hasMore = useSession((s) => s.hasMore);
|
|
345
|
+
const loadingOlder = useSession((s) => s.loadingOlder);
|
|
346
|
+
const items = (0, react.useMemo)(() => {
|
|
347
|
+
const result = [];
|
|
348
|
+
for (const key of order) {
|
|
349
|
+
const node = nodes.get(key);
|
|
350
|
+
if (node === void 0) continue;
|
|
351
|
+
const kind = node.kind;
|
|
352
|
+
if (kind !== "user" && !(includeSteering && kind === "steering")) continue;
|
|
353
|
+
result.push({
|
|
354
|
+
key,
|
|
355
|
+
node
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return result;
|
|
359
|
+
}, [
|
|
360
|
+
order,
|
|
361
|
+
nodes,
|
|
362
|
+
includeSteering
|
|
363
|
+
]);
|
|
364
|
+
const [focusKey, setFocusKey] = (0, react.useState)(null);
|
|
365
|
+
const current = focusKey ?? rail.activeKey ?? items.at(-1)?.key ?? null;
|
|
366
|
+
const panelRef = (0, react.useRef)(null);
|
|
367
|
+
const active = rail.chatView && rail.ready && rail.sessionId === sessionId;
|
|
368
|
+
const visible = active && items.length > 0;
|
|
369
|
+
const gutter = rail.flowLeft === null ? null : rail.flowLeft - rail.left - PANEL_PADDING_X - PANEL_GAP;
|
|
370
|
+
const width = gutter === null ? Math.min(panelWidth, Math.max(120, rail.scrollportWidth * DEGRADED_WIDTH_RATIO)) : Math.min(panelWidth, Math.max(0, gutter));
|
|
371
|
+
const tooNarrow = gutter !== null && width < 120;
|
|
372
|
+
(0, react.useEffect)(() => {
|
|
373
|
+
panelRef.current?.querySelector(`[data-item-key=${JSON.stringify(current)}]`)?.scrollIntoView({ block: "nearest" });
|
|
374
|
+
}, [current]);
|
|
375
|
+
const prefetchedPagesRef = (0, react.useRef)(0);
|
|
376
|
+
(0, react.useEffect)(() => {
|
|
377
|
+
if (!active) {
|
|
378
|
+
prefetchedPagesRef.current = 0;
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!hasMore || loadingOlder) return;
|
|
382
|
+
if (items.length > 0 && prefetchedPagesRef.current >= initialPages) return;
|
|
383
|
+
prefetchedPagesRef.current += 1;
|
|
384
|
+
loadOlder();
|
|
385
|
+
}, [
|
|
386
|
+
active,
|
|
387
|
+
hasMore,
|
|
388
|
+
loadingOlder,
|
|
389
|
+
items.length,
|
|
390
|
+
initialPages,
|
|
391
|
+
loadOlder
|
|
392
|
+
]);
|
|
393
|
+
const onPanelScroll = (event) => {
|
|
394
|
+
if (!hasMore || loadingOlder) return;
|
|
395
|
+
if (event.currentTarget.scrollTop <= 8) loadOlder();
|
|
396
|
+
};
|
|
397
|
+
const confirm = (key) => {
|
|
398
|
+
setFocusKey(null);
|
|
399
|
+
jumpTo(key);
|
|
400
|
+
};
|
|
401
|
+
if (!visible || tooNarrow) return null;
|
|
402
|
+
return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
403
|
+
ref: panelRef,
|
|
404
|
+
className: TimelineRail_module_css_default.panel,
|
|
405
|
+
role: "navigation",
|
|
406
|
+
"aria-label": t("rail.panel"),
|
|
407
|
+
tabIndex: 0,
|
|
408
|
+
"data-timeline-panel": "",
|
|
409
|
+
style: {
|
|
410
|
+
left: rail.left,
|
|
411
|
+
top: rail.top,
|
|
412
|
+
height: rail.height,
|
|
413
|
+
width
|
|
414
|
+
},
|
|
415
|
+
onScroll: onPanelScroll,
|
|
416
|
+
onKeyDown: (event) => {
|
|
417
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
418
|
+
event.preventDefault();
|
|
419
|
+
const anchor = items.findIndex((item) => item.key === current);
|
|
420
|
+
const next = event.key === "ArrowDown" ? items[Math.min(anchor + 1, items.length - 1)] : items[Math.max(anchor - 1, 0)];
|
|
421
|
+
/* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */
|
|
422
|
+
if (next !== void 0) setFocusKey(next.key);
|
|
423
|
+
} else if (event.key === "Enter" || event.key === " ") {
|
|
424
|
+
event.preventDefault();
|
|
425
|
+
/* v8 ignore next -- the panel only renders with at least one item, so a current key always exists */
|
|
426
|
+
if (current !== null) confirm(current);
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
onBlur: () => {
|
|
430
|
+
setFocusKey(null);
|
|
431
|
+
},
|
|
432
|
+
children: items.map((item) => {
|
|
433
|
+
const isCurrent = item.key === current;
|
|
434
|
+
const className = item.key === focusKey ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemFocused}` : isCurrent ? `${TimelineRail_module_css_default.item} ${TimelineRail_module_css_default.itemCurrent}` : TimelineRail_module_css_default.item;
|
|
435
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
436
|
+
type: "button",
|
|
437
|
+
"data-item-key": item.key,
|
|
438
|
+
"aria-current": isCurrent || void 0,
|
|
439
|
+
className,
|
|
440
|
+
onClick: () => {
|
|
441
|
+
confirm(item.key);
|
|
442
|
+
},
|
|
443
|
+
onMouseEnter: () => {
|
|
444
|
+
setFocusKey(item.key);
|
|
445
|
+
},
|
|
446
|
+
onMouseLeave: () => {
|
|
447
|
+
setFocusKey((key) => key === item.key ? null : key);
|
|
448
|
+
},
|
|
449
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
450
|
+
className: isCurrent ? `${TimelineRail_module_css_default.tick} ${TimelineRail_module_css_default.tickCurrent}` : TimelineRail_module_css_default.tick,
|
|
451
|
+
"aria-hidden": "true"
|
|
452
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
453
|
+
className: TimelineRail_module_css_default.itemText,
|
|
454
|
+
children: previewText(nodeContent(item.node)) ?? t("rail.empty")
|
|
455
|
+
})]
|
|
456
|
+
}, item.key);
|
|
457
|
+
})
|
|
458
|
+
}), document.body);
|
|
459
|
+
}
|
|
460
|
+
/** Memoized export for the slot machinery (stable component identity). */
|
|
461
|
+
const TimelineRailEntry = (0, react.memo)(TimelineRail);
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/client/index.ts
|
|
464
|
+
/** Dictionary namespace owned by this plugin. */
|
|
465
|
+
const NS = "message-timeline";
|
|
466
|
+
/** Required services: the slot ledger, the session store, and the copy. */
|
|
467
|
+
const inject = [
|
|
468
|
+
"slots",
|
|
469
|
+
"sessions",
|
|
470
|
+
"locale"
|
|
471
|
+
];
|
|
472
|
+
/**
|
|
473
|
+
* Client plugin body: register the header-utilities entry and install the DOM
|
|
474
|
+
* tracker that publishes rail geometry and answers jumps.
|
|
475
|
+
* @param ctx - client root context.
|
|
476
|
+
* @param config - entry config; defaults apply when the runner passes none.
|
|
477
|
+
*/
|
|
478
|
+
function apply(ctx, config) {
|
|
479
|
+
const options = resolveConfig(config);
|
|
480
|
+
ctx.effect(() => ctx.locale.register(NS, {
|
|
481
|
+
zh,
|
|
482
|
+
en
|
|
483
|
+
}), "message-timeline: dictionaries");
|
|
484
|
+
if (!options.enabled) return;
|
|
485
|
+
const tracker = installRailTracker(ctx, options.includeSteering);
|
|
486
|
+
ctx.effect(() => () => {
|
|
487
|
+
tracker.dispose();
|
|
488
|
+
}, "message-timeline: rail tracker");
|
|
489
|
+
ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
|
|
490
|
+
name: "conversation.session.header.utilities",
|
|
491
|
+
id: "message-timeline",
|
|
492
|
+
order: 100,
|
|
493
|
+
locale: NS,
|
|
494
|
+
inject: (sessionId) => {
|
|
495
|
+
const actx = ctx.sessions.scope(sessionId);
|
|
496
|
+
if (actx === void 0) throw new Error("message-timeline: session resolved no scope");
|
|
497
|
+
const conversation = actx.get("conversation");
|
|
498
|
+
if (conversation === void 0) throw new Error("message-timeline: conversation service unavailable");
|
|
499
|
+
return {
|
|
500
|
+
includeSteering: options.includeSteering,
|
|
501
|
+
panelWidth: options.panelWidth,
|
|
502
|
+
initialPages: options.initialPages,
|
|
503
|
+
loadOlder: () => conversation.loadOlder(),
|
|
504
|
+
jumpTo: (key) => {
|
|
505
|
+
tracker.jumpTo(key);
|
|
506
|
+
},
|
|
507
|
+
hooks: { rail: tracker.state }
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
}, TimelineRailEntry));
|
|
511
|
+
}
|
|
512
|
+
//#endregion
|
|
513
|
+
exports.apply = apply;
|
|
514
|
+
exports.inject = inject;
|
|
515
|
+
exports.previewText = previewText;
|
|
516
|
+
exports.resolveConfig = resolveConfig;
|
|
517
|
+
return module.exports;
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.js","names":["useMemo","useState","useRef","createPortal","css","memo"],"sources":["../src/client/config.ts","../src/client/locales.ts","../src/client/rail-tracker.ts","../src/client/preview.ts","../src/client/TimelineRail.tsx","../src/client/index.ts"],"sourcesContent":["/**\n * Browser-half configuration of the message-timeline plugin. The runner hands\n * the plugin its validated entry config through the apply second parameter;\n * this module supplies the defaults and clamps, so a composition that does not\n * pass config still renders with the documented values and out-of-range input\n * lands inside the legal bounds.\n */\n\n/** Deployment-tunable rail behavior. */\nexport interface TimelineConfig {\n /** Master switch: false hides the toggle and the rail entirely. */\n enabled: boolean\n /** Count steering messages (user text admitted mid-turn) as rows. */\n includeSteering: boolean\n /**\n * Preferred timeline panel width in px (clamped 120–640; long text\n * ellipsizes). The panel's right edge never crosses the message flow: the\n * width is capped by the scrollport's left gutter, so a narrow column\n * shrinks the panel automatically, and a gutter too small for\n * {@link PANEL_WIDTH_MIN} hides the panel entirely.\n */\n panelWidth: number\n /**\n * History pages to prefetch when the rail opens (50 events each); older\n * pages load on demand when the rail or the panel is scrolled to its top.\n */\n initialPages: number\n}\n\n/** Bounds of the deployment-tunable numbers (clamped in resolveConfig). */\nexport const PANEL_WIDTH_MIN = 120\nexport const PANEL_WIDTH_MAX = 640\nconst INITIAL_PAGES_MIN = 1\nconst INITIAL_PAGES_MAX = 20\n\nfunction clamp(value: number, min: number, max: number): number {\n return Math.min(max, Math.max(min, value))\n}\n\n/**\n * Normalize the entry config into the full {@link TimelineConfig}: every\n * omitted field takes its documented default and every numeric field is\n * clamped into its legal range.\n * @param config - the unvalidated entry config, when the runner passes one.\n * @returns the effective rail behavior.\n */\nexport function resolveConfig(config: Partial<TimelineConfig> | undefined): TimelineConfig {\n return {\n enabled: config?.enabled ?? true,\n includeSteering: config?.includeSteering ?? true,\n panelWidth: clamp(config?.panelWidth ?? 360, PANEL_WIDTH_MIN, PANEL_WIDTH_MAX),\n initialPages: clamp(config?.initialPages ?? 5, INITIAL_PAGES_MIN, INITIAL_PAGES_MAX),\n }\n}\n","/** `message-timeline` namespace dictionaries. */\n\n/** Simplified Chinese dictionary (the key-set source of truth). */\nexport const zh = {\n 'rail.panel': '消息导览',\n 'rail.empty': '暂无用户消息',\n} satisfies Record<string, string>\n\n/** The message-timeline namespace key union. */\nexport type TimelineKey = keyof typeof zh\n\ndeclare module '@deepseek-ai/dsh-client-ui-slots' {\n interface LocaleNamespaceMap {\n /** The message timeline panel's copy. */\n 'message-timeline': TimelineKey\n }\n}\n\n/** English dictionary, checked complete against the zh key set. */\nexport const en = {\n 'rail.panel': 'Message timeline',\n 'rail.empty': 'No user messages',\n} satisfies Record<TimelineKey, string>\n","/**\n * Rail DOM tracker, apply world. The floating timeline panel needs the\n * official chat scrollport's live geometry and the currently visible\n * user-message row, so\n * this module owns the only DOM the plugin touches — read-only probes plus\n * the scroll write the jump performs — and publishes the result through the\n * reserved hooks compartment (components never see the DOM or the sources).\n *\n * The probed attributes ([data-conversation-scroll], [data-chat-anchor-key],\n * [data-chat-flow-kind]) are official render output, not a declared API: when\n * they change, the tracker degrades — a missing scrollport hides the rail\n * (one console.warn), missing rows only clear the active marker and make\n * jumps no-ops. Nothing throws and no official code is modified.\n */\nimport type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'\nimport type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'\nimport type { TimelineRailState } from './slots.ts'\n\n/** Horizontal inset of the rail from the scrollport's left edge (px). */\nconst RAIL_LEFT_INSET = 6\n/** Vertical padding above and below the rail inside the scrollport (px). */\nconst RAIL_VERTICAL_PADDING = 8\n/** Keep the target row this far below the scrollport top after a jump (px). */\nconst JUMP_OFFSET = 16\n\n/** Idle state published before any session binds or while none is current. */\nconst IDLE: TimelineRailState = {\n sessionId: undefined, ready: false, left: 0, top: 0, height: 0, scrollportWidth: 0, flowLeft: null,\n activeKey: null, chatView: false,\n}\n\n/**\n * Measure the panel's viewport box from one scrollport: its rect inset by the\n * panel padding, minus the sticky composer seat at the bottom and the\n * conversation tab strip at the top. The tabs render just above the\n * scrollport, but centering reads against the whole window, so the strip\n * height leaves the box either way — otherwise the list sits visibly high.\n * The scrollport's own width rides along for the width-cap fallback when the\n * message-flow probe is unanswered.\n * @param scrollport - the official conversation scrollport element.\n * @returns the panel box plus the scrollport width, or null while the\n * scrollport has no laid-out size.\n */\nexport function measureGeometry(scrollport: HTMLElement): { left: number; top: number; height: number; width: number } | null {\n const rect = scrollport.getBoundingClientRect()\n if (rect.width === 0 && rect.height === 0) return null\n const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')\n const composerHeight = composer?.getBoundingClientRect().height ?? 0\n let topInset = RAIL_VERTICAL_PADDING\n for (const tabs of scrollport.ownerDocument.querySelectorAll<HTMLElement>('[role=\"tablist\"]')) {\n const tabsRect = tabs.getBoundingClientRect()\n // Only the strip adjacent to the scrollport's top edge counts as chrome.\n if (tabsRect.height > 0 && Math.abs(tabsRect.bottom - rect.top) <= 80) {\n topInset += tabsRect.height\n break\n }\n }\n return {\n left: rect.left + RAIL_LEFT_INSET,\n top: rect.top + topInset,\n height: Math.max(0, rect.height - composerHeight - topInset - RAIL_VERTICAL_PADDING),\n width: rect.width,\n }\n}\n\n/**\n * The viewport x of the message flow's left edge: the left of the first\n * rendered `[data-chat-flow-kind]` row, which sits flush inside the official\n * centered content column (max 748px, `margin: 0 auto`). Every flow row\n * shares that edge, so the first one found suffices. The panel's right edge\n * stays left of it — the panel may only occupy the scrollport's left gutter.\n * @param scrollport - the official conversation scrollport element.\n * @returns the flow's left edge, or null while no flow row is rendered.\n */\nexport function flowLeftX(scrollport: HTMLElement): number | null {\n const row = scrollport.querySelector<HTMLElement>('[data-chat-flow-kind]')\n return row === null ? null : row.getBoundingClientRect().left\n}\n\n/**\n * Resolve the key of the user-message row the reading position belongs to:\n * the first matching row whose bottom is still inside the viewport, or —\n * while the reader sits inside a long assistant answer with no user row\n * visible — the nearest user row above the viewport, so the lit tick stays\n * anchored to the question being answered instead of jumping to the\n * session's latest message.\n * @param scrollport - the official conversation scrollport element.\n * @param includeSteering - whether steering rows count as user messages.\n * @returns the row's anchor key, or null when no user row is rendered.\n */\nexport function activeRowKey(scrollport: HTMLElement, includeSteering: boolean): string | null {\n const viewTop = scrollport.getBoundingClientRect().top\n let lastAbove: string | null = null\n for (const row of scrollport.querySelectorAll<HTMLElement>('[data-chat-flow-kind]')) {\n const kind = row.dataset.chatFlowKind\n if (kind !== 'user' && !(includeSteering && kind === 'steering')) continue\n if (row.getBoundingClientRect().bottom <= viewTop) {\n lastAbove = row.dataset.chatAnchorKey ?? lastAbove\n continue\n }\n return row.dataset.chatAnchorKey ?? null\n }\n return lastAbove\n}\n\n/**\n * Scroll one user-message row to the top of the transcript scrollport.\n * @param scrollport - the official conversation scrollport element.\n * @param key - the target node's anchor key.\n * @returns whether the row was found and scrolled.\n */\nexport function jumpRow(scrollport: HTMLElement, key: string): boolean {\n const row = scrollport.querySelector<HTMLElement>(`[data-chat-anchor-key=${JSON.stringify(key)}]`)\n if (row === null) return false\n const target = row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top\n + scrollport.scrollTop - JUMP_OFFSET\n scrollport.scrollTop = Math.max(0, target)\n return true\n}\n\n/** The tracker's outward face: the observable state, the jump verb, and the disposer. */\nexport interface RailTracker {\n /** Live rail geometry and active marker for the current session. */\n readonly state: HostObservable<TimelineRailState>\n /** Scroll the transcript to the message addressed by `key` (no-op while unbound). */\n jumpTo(key: string): void\n /** Unbind every listener and observer. */\n dispose(): void\n}\n\n/**\n * Install the rail tracker: bind the current session's scrollport, publish\n * geometry/active updates on scroll and resize, and answer jump requests.\n * Follows the current session through the sessions list and provide channels\n * (the interface renders one conversation at a time, so one tracker suffices).\n * @param ctx - client root context (sessions service).\n * @param includeSteering - whether steering rows count as user dots.\n * @returns the tracker face.\n */\nexport function installRailTracker(ctx: ClientContext, includeSteering: boolean): RailTracker {\n const listeners = new Set<() => void>()\n let state: TimelineRailState = IDLE\n let activeSession: SessionId | undefined\n let scrollport: HTMLElement | null = null\n let resizeObserver: ResizeObserver | undefined\n let rafPending = false\n let warned = false\n\n const nextFrame = typeof requestAnimationFrame === 'function'\n ? requestAnimationFrame\n : (callback: () => void): void => { setTimeout(callback, 16) }\n\n const same = (left: TimelineRailState, right: TimelineRailState): boolean =>\n left.sessionId === right.sessionId && left.ready === right.ready\n && left.left === right.left && left.top === right.top\n && left.height === right.height && left.scrollportWidth === right.scrollportWidth\n && left.flowLeft === right.flowLeft\n && left.activeKey === right.activeKey\n && left.chatView === right.chatView\n\n const publish = (next: TimelineRailState): void => {\n if (same(state, next)) return\n state = next\n for (const fn of [...listeners]) fn()\n }\n\n const update = (): void => {\n if (scrollport === null) return\n const geometry = measureGeometry(scrollport)\n if (geometry === null) return\n publish({\n sessionId: activeSession,\n ready: true,\n left: geometry.left,\n top: geometry.top,\n height: geometry.height,\n scrollportWidth: geometry.width,\n flowLeft: flowLeftX(scrollport),\n activeKey: activeRowKey(scrollport, includeSteering),\n chatView: scrollport.querySelector('[data-chat-flow]') !== null,\n })\n }\n\n const scheduleUpdate = (): void => {\n if (rafPending) return\n rafPending = true\n nextFrame(() => {\n rafPending = false\n update()\n })\n }\n\n const onScroll = (): void => { scheduleUpdate() }\n\n const teardownBind = (): void => {\n if (scrollport !== null) scrollport.removeEventListener('scroll', onScroll)\n scrollport = null\n resizeObserver?.disconnect()\n resizeObserver = undefined\n rafPending = false\n }\n\n const bind = (sessionId: SessionId | undefined): void => {\n if (sessionId === activeSession && scrollport !== null) return\n teardownBind()\n activeSession = sessionId\n if (sessionId === undefined) {\n publish(IDLE)\n return\n }\n nextFrame(() => {\n const found = document.querySelector<HTMLElement>('[data-conversation-scroll]')\n if (found === null) {\n if (!warned) {\n warned = true\n console.warn('message-timeline: [data-conversation-scroll] not found; the rail stays hidden')\n }\n publish(IDLE)\n return\n }\n scrollport = found\n scrollport.addEventListener('scroll', onScroll, { passive: true })\n if (typeof ResizeObserver === 'function') {\n resizeObserver = new ResizeObserver(scheduleUpdate)\n resizeObserver.observe(scrollport)\n const composer = scrollport.querySelector<HTMLElement>('[data-composer-seat]')\n if (composer !== null) resizeObserver.observe(composer)\n }\n update()\n })\n }\n\n const bindCurrent = (): void => {\n bind(ctx.sessions.list.getSnapshot().current)\n }\n const stopList = ctx.sessions.list.subscribe(bindCurrent)\n const stopProvide = ctx.sessions.currentProvideInfo.subscribe(bindCurrent)\n bindCurrent()\n\n // Layout fallbacks beyond the scrollport's own ResizeObserver: a window\n // resize re-measures, and a body MutationObserver catches panel folds and\n // other layout changes that resize the scrollport without a window event.\n // Both go through the rAF-throttled scheduleUpdate, and publish() skips\n // identical geometry, so the cost stays one measurement per changed frame.\n const onWindowResize = (): void => { scheduleUpdate() }\n if (typeof window !== 'undefined') window.addEventListener('resize', onWindowResize)\n let mutationObserver: MutationObserver | undefined\n if (typeof MutationObserver === 'function' && typeof document !== 'undefined') {\n mutationObserver = new MutationObserver(scheduleUpdate)\n mutationObserver.observe(document.body, { childList: true, subtree: true })\n }\n\n return {\n state: {\n getSnapshot: () => state,\n subscribe: (fn) => {\n listeners.add(fn)\n return () => { listeners.delete(fn) }\n },\n },\n jumpTo: (key) => {\n if (scrollport !== null) jumpRow(scrollport, key)\n },\n dispose: () => {\n stopList()\n stopProvide()\n if (typeof window !== 'undefined') window.removeEventListener('resize', onWindowResize)\n mutationObserver?.disconnect()\n teardownBind()\n },\n }\n}\n","/**\n * Pure text extraction for the timeline panel rows: user message content is a\n * block list (text, image, …); the preview concatenates the text blocks so\n * long messages ellipsize through CSS instead of dropping their first lines.\n */\nimport type { ContentBlock } from '@deepseek-ai/dsh-llm'\n\n/**\n * Join the text blocks of one message into a single preview string.\n * @param content - the message's content block list.\n * @returns the concatenated text, or null when the message carries none.\n */\nexport function previewText(content: readonly ContentBlock[]): string | null {\n const parts: string[] = []\n for (const block of content) {\n if (block.type === 'text' && block.text !== '') parts.push(block.text)\n }\n return parts.length === 0 ? null : parts.join('\\n')\n}\n","/**\n * Message timeline panel, browser half. One entry in the official\n * conversation.session.header.utilities seat anchors the plugin into the\n * session scope and renders, through a body portal, the flat floating\n * timeline over the left edge of the chat scrollport: one row per loaded\n * user message — a tick plus an ellipsized one-line preview, no frame and no\n * visible scrollbar. At rest only the dimmed ticks show, the reading\n * position's tick in blue (the latest message until the tracker answers);\n * hovering or keyboard-focusing the panel reveals the row texts with the\n * blue row on top, and clicking a row jumps the transcript to that message.\n * The panel is always on while the chat view shows; the `enabled` config is\n * the off switch.\n */\nimport { memo, useEffect, useMemo, useRef, useState, type UIEvent } from 'react'\nimport { createPortal } from 'react-dom'\nimport type { ContentBlock } from '@deepseek-ai/dsh-llm'\nimport { PANEL_WIDTH_MIN } from './config.ts'\nimport type { TimelineItem, TimelineRailProps } from './slots.ts'\nimport { previewText } from './preview.ts'\nimport css from './TimelineRail.module.css'\n\n/** Extract the preview blocks of one user/steering node (kind-checked by the caller). */\nfunction nodeContent(node: { data: unknown }): readonly ContentBlock[] {\n return (node.data as { content?: readonly ContentBlock[] }).content ?? []\n}\n\n/**\n * Horizontal panel padding, matching `.panel`'s `padding: 4px 8px`: the text\n * band sits this far inside the box, so the width budget must give it back\n * for {@link PANEL_GAP} to be the visible gap to the message flow.\n */\nconst PANEL_PADDING_X = 8\n/** Visible breathing gap between the timeline text and the message flow (px). */\nconst PANEL_GAP = 16\n/** Degraded width cap (fraction of the scrollport) while the flow probe is unanswered. */\nconst DEGRADED_WIDTH_RATIO = 0.4\n\n/**\n * The header-utilities entry: the portal timeline panel.\n * @param props - composed props (see {@link TimelineRailProps}).\n * @returns nothing visible in the seat; the floating panel while the chat view shows.\n */\nexport function TimelineRail({\n useSession, sessionId,\n includeSteering, panelWidth, initialPages,\n loadOlder, jumpTo, useRail, t,\n}: TimelineRailProps) {\n const rail = useRail(s => s)\n const order = useSession(s => s.chat.order)\n const nodes = useSession(s => s.chat.nodes)\n const hasMore = useSession(s => s.hasMore)\n const loadingOlder = useSession(s => s.loadingOlder)\n\n const items = useMemo<TimelineItem[]>(() => {\n const result: TimelineItem[] = []\n for (const key of order) {\n const node = nodes.get(key)\n if (node === undefined) continue\n const kind = node.kind\n if (kind !== 'user' && !(includeSteering && kind === 'steering')) continue\n result.push({ key, node })\n }\n return result\n }, [order, nodes, includeSteering])\n\n // The lit row: the hover/arrow preselection while it moves, otherwise the\n // live reading position the tracker publishes, defaulting to the latest\n // message before the tracker answers. ArrowUp/Down or hovering moves a\n // bold preselection; Enter or a click confirms it and jumps.\n const [focusKey, setFocusKey] = useState<string | null>(null)\n const current = focusKey ?? rail.activeKey ?? items.at(-1)?.key ?? null\n const panelRef = useRef<HTMLDivElement | null>(null)\n\n // The panel is a chat-view affordance: hide it while the session shows\n // another tab (trajectory etc.), detected through ChatView's data-chat-flow\n // marker. `active` splits from `visible` so history paging can bootstrap: a\n // session whose loaded event window holds no user message yet (a huge\n // assistant turn pushed it past the first page) renders no rows but must\n // still pull pages until one materializes.\n const active = rail.chatView && rail.ready && rail.sessionId === sessionId\n const visible = active && items.length > 0\n\n // The panel must never cover the message flow: its width is the configured\n // preferred width capped by the scrollport's left gutter — the message\n // flow's left edge minus the panel's left edge, less the panel's right\n // padding and the visible breathing gap. A gutter too small for the minimum\n // usable width hides the panel entirely (the timeline is an overlay\n // affordance; squeezed into nothing it only intercepts the transcript).\n // When the flow probe is unanswered (official structure change), the width\n // degrades to a fraction of the scrollport instead — never throws, never\n // covers more than the fallback.\n const gutter = rail.flowLeft === null ? null : rail.flowLeft - rail.left - PANEL_PADDING_X - PANEL_GAP\n const width = gutter === null\n ? Math.min(panelWidth, Math.max(PANEL_WIDTH_MIN, rail.scrollportWidth * DEGRADED_WIDTH_RATIO))\n : Math.min(panelWidth, Math.max(0, gutter))\n const tooNarrow = gutter !== null && width < PANEL_WIDTH_MIN\n\n // Keep the lit row in view: the panel follows the reading position (a new\n // message scrolls its row in), and mouse browsing is never yanked because\n // the hovered row is the current one and always visible under the pointer.\n useEffect(() => {\n panelRef.current?.querySelector<HTMLElement>(`[data-item-key=${JSON.stringify(current)}]`)\n ?.scrollIntoView({ block: 'nearest' })\n }, [current])\n\n // Prefetch history while the panel is on a chat view: keep pulling pages\n // until the first user message materializes (bootstrap), then until\n // initialPages pages arrived, so the list starts near-complete. Older\n // pages load on demand when the panel scrolls to its top.\n const prefetchedPagesRef = useRef(0)\n useEffect(() => {\n if (!active) {\n prefetchedPagesRef.current = 0\n return\n }\n if (!hasMore || loadingOlder) return\n if (items.length > 0 && prefetchedPagesRef.current >= initialPages) return\n prefetchedPagesRef.current += 1\n void loadOlder()\n }, [active, hasMore, loadingOlder, items.length, initialPages, loadOlder])\n\n // Older history loads by scrolling the panel to its top; the official chat\n // view owns the load-older button, so the panel stays chromeless.\n const onPanelScroll = (event: UIEvent<HTMLDivElement>): void => {\n if (!hasMore || loadingOlder) return\n if (event.currentTarget.scrollTop <= 8) void loadOlder()\n }\n\n const confirm = (key: string): void => {\n setFocusKey(null)\n jumpTo(key)\n }\n\n if (!visible || tooNarrow) return null\n return createPortal(\n <div\n ref={panelRef}\n className={css.panel}\n role=\"navigation\"\n aria-label={t('rail.panel')}\n tabIndex={0}\n data-timeline-panel=\"\"\n style={{ left: rail.left, top: rail.top, height: rail.height, width }}\n onScroll={onPanelScroll}\n onKeyDown={(event) => {\n if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {\n event.preventDefault()\n const anchor = items.findIndex(item => item.key === current)\n const next = event.key === 'ArrowDown'\n ? items[Math.min(anchor + 1, items.length - 1)]\n : items[Math.max(anchor - 1, 0)]\n /* v8 ignore next -- a non-empty item list keeps the clamped index inside bounds */\n if (next !== undefined) setFocusKey(next.key)\n } else if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault()\n /* v8 ignore next -- the panel only renders with at least one item, so a current key always exists */\n if (current !== null) confirm(current)\n }\n }}\n onBlur={() => { setFocusKey(null) }}\n >\n {items.map((item) => {\n const isCurrent = item.key === current\n const isFocused = item.key === focusKey\n const className = isFocused\n ? `${css.item} ${css.itemFocused}`\n : isCurrent ? `${css.item} ${css.itemCurrent}` : css.item\n return (\n <button\n key={item.key}\n type=\"button\"\n data-item-key={item.key}\n aria-current={isCurrent || undefined}\n className={className}\n onClick={() => { confirm(item.key) }}\n onMouseEnter={() => { setFocusKey(item.key) }}\n onMouseLeave={() => { setFocusKey(key => key === item.key ? null : key) }}\n >\n <span className={isCurrent ? `${css.tick} ${css.tickCurrent}` : css.tick} aria-hidden=\"true\" />\n <span className={css.itemText}>{previewText(nodeContent(item.node)) ?? t('rail.empty')}</span>\n </button>\n )\n })}\n </div>,\n document.body,\n )\n}\n\n/** Memoized export for the slot machinery (stable component identity). */\nexport const TimelineRailEntry = memo(TimelineRail)\n","/**\n * Message timeline plugin, browser half. Registers one entry into the\n * official `conversation.session.header.utilities` seat (a right-aligned\n * optional-utility slot) that anchors the plugin into the session scope; the\n * component itself renders only through a body portal: the flat floating\n * timeline panel over the chat scrollport's left edge — one row per loaded\n * user message, no frame, no visible scrollbar. The\n * panel reads the session chat snapshot through the framework `useSession`\n * hook, jumps through the official row anchor attributes, and degrades to a\n * hidden panel when those attributes change — no official code is modified.\n * @module @khorsheed/dsh-message-timeline/client\n */\nimport type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'\n// Type-only: pulls the ctx.locale service merge.\nimport type {} from '@deepseek-ai/dsh-client-locale/client'\n// Type-only: pulls ui-conversation's SlotMap merge (the header utilities seat)\n// and the conversation service merge.\nimport type {} from '@deepseek-ai/dsh-client-ui-conversation/client'\nimport { resolveConfig, type TimelineConfig } from './config.ts'\nimport { en, zh } from './locales.ts'\nimport { installRailTracker } from './rail-tracker.ts'\nimport type { TimelineRailInjected } from './slots.ts'\nimport { TimelineRailEntry } from './TimelineRail.tsx'\n\nexport type { TimelineConfig } from './config.ts'\nexport { resolveConfig } from './config.ts'\nexport type { TimelineKey } from './locales.ts'\nexport { previewText } from './preview.ts'\nexport type { TimelineItem, TimelineRailInjected, TimelineRailProps, TimelineRailState } from './slots.ts'\n\n/** Dictionary namespace owned by this plugin. */\nconst NS = 'message-timeline'\n\n/** Required services: the slot ledger, the session store, and the copy. */\nexport const inject = ['slots', 'sessions', 'locale']\n\n/**\n * Client plugin body: register the header-utilities entry and install the DOM\n * tracker that publishes rail geometry and answers jumps.\n * @param ctx - client root context.\n * @param config - entry config; defaults apply when the runner passes none.\n */\nexport function apply(ctx: ClientContext, config?: Partial<TimelineConfig>): void {\n const options = resolveConfig(config)\n ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'message-timeline: dictionaries')\n if (!options.enabled) return\n\n const tracker = installRailTracker(ctx, options.includeSteering)\n ctx.effect(() => () => { tracker.dispose() }, 'message-timeline: rail tracker')\n\n // The slot is declared by ui-conversation, whose apply order relative to\n // this plugin is unconstrained: register through slots.inject so the entry\n // waits for the declaration instead of crashing the loader at boot.\n ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({\n name: 'conversation.session.header.utilities',\n id: 'message-timeline',\n order: 100,\n locale: NS,\n inject: (sessionId): TimelineRailInjected => {\n const actx = ctx.sessions.scope(sessionId)\n if (actx === undefined) throw new Error('message-timeline: session resolved no scope')\n const conversation = actx.get('conversation')\n if (conversation === undefined) throw new Error('message-timeline: conversation service unavailable')\n return {\n includeSteering: options.includeSteering,\n panelWidth: options.panelWidth,\n initialPages: options.initialPages,\n loadOlder: () => conversation.loadOlder(),\n jumpTo: (key) => { tracker.jumpTo(key) },\n hooks: { rail: tracker.state },\n }\n },\n }, TimelineRailEntry))\n}\n"],"mappings":"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"}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
//#region lib/types/index.js
|
|
2
|
+
/**
|
|
3
|
+
* Message timeline plugin, node half. Pure UI plugin: the empty apply exists
|
|
4
|
+
* so the plugin appears in the host cordis.yml / Loader; the browser half
|
|
5
|
+
* ships via exports["./client"], discovered through the package.json
|
|
6
|
+
* dsh.client declaration.
|
|
7
|
+
*/
|
|
8
|
+
/** Host plugin body — no host-side behavior for this surface plugin. */
|
|
9
|
+
function apply() {}
|
|
10
|
+
//#endregion
|
|
11
|
+
export { apply };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@khorsheed/dsh-message-timeline`.
|
|
4
|
+
* @module @khorsheed/dsh-message-timeline/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@khorsheed/dsh-message-timeline";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "client-message-timeline-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: the rail contribution is one slot entry whose disposal
|
|
13
|
+
* is proven by the HMR-safety spec — the plugin writes no session state, emits
|
|
14
|
+
* no cordis events, and reads the conversation snapshot through the framework
|
|
15
|
+
* hook only. Its DOM probe targets the official chat row attributes
|
|
16
|
+
* ([data-chat-anchor-key] / [data-conversation-scroll]) read-only and
|
|
17
|
+
* degrades to a hidden rail when they change, so no second authority exists to
|
|
18
|
+
* check at runtime.
|
|
19
|
+
*/
|
|
20
|
+
const install = () => {};
|
|
21
|
+
/**
|
|
22
|
+
* Register this package's invariant companion.
|
|
23
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
24
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
25
|
+
*/
|
|
26
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
27
|
+
//#endregion
|
|
28
|
+
export { apply, inject, name };
|