@chestnut23/dsh-conversation-outline 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/LICENSE +21 -0
- package/README.md +113 -0
- package/README.zh.md +105 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +106 -0
- package/cordis.patch.yml +12 -0
- package/docs/implementation-spec.md +455 -0
- package/docs/publishing-guide.md +384 -0
- package/docs/security.md +47 -0
- package/docs/troubleshooting.md +65 -0
- package/docs/usage.md +62 -0
- package/lib/client/OutlinePanel.js +246 -0
- package/lib/client/index.js +27 -0
- package/lib/client/locales.js +32 -0
- package/lib/client/outline.js +83 -0
- package/lib/client/styles.js +304 -0
- package/lib/client.js +750 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +15 -0
- package/lib/types/client/OutlinePanel.d.ts +23 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/locales.d.ts +36 -0
- package/lib/types/client/outline.d.ts +82 -0
- package/lib/types/client/styles.d.ts +14 -0
- package/lib/types/index.d.ts +14 -0
- package/package.json +82 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
3
|
+
import { collectQuestions, filterQuestions, formatTime, isJumpTargetRow, } from "./outline.js";
|
|
4
|
+
/** Stable getSnapshot value while no session is current (uSES contract). */
|
|
5
|
+
const NO_SESSION = null;
|
|
6
|
+
const JUMP_HEADROOM = 96;
|
|
7
|
+
const JUMP_TIMEOUT_MS = 1500;
|
|
8
|
+
const FLASH_MS = 1900;
|
|
9
|
+
const COPIED_MS = 1500;
|
|
10
|
+
/** Grace period before the hover panel collapses (lets the pointer travel). */
|
|
11
|
+
const COLLAPSE_DELAY_MS = 240;
|
|
12
|
+
/** Rail capacity; older questions fold into the "+N" marker. */
|
|
13
|
+
const MAX_BARS = 60;
|
|
14
|
+
/**
|
|
15
|
+
* The conversation view's header tablist, scoped to the conversation root:
|
|
16
|
+
* walk up from the scrollport's PARENT (the scrollport itself may contain
|
|
17
|
+
* other tablists, e.g. the trajectory event-details tabs) and return the
|
|
18
|
+
* nearest ancestor containing a `[role="tablist"]`. Never falls back to a
|
|
19
|
+
* document-wide query, so other tablists (trajectory event details,
|
|
20
|
+
* settings, cordis source viewer) are not hit.
|
|
21
|
+
*/
|
|
22
|
+
function findConversationTablist(scrollport) {
|
|
23
|
+
let node = scrollport.parentElement;
|
|
24
|
+
while (node) {
|
|
25
|
+
const tablist = node.querySelector('[role="tablist"]');
|
|
26
|
+
if (tablist)
|
|
27
|
+
return tablist;
|
|
28
|
+
node = node.parentElement;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** RAF that prunes itself from the tracking ref once it fires. */
|
|
33
|
+
function scheduleRaf(rafRef, fn) {
|
|
34
|
+
const id = requestAnimationFrame(() => {
|
|
35
|
+
rafRef.current = rafRef.current.filter((x) => x !== id);
|
|
36
|
+
fn();
|
|
37
|
+
});
|
|
38
|
+
rafRef.current.push(id);
|
|
39
|
+
}
|
|
40
|
+
/** Timeout that prunes itself from the tracking ref once it fires. */
|
|
41
|
+
function scheduleTimeout(timeoutRef, fn, ms) {
|
|
42
|
+
const id = window.setTimeout(() => {
|
|
43
|
+
timeoutRef.current = timeoutRef.current.filter((x) => x !== id);
|
|
44
|
+
fn();
|
|
45
|
+
}, ms);
|
|
46
|
+
timeoutRef.current.push(id);
|
|
47
|
+
}
|
|
48
|
+
export function OutlinePanel({ sessions, useSessions, t }) {
|
|
49
|
+
const current = useSessions((s) => s.current);
|
|
50
|
+
const session = current ? sessions.binding(current)?.session : undefined;
|
|
51
|
+
// Conversation snapshot subscription (spec §1.5): live updates while the
|
|
52
|
+
// session runs, load-older, blank detection — all ride this one store.
|
|
53
|
+
const subscribe = useCallback((onChange) => (session ? session.subscribe(onChange) : () => { }), [session]);
|
|
54
|
+
const getSnapshot = useCallback(() => (session ? session.getSnapshot() : NO_SESSION), [session]);
|
|
55
|
+
const snapshot = useSyncExternalStore(subscribe, getSnapshot);
|
|
56
|
+
const [open, setOpen] = useState(false);
|
|
57
|
+
const [query, setQuery] = useState('');
|
|
58
|
+
const [copiedKey, setCopiedKey] = useState(null);
|
|
59
|
+
const timeoutsRef = useRef([]);
|
|
60
|
+
const rafsRef = useRef([]);
|
|
61
|
+
const flashedRowRef = useRef(null);
|
|
62
|
+
const collapseTimerRef = useRef(null);
|
|
63
|
+
// Cancel pending jump work (RAFs/timeouts), the flash highlight, and a
|
|
64
|
+
// scheduled hover collapse.
|
|
65
|
+
const clearPending = useCallback(() => {
|
|
66
|
+
for (const id of rafsRef.current)
|
|
67
|
+
window.cancelAnimationFrame(id);
|
|
68
|
+
for (const id of timeoutsRef.current)
|
|
69
|
+
window.clearTimeout(id);
|
|
70
|
+
rafsRef.current = [];
|
|
71
|
+
timeoutsRef.current = [];
|
|
72
|
+
if (collapseTimerRef.current !== null) {
|
|
73
|
+
window.clearTimeout(collapseTimerRef.current);
|
|
74
|
+
collapseTimerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
if (flashedRowRef.current) {
|
|
77
|
+
flashedRowRef.current.removeAttribute('data-dsh-outline-flash');
|
|
78
|
+
flashedRowRef.current = null;
|
|
79
|
+
}
|
|
80
|
+
}, []);
|
|
81
|
+
// Session change → cancel pending work and collapse (navigate closes the
|
|
82
|
+
// panel, spec §2.1; a mid-jump poll must not target the stale session).
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
clearPending();
|
|
85
|
+
setOpen(false);
|
|
86
|
+
}, [current, clearPending]);
|
|
87
|
+
// Escape closes the panel (hover-opened or tap-pinned).
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!open)
|
|
90
|
+
return;
|
|
91
|
+
const onKey = (event) => {
|
|
92
|
+
if (event.key === 'Escape')
|
|
93
|
+
setOpen(false);
|
|
94
|
+
};
|
|
95
|
+
window.addEventListener('keydown', onKey);
|
|
96
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
97
|
+
}, [open]);
|
|
98
|
+
// Unmount cleanup: cancel pending flash/copied timeouts, jump RAFs, collapse
|
|
99
|
+
// timer, and the flash attribute.
|
|
100
|
+
useEffect(() => clearPending, [clearPending]);
|
|
101
|
+
const allItems = useMemo(() => (snapshot ? collectQuestions(snapshot) : []), [snapshot]);
|
|
102
|
+
const items = useMemo(() => filterQuestions(allItems, query), [allItems, query]);
|
|
103
|
+
const blank = snapshot?.blank ?? true;
|
|
104
|
+
const hasMore = snapshot?.hasMore ?? false;
|
|
105
|
+
const loadingOlder = snapshot?.loadingOlder ?? false;
|
|
106
|
+
// ---- hover-open mechanics (rail ⇄ panel travel survives via the grace) ---
|
|
107
|
+
const cancelCollapse = useCallback(() => {
|
|
108
|
+
if (collapseTimerRef.current !== null) {
|
|
109
|
+
window.clearTimeout(collapseTimerRef.current);
|
|
110
|
+
collapseTimerRef.current = null;
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
const scheduleCollapse = useCallback(() => {
|
|
114
|
+
cancelCollapse();
|
|
115
|
+
collapseTimerRef.current = window.setTimeout(() => {
|
|
116
|
+
collapseTimerRef.current = null;
|
|
117
|
+
setOpen(false);
|
|
118
|
+
}, COLLAPSE_DELAY_MS);
|
|
119
|
+
}, [cancelCollapse]);
|
|
120
|
+
const showPanel = useCallback(() => {
|
|
121
|
+
cancelCollapse();
|
|
122
|
+
setOpen(true);
|
|
123
|
+
}, [cancelCollapse]);
|
|
124
|
+
/** Find the chat row for a node key (pure predicate over DOM rows). */
|
|
125
|
+
const findRow = useCallback((key) => {
|
|
126
|
+
const rows = document.querySelectorAll('[data-chat-anchor-key]');
|
|
127
|
+
for (const row of rows) {
|
|
128
|
+
if (isJumpTargetRow(row, key))
|
|
129
|
+
return row;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}, []);
|
|
133
|
+
/** Scroll the row into view (96px headroom) and flash a highlight. */
|
|
134
|
+
const flashAndScroll = useCallback((row) => {
|
|
135
|
+
const scrollport = row.closest('[data-conversation-scroll]') ?? document.querySelector('[data-conversation-scroll]');
|
|
136
|
+
if (scrollport) {
|
|
137
|
+
const flowTop = row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top;
|
|
138
|
+
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
139
|
+
scrollport.scrollTo({
|
|
140
|
+
top: scrollport.scrollTop + flowTop - JUMP_HEADROOM,
|
|
141
|
+
behavior: reduced ? 'auto' : 'smooth',
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// A second jump within FLASH_MS: clear the previous row first.
|
|
145
|
+
if (flashedRowRef.current && flashedRowRef.current !== row) {
|
|
146
|
+
flashedRowRef.current.removeAttribute('data-dsh-outline-flash');
|
|
147
|
+
}
|
|
148
|
+
flashedRowRef.current = row;
|
|
149
|
+
row.setAttribute('data-dsh-outline-flash', 'true');
|
|
150
|
+
scheduleTimeout(timeoutsRef, () => {
|
|
151
|
+
if (flashedRowRef.current === row) {
|
|
152
|
+
row.removeAttribute('data-dsh-outline-flash');
|
|
153
|
+
flashedRowRef.current = null;
|
|
154
|
+
}
|
|
155
|
+
}, FLASH_MS);
|
|
156
|
+
}, []);
|
|
157
|
+
/** Jump-to-message algorithm (spec §2.2). */
|
|
158
|
+
const jumpTo = useCallback((key) => {
|
|
159
|
+
// The conversation page must be mounted; abort silently otherwise.
|
|
160
|
+
const scrollport = document.querySelector('[data-conversation-scroll]');
|
|
161
|
+
if (!scrollport)
|
|
162
|
+
return;
|
|
163
|
+
// Ensure the Chat view is active: chat is order 0 — the FIRST tab of the
|
|
164
|
+
// conversation-root tablist, when one exists (setView is idempotent).
|
|
165
|
+
// A profile without extra views has no header tablist at all — that is
|
|
166
|
+
// fine, the rows are already visible.
|
|
167
|
+
const tablist = findConversationTablist(scrollport);
|
|
168
|
+
if (tablist) {
|
|
169
|
+
const firstTab = tablist.querySelector('button[role="tab"]');
|
|
170
|
+
if (firstTab)
|
|
171
|
+
firstTab.click();
|
|
172
|
+
}
|
|
173
|
+
// Poll for the target row regardless of tablist presence — render timing
|
|
174
|
+
// only; the row is guaranteed in the loaded window (it came from the
|
|
175
|
+
// snapshot). Canceled on session change/unmount via clearPending.
|
|
176
|
+
const deadline = Date.now() + JUMP_TIMEOUT_MS;
|
|
177
|
+
const poll = () => {
|
|
178
|
+
const row = findRow(key);
|
|
179
|
+
if (row) {
|
|
180
|
+
flashAndScroll(row);
|
|
181
|
+
setOpen(false);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() < deadline) {
|
|
185
|
+
scheduleRaf(rafsRef, poll);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
console.warn(t('jumpFailed'), { key });
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
scheduleRaf(rafsRef, poll);
|
|
192
|
+
}, [findRow, flashAndScroll, t]);
|
|
193
|
+
/** Copy a question's text (clipboard API with execCommand fallback). */
|
|
194
|
+
const copyText = useCallback((text, key) => {
|
|
195
|
+
const done = () => {
|
|
196
|
+
setCopiedKey(key);
|
|
197
|
+
scheduleTimeout(timeoutsRef, () => setCopiedKey((k) => (k === key ? null : k)), COPIED_MS);
|
|
198
|
+
};
|
|
199
|
+
const fallback = () => {
|
|
200
|
+
try {
|
|
201
|
+
const ta = document.createElement('textarea');
|
|
202
|
+
ta.value = text;
|
|
203
|
+
ta.setAttribute('readonly', '');
|
|
204
|
+
ta.style.position = 'fixed';
|
|
205
|
+
ta.style.opacity = '0';
|
|
206
|
+
document.body.appendChild(ta);
|
|
207
|
+
ta.select();
|
|
208
|
+
document.execCommand('copy');
|
|
209
|
+
ta.remove();
|
|
210
|
+
done();
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// clipboard unavailable — ignore
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
if (navigator.clipboard?.writeText) {
|
|
217
|
+
navigator.clipboard.writeText(text).then(done, fallback);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
fallback();
|
|
221
|
+
}
|
|
222
|
+
}, []);
|
|
223
|
+
// No current session, or a blank session → render nothing (rail included).
|
|
224
|
+
// MUST stay below every hook: this overlay entry stays mounted across
|
|
225
|
+
// session changes, and blank flips false on the first accepted prompt, so a
|
|
226
|
+
// conditional hook would crash React (reviewer MUST-FIX #1).
|
|
227
|
+
if (!current || !session || blank)
|
|
228
|
+
return null;
|
|
229
|
+
// Rail bars: chronological top→bottom, capped at MAX_BARS with the overflow
|
|
230
|
+
// folded into a "+N" marker (the rail is a minimap, not the full list).
|
|
231
|
+
const start = Math.max(0, allItems.length - MAX_BARS);
|
|
232
|
+
const bars = allItems.slice(start);
|
|
233
|
+
const overflow = start;
|
|
234
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { className: "dso-rail", role: "group", "aria-label": t('title'), tabIndex: 0, onMouseEnter: showPanel, onMouseLeave: scheduleCollapse, onFocus: showPanel, onBlur: scheduleCollapse, onClick: (event) => {
|
|
235
|
+
// Tap (touch) on the strip itself pins/unpins the panel.
|
|
236
|
+
if (event.target === event.currentTarget)
|
|
237
|
+
setOpen((v) => !v);
|
|
238
|
+
}, onKeyDown: (event) => {
|
|
239
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
240
|
+
event.preventDefault();
|
|
241
|
+
setOpen((v) => !v);
|
|
242
|
+
}
|
|
243
|
+
}, children: [overflow > 0 && (_jsxs("span", { className: "dso-rail-more", "aria-label": t('moreBars', { count: overflow }), children: ["+", overflow] })), bars.map((item, index) => (_jsx("button", { type: "button", className: "dso-bar", "aria-label": t('barLabel', { n: start + index + 1 }), onClick: () => jumpTo(item.key) }, item.key)))] }), open && (_jsxs("div", { className: "dso-panel", role: "region", "aria-label": t('title'), onMouseEnter: showPanel, onMouseLeave: scheduleCollapse, onFocus: showPanel, onBlur: scheduleCollapse, children: [_jsxs("header", { className: "dso-panel-header", children: [_jsx("span", { className: "dso-panel-title", children: t('title') }), _jsx("span", { className: "dso-panel-count", children: t('count', { count: allItems.length }) }), _jsx("button", { type: "button", className: "dso-panel-close", "aria-label": t('close'), onClick: () => setOpen(false), children: "\u00D7" })] }), _jsx("input", { className: "dso-search", type: "search", value: query, placeholder: t('searchPlaceholder'), "aria-label": t('searchPlaceholder'), onChange: (event) => setQuery(event.target.value) }), _jsx("div", { className: "dso-list", children: items.length === 0 ? (_jsx("div", { className: "dso-empty", children: t('empty') })) : (items.map((item) => (_jsxs("div", { className: "dso-row", children: [_jsxs("button", { type: "button", className: "dso-row-main", onClick: () => jumpTo(item.key), children: [_jsx("span", { className: "dso-turn", children: item.turn !== undefined ? `#${item.turn}` : '' }), _jsx("span", { className: "dso-text", children: item.text }), item.kind === 'steering' && _jsx("span", { className: "dso-steer", children: t('steerTag') }), _jsx("span", { className: "dso-time", children: formatTime(item.time) })] }), _jsx("button", { type: "button", className: copiedKey === item.key ? 'dso-copy dso-copied' : 'dso-copy', "aria-label": copiedKey === item.key ? t('copied') : t('copy'), onClick: () => copyText(item.text, item.key), children: copiedKey === item.key ? t('copied') : t('copy') })] }, item.key)))) }), hasMore && (_jsx("footer", { className: "dso-footer", children: _jsx("button", { type: "button", className: "dso-load-older", disabled: loadingOlder, onClick: () => {
|
|
244
|
+
void session.loadOlder();
|
|
245
|
+
}, children: loadingOlder ? t('loadingOlder') : t('loadOlder') }) }))] }))] }));
|
|
246
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { OutlinePanel } from "./OutlinePanel.js";
|
|
2
|
+
import { NS, dictionaries } from "./locales.js";
|
|
3
|
+
import { injectStyle } from "./styles.js";
|
|
4
|
+
/**
|
|
5
|
+
* Client entry (implementation-spec §1.1/§2.4): the browser half of the
|
|
6
|
+
* plugin. Cordis services this fiber waits for before apply() runs.
|
|
7
|
+
*/
|
|
8
|
+
export const inject = ['slots', 'sessions', 'locale'];
|
|
9
|
+
export function apply(ctx) {
|
|
10
|
+
// Locale dictionaries — the typed register form (both shipped locales
|
|
11
|
+
// required, keys compile-checked). The disposer is owned by the fiber, so
|
|
12
|
+
// HMR removes the namespace on unload.
|
|
13
|
+
ctx.effect(() => ctx.locale.register(NS, dictionaries), 'dsh-conversation-outline: locale');
|
|
14
|
+
// Panel CSS as an HMR-safe <style data-plugin> tag (effect-owned).
|
|
15
|
+
injectStyle(ctx);
|
|
16
|
+
// Right-edge rail + hover panel in the frame-wide overlay layer.
|
|
17
|
+
// slots.inject waits for the layout's declaration of `shell.overlay` and
|
|
18
|
+
// routes the registration (and its unload cascade) through this fiber.
|
|
19
|
+
// `locale: NS` gives the component the typed `t` seat; the inject factory
|
|
20
|
+
// passes the sessions service face.
|
|
21
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
22
|
+
name: 'shell.overlay',
|
|
23
|
+
id: 'dsh-conversation-outline.rail',
|
|
24
|
+
locale: NS,
|
|
25
|
+
inject: () => ({ sessions: ctx.sessions }),
|
|
26
|
+
}, OutlinePanel));
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const NS = 'dsh-conversation-outline';
|
|
2
|
+
export const zh = {
|
|
3
|
+
title: '会话大纲',
|
|
4
|
+
count: '{count} 个问题',
|
|
5
|
+
searchPlaceholder: '搜索问题…',
|
|
6
|
+
empty: '当前会话还没有问题',
|
|
7
|
+
loadOlder: '加载更早',
|
|
8
|
+
loadingOlder: '加载中…',
|
|
9
|
+
copy: '复制',
|
|
10
|
+
copied: '已复制',
|
|
11
|
+
steerTag: '追问',
|
|
12
|
+
close: '关闭',
|
|
13
|
+
jumpFailed: '跳转失败:未找到对应消息',
|
|
14
|
+
barLabel: '跳到第 {n} 个问题',
|
|
15
|
+
moreBars: '还有 {count} 个更早的问题',
|
|
16
|
+
};
|
|
17
|
+
export const en = {
|
|
18
|
+
title: 'Outline',
|
|
19
|
+
count: '{count} questions',
|
|
20
|
+
searchPlaceholder: 'Search questions…',
|
|
21
|
+
empty: 'No questions in this conversation yet',
|
|
22
|
+
loadOlder: 'Load older',
|
|
23
|
+
loadingOlder: 'Loading…',
|
|
24
|
+
copy: 'Copy',
|
|
25
|
+
copied: 'Copied',
|
|
26
|
+
steerTag: 'steer',
|
|
27
|
+
close: 'Close',
|
|
28
|
+
jumpFailed: 'Jump failed: target message not found',
|
|
29
|
+
barLabel: 'Jump to question {n}',
|
|
30
|
+
moreBars: '{count} more earlier questions',
|
|
31
|
+
};
|
|
32
|
+
export const dictionaries = { zh, en };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure logic for the conversation outline (implementation-spec §2.3).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately has NO DOM access and NO React imports so scripts/verify.mjs can
|
|
5
|
+
* import it directly in Node from the tsc output (lib/client/outline.js) and
|
|
6
|
+
* assert the derivations offline.
|
|
7
|
+
*
|
|
8
|
+
* Input shapes are structural "lite" views of the runtime snapshot types
|
|
9
|
+
* (@deepseek-ai/dsh-client-runtime/client) — the real objects are assignable
|
|
10
|
+
* to them, and Node can import this module without the browser type graph.
|
|
11
|
+
*/
|
|
12
|
+
const IMAGE_PLACEHOLDER = '[image]';
|
|
13
|
+
/**
|
|
14
|
+
* Flatten message content blocks into a single display string: text blocks are
|
|
15
|
+
* joined, image blocks become a placeholder, and whitespace is trimmed and
|
|
16
|
+
* collapsed.
|
|
17
|
+
*/
|
|
18
|
+
export function flattenQuestionText(content) {
|
|
19
|
+
return content
|
|
20
|
+
.map((block) => {
|
|
21
|
+
if (block.type === 'text')
|
|
22
|
+
return block.text ?? '';
|
|
23
|
+
if (block.type === 'image')
|
|
24
|
+
return IMAGE_PLACEHOLDER;
|
|
25
|
+
return '';
|
|
26
|
+
})
|
|
27
|
+
.join(' ')
|
|
28
|
+
.trim()
|
|
29
|
+
.replace(/\s+/g, ' ');
|
|
30
|
+
}
|
|
31
|
+
/** Turn number of a node location: step/turn → location.turn.turn, else undefined. */
|
|
32
|
+
function turnOf(location) {
|
|
33
|
+
if (!location)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (location.kind === 'step' || location.kind === 'turn')
|
|
36
|
+
return location.turn?.turn;
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Walk the chat flow in order, keep user/steering nodes, skip empty text, and
|
|
41
|
+
* produce chronological outline items (spec §2.3).
|
|
42
|
+
*/
|
|
43
|
+
export function collectQuestions(snapshot) {
|
|
44
|
+
const items = [];
|
|
45
|
+
for (const key of snapshot.chat.order) {
|
|
46
|
+
const node = snapshot.chat.nodes.get(key);
|
|
47
|
+
if (!node)
|
|
48
|
+
continue;
|
|
49
|
+
if (node.kind !== 'user' && node.kind !== 'steering')
|
|
50
|
+
continue;
|
|
51
|
+
const data = node.data;
|
|
52
|
+
const text = flattenQuestionText(data?.content ?? []);
|
|
53
|
+
if (!text)
|
|
54
|
+
continue;
|
|
55
|
+
items.push({
|
|
56
|
+
key: node.key,
|
|
57
|
+
kind: node.kind,
|
|
58
|
+
seq: data?.seq ?? 0,
|
|
59
|
+
time: data?.time ?? 0,
|
|
60
|
+
turn: turnOf(node.location),
|
|
61
|
+
text,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return items;
|
|
65
|
+
}
|
|
66
|
+
/** Case-insensitive substring filter over the flattened question text. */
|
|
67
|
+
export function filterQuestions(items, query) {
|
|
68
|
+
const q = query.trim().toLowerCase();
|
|
69
|
+
if (!q)
|
|
70
|
+
return items;
|
|
71
|
+
return items.filter((item) => item.text.toLowerCase().includes(q));
|
|
72
|
+
}
|
|
73
|
+
/** Local `HH:MM` from a unix-ms timestamp (spec §2.3). */
|
|
74
|
+
export function formatTime(ms) {
|
|
75
|
+
const d = new Date(ms);
|
|
76
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
77
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
78
|
+
return `${hh}:${mm}`;
|
|
79
|
+
}
|
|
80
|
+
/** Pure DOM predicate for the jump loop: is this row the target node? */
|
|
81
|
+
export function isJumpTargetRow(row, key) {
|
|
82
|
+
return row.getAttribute('data-chat-anchor-key') === key;
|
|
83
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outline rail + hover panel CSS (implementation-spec §1.8/§2.1, revised):
|
|
3
|
+
* a thin right-edge rail (conversation minimap) that is always visible while
|
|
4
|
+
* the session has questions, and a preview panel that expands on hover.
|
|
5
|
+
* Plain CSS string injected via an HMR-safe
|
|
6
|
+
* `<style data-plugin="dsh-conversation-outline" data-plugin-css="...">` tag.
|
|
7
|
+
* Stable prefixed class names (`dso_*`), DSH theme vars (`--dsw-alias-*`) —
|
|
8
|
+
* never hashed class names of other packages. No layout shift: the panel is a
|
|
9
|
+
* pure overlay.
|
|
10
|
+
*/
|
|
11
|
+
export const panelCss = `
|
|
12
|
+
/* ---- Right-edge rail (always-visible minimap) --------------------------- */
|
|
13
|
+
.dso-rail {
|
|
14
|
+
position: fixed;
|
|
15
|
+
top: 50%;
|
|
16
|
+
right: 0;
|
|
17
|
+
transform: translateY(-50%);
|
|
18
|
+
z-index: 2147483000;
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
align-items: center;
|
|
23
|
+
gap: 3px;
|
|
24
|
+
width: 30px;
|
|
25
|
+
max-height: 55vh;
|
|
26
|
+
padding: 8px 10px;
|
|
27
|
+
overflow: hidden;
|
|
28
|
+
cursor: pointer;
|
|
29
|
+
}
|
|
30
|
+
.dso-rail-more {
|
|
31
|
+
flex: none;
|
|
32
|
+
font-size: 9px;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
line-height: 10px;
|
|
35
|
+
color: var(--dsw-alias-label-tertiary);
|
|
36
|
+
font-variant-numeric: tabular-nums;
|
|
37
|
+
}
|
|
38
|
+
.dso-bar {
|
|
39
|
+
flex: none;
|
|
40
|
+
width: 10px;
|
|
41
|
+
height: 6px;
|
|
42
|
+
padding: 0;
|
|
43
|
+
border: none;
|
|
44
|
+
border-radius: 3px;
|
|
45
|
+
background: color-mix(in srgb, var(--dsw-alias-label-tertiary) 45%, transparent);
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
transition: background 0.12s, transform 0.12s;
|
|
48
|
+
}
|
|
49
|
+
.dso-bar:hover {
|
|
50
|
+
background: var(--dsw-alias-state-business-primary);
|
|
51
|
+
transform: scaleX(1.35);
|
|
52
|
+
}
|
|
53
|
+
.dso-rail:focus-visible,
|
|
54
|
+
.dso-bar:focus-visible {
|
|
55
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
56
|
+
outline-offset: 2px;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* ---- Hover-expanded preview panel --------------------------------------- */
|
|
60
|
+
.dso-panel {
|
|
61
|
+
position: fixed;
|
|
62
|
+
top: 50%;
|
|
63
|
+
right: 16px;
|
|
64
|
+
transform: translateY(-50%);
|
|
65
|
+
z-index: 2147483000;
|
|
66
|
+
box-sizing: border-box;
|
|
67
|
+
display: flex;
|
|
68
|
+
flex-direction: column;
|
|
69
|
+
width: min(340px, calc(100vw - 24px));
|
|
70
|
+
max-height: min(70vh, 600px);
|
|
71
|
+
overflow: hidden;
|
|
72
|
+
border: 1px solid var(--dsw-alias-line-normal);
|
|
73
|
+
border-radius: 16px;
|
|
74
|
+
background: color-mix(in srgb, var(--dsw-alias-bg-module-platform) 92%, transparent);
|
|
75
|
+
backdrop-filter: blur(20px);
|
|
76
|
+
box-shadow: 0 16px 48px color-mix(in srgb, var(--dsw-alias-label-primary) 18%, transparent);
|
|
77
|
+
color: var(--dsw-alias-label-primary);
|
|
78
|
+
animation: dso-panel-in 140ms ease-out;
|
|
79
|
+
}
|
|
80
|
+
@keyframes dso-panel-in {
|
|
81
|
+
from { opacity: 0; transform: translateY(-50%) translateX(12px); }
|
|
82
|
+
to { opacity: 1; transform: translateY(-50%) translateX(0); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.dso-panel-header {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
gap: 8px;
|
|
89
|
+
padding: 12px 14px 10px;
|
|
90
|
+
border-bottom: 1px solid var(--dsw-alias-line-normal);
|
|
91
|
+
}
|
|
92
|
+
.dso-panel-title {
|
|
93
|
+
font-size: 14px;
|
|
94
|
+
font-weight: 600;
|
|
95
|
+
color: var(--dsw-alias-label-primary);
|
|
96
|
+
}
|
|
97
|
+
.dso-panel-count {
|
|
98
|
+
flex: 1;
|
|
99
|
+
font-size: 12px;
|
|
100
|
+
color: var(--dsw-alias-label-tertiary);
|
|
101
|
+
}
|
|
102
|
+
.dso-panel-close {
|
|
103
|
+
display: inline-flex;
|
|
104
|
+
align-items: center;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
width: 24px;
|
|
107
|
+
height: 24px;
|
|
108
|
+
border: none;
|
|
109
|
+
border-radius: 6px;
|
|
110
|
+
background: transparent;
|
|
111
|
+
color: var(--dsw-alias-label-secondary);
|
|
112
|
+
font-size: 16px;
|
|
113
|
+
line-height: 1;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
transition: background 0.12s;
|
|
116
|
+
}
|
|
117
|
+
.dso-panel-close:hover { background: var(--dsw-alias-interactive-bg-hover-solid); }
|
|
118
|
+
|
|
119
|
+
.dso-panel-close:focus-visible,
|
|
120
|
+
.dso-search:focus-visible,
|
|
121
|
+
.dso-row-main:focus-visible,
|
|
122
|
+
.dso-copy:focus-visible,
|
|
123
|
+
.dso-load-older:focus-visible {
|
|
124
|
+
outline: 2px solid var(--dsw-alias-state-business-primary);
|
|
125
|
+
outline-offset: 2px;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.dso-search {
|
|
129
|
+
margin: 10px 12px 4px;
|
|
130
|
+
box-sizing: border-box;
|
|
131
|
+
height: 30px;
|
|
132
|
+
padding: 0 10px;
|
|
133
|
+
border: 1px solid var(--dsw-alias-line-normal);
|
|
134
|
+
border-radius: 8px;
|
|
135
|
+
background: var(--dsw-alias-interactive-bg-hover-solid);
|
|
136
|
+
color: var(--dsw-alias-label-primary);
|
|
137
|
+
font: inherit;
|
|
138
|
+
font-size: 12px;
|
|
139
|
+
outline: none;
|
|
140
|
+
}
|
|
141
|
+
.dso-search::placeholder { color: var(--dsw-alias-label-tertiary); }
|
|
142
|
+
|
|
143
|
+
/* ---- Question list ------------------------------------------------------ */
|
|
144
|
+
.dso-list {
|
|
145
|
+
flex: 1;
|
|
146
|
+
min-height: 0;
|
|
147
|
+
overflow-y: auto;
|
|
148
|
+
display: flex;
|
|
149
|
+
flex-direction: column;
|
|
150
|
+
gap: 2px;
|
|
151
|
+
padding: 6px;
|
|
152
|
+
}
|
|
153
|
+
/* Row container: two sibling buttons (jump + copy) — no nested interactive
|
|
154
|
+
controls. Hover highlights the whole row. */
|
|
155
|
+
.dso-row {
|
|
156
|
+
display: flex;
|
|
157
|
+
align-items: center;
|
|
158
|
+
gap: 4px;
|
|
159
|
+
padding: 2px;
|
|
160
|
+
border-radius: 10px;
|
|
161
|
+
transition: background 0.12s;
|
|
162
|
+
}
|
|
163
|
+
.dso-row:hover { background: var(--dsw-alias-interactive-bg-hover-solid); }
|
|
164
|
+
.dso-row-main {
|
|
165
|
+
flex: 1;
|
|
166
|
+
min-width: 0;
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
gap: 6px;
|
|
170
|
+
height: 28px;
|
|
171
|
+
padding: 2px 6px;
|
|
172
|
+
border: none;
|
|
173
|
+
border-radius: 8px;
|
|
174
|
+
background: transparent;
|
|
175
|
+
color: inherit;
|
|
176
|
+
font: inherit;
|
|
177
|
+
text-align: left;
|
|
178
|
+
cursor: pointer;
|
|
179
|
+
transition: background 0.12s;
|
|
180
|
+
}
|
|
181
|
+
.dso-row-main:hover { background: var(--dsw-alias-interactive-bg-hover-solid); }
|
|
182
|
+
|
|
183
|
+
.dso-turn {
|
|
184
|
+
flex: none;
|
|
185
|
+
min-width: 26px;
|
|
186
|
+
height: 16px;
|
|
187
|
+
display: inline-flex;
|
|
188
|
+
align-items: center;
|
|
189
|
+
justify-content: center;
|
|
190
|
+
border-radius: 8px;
|
|
191
|
+
background: var(--dsw-alias-bg-fill-business);
|
|
192
|
+
color: var(--dsw-alias-label-on-fill);
|
|
193
|
+
font-size: 10px;
|
|
194
|
+
font-weight: 600;
|
|
195
|
+
font-variant-numeric: tabular-nums;
|
|
196
|
+
}
|
|
197
|
+
/* Single-line truncation: shows the opening words of each question —
|
|
198
|
+
anything longer simply ellipsizes (per user request). */
|
|
199
|
+
.dso-text {
|
|
200
|
+
flex: 1;
|
|
201
|
+
min-width: 0;
|
|
202
|
+
font-size: 13px;
|
|
203
|
+
line-height: 18px;
|
|
204
|
+
white-space: nowrap;
|
|
205
|
+
overflow: hidden;
|
|
206
|
+
text-overflow: ellipsis;
|
|
207
|
+
}
|
|
208
|
+
.dso-time {
|
|
209
|
+
flex: none;
|
|
210
|
+
font-size: 11px;
|
|
211
|
+
font-variant-numeric: tabular-nums;
|
|
212
|
+
color: var(--dsw-alias-label-tertiary);
|
|
213
|
+
}
|
|
214
|
+
.dso-steer {
|
|
215
|
+
flex: none;
|
|
216
|
+
padding: 0 6px;
|
|
217
|
+
border: 1px solid var(--dsw-alias-line-normal);
|
|
218
|
+
border-radius: 999px;
|
|
219
|
+
font-size: 10px;
|
|
220
|
+
color: var(--dsw-alias-label-secondary);
|
|
221
|
+
}
|
|
222
|
+
.dso-copy {
|
|
223
|
+
flex: none;
|
|
224
|
+
padding: 2px 6px;
|
|
225
|
+
border: none;
|
|
226
|
+
border-radius: 6px;
|
|
227
|
+
background: transparent;
|
|
228
|
+
color: var(--dsw-alias-label-secondary);
|
|
229
|
+
font: inherit;
|
|
230
|
+
font-size: 11px;
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
opacity: 0;
|
|
233
|
+
transition: opacity 0.12s, color 0.12s, background 0.12s;
|
|
234
|
+
}
|
|
235
|
+
.dso-row:hover .dso-copy,
|
|
236
|
+
.dso-row:focus-within .dso-copy,
|
|
237
|
+
.dso-copy:focus-visible { opacity: 1; }
|
|
238
|
+
.dso-copy:hover { background: var(--dsw-alias-bg-fill-neutral); }
|
|
239
|
+
.dso-copy.dso-copied { color: var(--dsw-alias-state-success); }
|
|
240
|
+
|
|
241
|
+
.dso-empty {
|
|
242
|
+
padding: 28px 16px;
|
|
243
|
+
text-align: center;
|
|
244
|
+
font-size: 13px;
|
|
245
|
+
color: var(--dsw-alias-label-tertiary);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ---- Footer / load older ------------------------------------------------ */
|
|
249
|
+
.dso-footer {
|
|
250
|
+
display: flex;
|
|
251
|
+
justify-content: center;
|
|
252
|
+
padding: 8px 12px 10px;
|
|
253
|
+
border-top: 1px solid var(--dsw-alias-line-normal);
|
|
254
|
+
}
|
|
255
|
+
.dso-load-older {
|
|
256
|
+
padding: 4px 14px;
|
|
257
|
+
border: none;
|
|
258
|
+
border-radius: 14px;
|
|
259
|
+
background: var(--dsw-alias-interactive-bg-hover-solid);
|
|
260
|
+
color: var(--dsw-alias-label-secondary);
|
|
261
|
+
font: inherit;
|
|
262
|
+
font-size: 12px;
|
|
263
|
+
cursor: pointer;
|
|
264
|
+
}
|
|
265
|
+
.dso-load-older:disabled { cursor: default; opacity: 0.6; }
|
|
266
|
+
|
|
267
|
+
/* ---- Jump flash highlight ----------------------------------------------- */
|
|
268
|
+
[data-dsh-outline-flash] {
|
|
269
|
+
animation: dso-flash 1.8s ease-out;
|
|
270
|
+
}
|
|
271
|
+
@keyframes dso-flash {
|
|
272
|
+
0% {
|
|
273
|
+
box-shadow: 0 0 0 0 color-mix(in srgb, var(--dsw-alias-state-business-primary) 50%, transparent);
|
|
274
|
+
background-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent);
|
|
275
|
+
}
|
|
276
|
+
70% {
|
|
277
|
+
box-shadow: 0 0 0 6px transparent;
|
|
278
|
+
background-color: color-mix(in srgb, var(--dsw-alias-state-business-primary) 8%, transparent);
|
|
279
|
+
}
|
|
280
|
+
100% {
|
|
281
|
+
box-shadow: 0 0 0 0 transparent;
|
|
282
|
+
background-color: transparent;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@media (prefers-reduced-motion: reduce) {
|
|
287
|
+
.dso-panel { animation: none; }
|
|
288
|
+
.dso-bar { transition: none; }
|
|
289
|
+
[data-dsh-outline-flash] { animation: none; }
|
|
290
|
+
}
|
|
291
|
+
`;
|
|
292
|
+
/** Inject the style tag, owned by the client fiber (removed on dispose/HMR). */
|
|
293
|
+
export function injectStyle(ctx) {
|
|
294
|
+
ctx.effect(() => {
|
|
295
|
+
const tag = document.createElement('style');
|
|
296
|
+
tag.dataset.plugin = 'dsh-conversation-outline';
|
|
297
|
+
tag.dataset.pluginCss = 'dsh-conversation-outline/panel.css';
|
|
298
|
+
tag.textContent = panelCss;
|
|
299
|
+
document.head.appendChild(tag);
|
|
300
|
+
return () => {
|
|
301
|
+
tag.remove();
|
|
302
|
+
};
|
|
303
|
+
}, 'dsh-conversation-outline: panel css');
|
|
304
|
+
}
|