dsh-prompt-history 1.2.13 → 1.2.14
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/lib/client.js +73 -55
- package/lib/types/client/ChatToc.d.ts +4 -7
- package/lib/types/client/nodes.d.ts +30 -0
- package/package.json +1 -1
- package/src/client/ChatToc.tsx +19 -45
- package/src/client/InputHistory.tsx +49 -37
- package/src/client/nodes.ts +61 -0
package/lib/client.js
CHANGED
|
@@ -242,6 +242,40 @@ window.__ModuleLoader__.load({
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
//#endregion
|
|
245
|
+
//#region src/client/nodes.ts
|
|
246
|
+
/** The node's ordering identity: anchorSeq (new), then seq (old), then data.seq. */
|
|
247
|
+
function nodeSeq(node) {
|
|
248
|
+
for (const key of ["anchorSeq", "seq"]) {
|
|
249
|
+
const value = node[key];
|
|
250
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
251
|
+
}
|
|
252
|
+
const data = node.data;
|
|
253
|
+
return typeof data?.seq === "number" && Number.isFinite(data.seq) ? data.seq : 0;
|
|
254
|
+
}
|
|
255
|
+
/** The node's text content blocks (old `content` or new `data.content`). */
|
|
256
|
+
function textBlocks(node) {
|
|
257
|
+
const content = node.content;
|
|
258
|
+
if (Array.isArray(content)) return content;
|
|
259
|
+
const data = node.data;
|
|
260
|
+
if (data !== void 0 && Array.isArray(data.content)) return data.content;
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
/** Normalize one node into {kind, seq, text}; null for non-text/unsupported. */
|
|
264
|
+
function promptMessage(node) {
|
|
265
|
+
const n = node;
|
|
266
|
+
if (n === null || n === void 0) return null;
|
|
267
|
+
if (n.kind !== "user" && n.kind !== "steering") return null;
|
|
268
|
+
let text = "";
|
|
269
|
+
for (const block of textBlocks(n)) if (block !== null && typeof block === "object" && block.type === "text" && typeof block.text === "string") text += block.text;
|
|
270
|
+
const trimmed = text.trim();
|
|
271
|
+
if (trimmed === "") return null;
|
|
272
|
+
return {
|
|
273
|
+
kind: n.kind,
|
|
274
|
+
seq: nodeSeq(n),
|
|
275
|
+
text: trimmed
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
//#endregion
|
|
245
279
|
//#region src/client/editor.ts
|
|
246
280
|
/**
|
|
247
281
|
* Composer editor abstraction for dsh-prompt-history.
|
|
@@ -378,16 +412,8 @@ window.__ModuleLoader__.load({
|
|
|
378
412
|
* message. Rendered via a portal to document.body so the fixed positioning is
|
|
379
413
|
* viewport-relative, and lives off the conversation snapshot's user nodes.
|
|
380
414
|
*/
|
|
381
|
-
/** Extract the plain text of a user-submitted node; null for others/empty. */
|
|
382
|
-
function entryText(node) {
|
|
383
|
-
if (node.kind !== "user" && node.kind !== "steering") return null;
|
|
384
|
-
let text = "";
|
|
385
|
-
for (const block of node.content) if (block.type === "text") text += block.text;
|
|
386
|
-
const trimmed = text.trim();
|
|
387
|
-
return trimmed === "" ? null : trimmed;
|
|
388
|
-
}
|
|
389
415
|
/** The TOC grip + panel entry. */
|
|
390
|
-
function ChatToc({
|
|
416
|
+
function ChatToc({ texts, onWiden }) {
|
|
391
417
|
const [open, setOpen] = (0, react.useState)(false);
|
|
392
418
|
const [origin, setOrigin] = (0, react.useState)({
|
|
393
419
|
left: 0,
|
|
@@ -398,35 +424,26 @@ window.__ModuleLoader__.load({
|
|
|
398
424
|
(0, react.useEffect)(() => {
|
|
399
425
|
if (open && onWiden !== void 0) onWiden();
|
|
400
426
|
}, [open]);
|
|
401
|
-
const entries = (0, react.useMemo)(() => {
|
|
402
|
-
const out = [];
|
|
403
|
-
for (const node of nodes) {
|
|
404
|
-
const text = entryText(node);
|
|
405
|
-
if (text === null) continue;
|
|
406
|
-
if (out[out.length - 1]?.text !== text) out.push({ text });
|
|
407
|
-
}
|
|
408
|
-
return out;
|
|
409
|
-
}, [nodes]);
|
|
410
427
|
const USER_ROW = "[data-chat-anchor-key][data-chat-flow-kind=\"user\"], [data-chat-anchor-key][data-chat-flow-kind=\"steering\"]";
|
|
411
428
|
const [rowMap, setRowMap] = (0, react.useState)([]);
|
|
412
429
|
(0, react.useLayoutEffect)(() => {
|
|
413
|
-
if (!open ||
|
|
430
|
+
if (!open || texts.length === 0) {
|
|
414
431
|
setRowMap([]);
|
|
415
432
|
return;
|
|
416
433
|
}
|
|
417
434
|
const norm = (t) => (t ?? "").replace(/\s+/g, " ").trim();
|
|
418
435
|
let rows = [...document.querySelectorAll(USER_ROW)];
|
|
419
436
|
if (rows.length === 0) rows = [...document.querySelectorAll("[data-chat-anchor-key]")];
|
|
420
|
-
const
|
|
437
|
+
const rowTexts = rows.map((row) => norm(row.textContent ?? ""));
|
|
421
438
|
const map = [];
|
|
422
439
|
let cursor = 0;
|
|
423
|
-
for (const
|
|
424
|
-
const target = norm(
|
|
425
|
-
while (cursor <
|
|
426
|
-
map.push(cursor <
|
|
440
|
+
for (const text of texts) {
|
|
441
|
+
const target = norm(text);
|
|
442
|
+
while (cursor < rowTexts.length && !(rowTexts[cursor]?.includes(target) ?? false)) cursor++;
|
|
443
|
+
map.push(cursor < rowTexts.length ? cursor : -1);
|
|
427
444
|
}
|
|
428
445
|
setRowMap(map);
|
|
429
|
-
}, [open,
|
|
446
|
+
}, [open, texts]);
|
|
430
447
|
const tipSetterRef = (0, react.useRef)(null);
|
|
431
448
|
const jumpTo = (index) => {
|
|
432
449
|
const mapped = rowMap[index];
|
|
@@ -437,7 +454,7 @@ window.__ModuleLoader__.load({
|
|
|
437
454
|
}
|
|
438
455
|
if (hit === void 0) {
|
|
439
456
|
const norm = (t) => (t ?? "").replace(/\s+/g, " ").trim();
|
|
440
|
-
const target = norm(
|
|
457
|
+
const target = norm(texts[index] ?? "");
|
|
441
458
|
hit = [...document.querySelectorAll("[data-chat-anchor-key]")].find((row) => norm(row.textContent ?? "").includes(target));
|
|
442
459
|
}
|
|
443
460
|
if (hit === void 0) return;
|
|
@@ -583,7 +600,7 @@ window.__ModuleLoader__.load({
|
|
|
583
600
|
const gripTop = pos?.top ?? gripY() - 26;
|
|
584
601
|
const [panelStyle, setPanelStyle] = (0, react.useState)(null);
|
|
585
602
|
(0, react.useLayoutEffect)(() => {
|
|
586
|
-
if (!open ||
|
|
603
|
+
if (!open || texts.length === 0) return;
|
|
587
604
|
const panel = panelRef.current;
|
|
588
605
|
if (panel === null) return;
|
|
589
606
|
const gap = 8;
|
|
@@ -607,7 +624,7 @@ window.__ModuleLoader__.load({
|
|
|
607
624
|
setPanelStyle((prev) => prev !== null && prev.top === next.top && prev.left === next.left && prev.width === next.width && prev.maxHeight === next.maxHeight && prev.height === next.height ? prev : next);
|
|
608
625
|
}, [
|
|
609
626
|
open,
|
|
610
|
-
|
|
627
|
+
texts.length,
|
|
611
628
|
gripLeft,
|
|
612
629
|
gripTop,
|
|
613
630
|
size
|
|
@@ -654,7 +671,7 @@ window.__ModuleLoader__.load({
|
|
|
654
671
|
},
|
|
655
672
|
children: "☰"
|
|
656
673
|
}),
|
|
657
|
-
open &&
|
|
674
|
+
open && texts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
658
675
|
ref: panelRef,
|
|
659
676
|
className: "dsh-ph-toc",
|
|
660
677
|
style: panelStyle ?? {
|
|
@@ -671,14 +688,14 @@ window.__ModuleLoader__.load({
|
|
|
671
688
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
672
689
|
className: "dsh-ph-toc-list",
|
|
673
690
|
onScroll: hideTip,
|
|
674
|
-
children:
|
|
691
|
+
children: texts.map((text, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
675
692
|
type: "button",
|
|
676
693
|
className: "dsh-ph-toc-item",
|
|
677
694
|
onClick: () => {
|
|
678
695
|
jumpTo(i);
|
|
679
696
|
},
|
|
680
697
|
onMouseEnter: (e) => {
|
|
681
|
-
enterRow(e,
|
|
698
|
+
enterRow(e, text);
|
|
682
699
|
},
|
|
683
700
|
onMouseLeave: hideTip,
|
|
684
701
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
@@ -686,9 +703,9 @@ window.__ModuleLoader__.load({
|
|
|
686
703
|
children: i + 1
|
|
687
704
|
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
688
705
|
className: "dsh-ph-toc-label",
|
|
689
|
-
children:
|
|
706
|
+
children: text
|
|
690
707
|
})]
|
|
691
|
-
}, `${i}:${
|
|
708
|
+
}, `${i}:${text}`))
|
|
692
709
|
}),
|
|
693
710
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
694
711
|
className: "dsh-ph-toc-resize",
|
|
@@ -752,13 +769,15 @@ window.__ModuleLoader__.load({
|
|
|
752
769
|
};
|
|
753
770
|
/** The suggestion menu (slash/at) renders a listbox inside the card while open. */
|
|
754
771
|
const OPEN_MENU = "[role=\"listbox\"]";
|
|
755
|
-
/**
|
|
756
|
-
function
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
const
|
|
761
|
-
|
|
772
|
+
/** Pull the ordered node array out of whichever snapshot shape is present. */
|
|
773
|
+
function rawNodesOf(snapshot) {
|
|
774
|
+
const legacy = snapshot.legacy?.nodes;
|
|
775
|
+
if (Array.isArray(legacy)) return legacy;
|
|
776
|
+
const order = snapshot.order;
|
|
777
|
+
const store = snapshot.nodes;
|
|
778
|
+
if (Array.isArray(order) && store !== void 0 && typeof store.get === "function") return order.map((key) => store.get?.(key)).filter((n) => n !== null && n !== void 0);
|
|
779
|
+
if (Array.isArray(snapshot.nodes)) return snapshot.nodes;
|
|
780
|
+
return [];
|
|
762
781
|
}
|
|
763
782
|
/**
|
|
764
783
|
* The composer history + right-click-paste entry.
|
|
@@ -772,12 +791,13 @@ window.__ModuleLoader__.load({
|
|
|
772
791
|
const sessionHook = useSession;
|
|
773
792
|
const draft = useInput((s) => s.draft);
|
|
774
793
|
const phase = useInput((s) => s.phase);
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
794
|
+
const rawNodes = nodesHook?.((s) => rawNodesOf(s)) ?? [];
|
|
795
|
+
const messages = (0, react.useMemo)(() => rawNodes.map(promptMessage).filter((m) => m !== null), [rawNodes]);
|
|
796
|
+
const tocTexts = (0, react.useMemo)(() => {
|
|
797
|
+
const out = [];
|
|
798
|
+
for (const message of messages) if (message.text !== null && out[out.length - 1] !== message.text) out.push(message.text);
|
|
799
|
+
return out;
|
|
800
|
+
}, [messages]);
|
|
781
801
|
const removed = sessionHook?.((s) => s.removed) ?? false;
|
|
782
802
|
const hasMore = sessionHook?.((s) => s.hasMore) ?? false;
|
|
783
803
|
const loadingOlder = sessionHook?.((s) => s.loadingOlder) ?? false;
|
|
@@ -864,16 +884,14 @@ window.__ModuleLoader__.load({
|
|
|
864
884
|
const seen = seenRef.current;
|
|
865
885
|
const history = historyRef.current;
|
|
866
886
|
const globalOn = getPrefs().globalHistory;
|
|
867
|
-
for (const
|
|
868
|
-
if (
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
if (text === null) continue;
|
|
873
|
-
if (globalOn ? !history.includes(text) : history[history.length - 1] !== text) history.push(text);
|
|
887
|
+
for (const message of messages) {
|
|
888
|
+
if (seen.has(message.seq)) continue;
|
|
889
|
+
seen.add(message.seq);
|
|
890
|
+
if (message.text === null) continue;
|
|
891
|
+
if (globalOn ? !history.includes(message.text) : history[history.length - 1] !== message.text) history.push(message.text);
|
|
874
892
|
}
|
|
875
893
|
if (globalOn) saveRing(historyRef.current);
|
|
876
|
-
}, [
|
|
894
|
+
}, [messages]);
|
|
877
895
|
(0, react.useEffect)(() => {
|
|
878
896
|
const browse = browseRef.current;
|
|
879
897
|
if (browse.index !== -1 && draft !== browse.lastSet) browseRef.current = RESET_BROWSE;
|
|
@@ -1273,7 +1291,7 @@ window.__ModuleLoader__.load({
|
|
|
1273
1291
|
};
|
|
1274
1292
|
}, []);
|
|
1275
1293
|
return (0, react.useSyncExternalStore)(subscribePrefs, getPrefs).tocVisible ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ChatToc, {
|
|
1276
|
-
|
|
1294
|
+
texts: tocTexts,
|
|
1277
1295
|
onWiden: requestWiden
|
|
1278
1296
|
}) : null;
|
|
1279
1297
|
}
|
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
import type { ConversationNode } from '@deepseek-ai/dsh-client-runtime/client';
|
|
2
1
|
/** One directory entry: the prompt text and a row index for scroll targeting. */
|
|
3
|
-
|
|
4
|
-
readonly text: string;
|
|
5
|
-
}
|
|
6
|
-
/** Full props: the conversation nodes to build the directory from. */
|
|
2
|
+
/** Full props: the directory texts, in conversation order (already collapsed). */
|
|
7
3
|
export interface ChatTocProps {
|
|
8
|
-
|
|
4
|
+
/** Every user/steering prompt text, oldest → newest (parent dedups). */
|
|
5
|
+
readonly texts: readonly string[];
|
|
9
6
|
/**
|
|
10
7
|
* Called when the directory opens: the parent widens the loaded history
|
|
11
8
|
* window (loadOlder pages) so the directory can list earlier messages too.
|
|
@@ -13,5 +10,5 @@ export interface ChatTocProps {
|
|
|
13
10
|
readonly onWiden?: () => void;
|
|
14
11
|
}
|
|
15
12
|
/** The TOC grip + panel entry. */
|
|
16
|
-
export declare function ChatToc({
|
|
13
|
+
export declare function ChatToc({ texts, onWiden }: ChatTocProps): JSX.Element;
|
|
17
14
|
//# sourceMappingURL=ChatToc.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerant conversation-node reading across DSH generations.
|
|
3
|
+
*
|
|
4
|
+
* ≤ 0.1.1 (web): session nodes are `{ kind, seq, content: ContentBlock[] }`.
|
|
5
|
+
* 0.1.2 rc (web): the same old-shaped array is exposed as the chat snapshot's
|
|
6
|
+
* `legacy.nodes` (and `nodes` holds a ChatNodeStore). 2.0.x (desktop) nodes
|
|
7
|
+
* are `chatNode`-shaped `{ key, kind, ..., anchorSeq, data }` with the text
|
|
8
|
+
* content under `data.content`. Everything below normalizes one node to the
|
|
9
|
+
* three facts the plugin needs: kind, seq, plain text.
|
|
10
|
+
*/
|
|
11
|
+
export interface PromptMessage {
|
|
12
|
+
readonly kind: string;
|
|
13
|
+
/** Stable order identity for append-once dedup. */
|
|
14
|
+
readonly seq: number;
|
|
15
|
+
/** Trimmed plain text of the message, or null when empty/unsupported. */
|
|
16
|
+
readonly text: string | null;
|
|
17
|
+
}
|
|
18
|
+
type NodeLike = {
|
|
19
|
+
kind?: unknown;
|
|
20
|
+
seq?: unknown;
|
|
21
|
+
anchorSeq?: unknown;
|
|
22
|
+
content?: unknown;
|
|
23
|
+
data?: unknown;
|
|
24
|
+
};
|
|
25
|
+
/** The node's ordering identity: anchorSeq (new), then seq (old), then data.seq. */
|
|
26
|
+
export declare function nodeSeq(node: NodeLike): number;
|
|
27
|
+
/** Normalize one node into {kind, seq, text}; null for non-text/unsupported. */
|
|
28
|
+
export declare function promptMessage(node: unknown): PromptMessage | null;
|
|
29
|
+
export {};
|
|
30
|
+
//# sourceMappingURL=nodes.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-prompt-history",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.14",
|
|
4
4
|
"description": "DSH web plugin: terminal-style input for the composer — bash-like Up/Down prompt history with prefix search and Ctrl+R, selection toolbar with copy and quote (Codex-style > blockquote), right-click paste, toggleable draggable chat TOC, fully internationalized (zh/en), with copy modes in Settings (history sourced from the session's own message log; zero config required).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
package/src/client/ChatToc.tsx
CHANGED
|
@@ -8,28 +8,13 @@
|
|
|
8
8
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
9
9
|
import type { MouseEvent as ReactMouseEvent } from 'react'
|
|
10
10
|
import { createPortal } from 'react-dom'
|
|
11
|
-
import type { ConversationNode } from '@deepseek-ai/dsh-client-runtime/client'
|
|
12
11
|
import { T } from './i18n.ts'
|
|
13
12
|
|
|
14
13
|
/** One directory entry: the prompt text and a row index for scroll targeting. */
|
|
15
|
-
|
|
16
|
-
readonly text: string
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Extract the plain text of a user-submitted node; null for others/empty. */
|
|
20
|
-
function entryText(node: ConversationNode): string | null {
|
|
21
|
-
if (node.kind !== 'user' && node.kind !== 'steering') return null
|
|
22
|
-
let text = ''
|
|
23
|
-
for (const block of node.content) {
|
|
24
|
-
if (block.type === 'text') text += block.text
|
|
25
|
-
}
|
|
26
|
-
const trimmed = text.trim()
|
|
27
|
-
return trimmed === '' ? null : trimmed
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Full props: the conversation nodes to build the directory from. */
|
|
14
|
+
/** Full props: the directory texts, in conversation order (already collapsed). */
|
|
31
15
|
export interface ChatTocProps {
|
|
32
|
-
|
|
16
|
+
/** Every user/steering prompt text, oldest → newest (parent dedups). */
|
|
17
|
+
readonly texts: readonly string[]
|
|
33
18
|
/**
|
|
34
19
|
* Called when the directory opens: the parent widens the loaded history
|
|
35
20
|
* window (loadOlder pages) so the directory can list earlier messages too.
|
|
@@ -38,7 +23,7 @@ export interface ChatTocProps {
|
|
|
38
23
|
}
|
|
39
24
|
|
|
40
25
|
/** The TOC grip + panel entry. */
|
|
41
|
-
export function ChatToc({
|
|
26
|
+
export function ChatToc({ texts, onWiden }: ChatTocProps): JSX.Element {
|
|
42
27
|
const [open, setOpen] = useState(false)
|
|
43
28
|
const [origin, setOrigin] = useState({ left: 0, top: 0, bottom: 0 })
|
|
44
29
|
const panelRef = useRef<HTMLDivElement | null>(null)
|
|
@@ -49,17 +34,6 @@ export function ChatToc({ nodes, onWiden }: ChatTocProps): JSX.Element {
|
|
|
49
34
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
50
35
|
}, [open])
|
|
51
36
|
|
|
52
|
-
// Directory entries: user/steering prompts, consecutive duplicates collapsed.
|
|
53
|
-
const entries = useMemo<TocEntry[]>(() => {
|
|
54
|
-
const out: TocEntry[] = []
|
|
55
|
-
for (const node of nodes) {
|
|
56
|
-
const text = entryText(node)
|
|
57
|
-
if (text === null) continue
|
|
58
|
-
if (out[out.length - 1]?.text !== text) out.push({ text })
|
|
59
|
-
}
|
|
60
|
-
return out
|
|
61
|
-
}, [nodes])
|
|
62
|
-
|
|
63
37
|
// Map every directory entry to its conversation row (user/steering rows only,
|
|
64
38
|
// matched in DOM order with whitespace-normalized text). Entries and rows
|
|
65
39
|
// share the same loaded event window, so the ordinal walk stays exact — and
|
|
@@ -67,23 +41,23 @@ export function ChatToc({ nodes, onWiden }: ChatTocProps): JSX.Element {
|
|
|
67
41
|
const USER_ROW = '[data-chat-anchor-key][data-chat-flow-kind="user"], [data-chat-anchor-key][data-chat-flow-kind="steering"]'
|
|
68
42
|
const [rowMap, setRowMap] = useState<readonly number[]>([])
|
|
69
43
|
useLayoutEffect(() => {
|
|
70
|
-
if (!open ||
|
|
44
|
+
if (!open || texts.length === 0) {
|
|
71
45
|
setRowMap([])
|
|
72
46
|
return
|
|
73
47
|
}
|
|
74
48
|
const norm = (t: string): string => (t ?? '').replace(/\s+/g, ' ').trim()
|
|
75
49
|
let rows = [...document.querySelectorAll<HTMLElement>(USER_ROW)]
|
|
76
50
|
if (rows.length === 0) rows = [...document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
|
77
|
-
const
|
|
51
|
+
const rowTexts = rows.map((row) => norm(row.textContent ?? ''))
|
|
78
52
|
const map: number[] = []
|
|
79
53
|
let cursor = 0
|
|
80
|
-
for (const
|
|
81
|
-
const target = norm(
|
|
82
|
-
while (cursor <
|
|
83
|
-
map.push(cursor <
|
|
54
|
+
for (const text of texts) {
|
|
55
|
+
const target = norm(text)
|
|
56
|
+
while (cursor < rowTexts.length && !(rowTexts[cursor]?.includes(target) ?? false)) cursor++
|
|
57
|
+
map.push(cursor < rowTexts.length ? cursor : -1)
|
|
84
58
|
}
|
|
85
59
|
setRowMap(map)
|
|
86
|
-
}, [open,
|
|
60
|
+
}, [open, texts])
|
|
87
61
|
|
|
88
62
|
// Scroll the conversation to the message behind entry `index`.
|
|
89
63
|
// jumpTo is defined before the tip state, so read/clear it through a ref
|
|
@@ -101,7 +75,7 @@ export function ChatToc({ nodes, onWiden }: ChatTocProps): JSX.Element {
|
|
|
101
75
|
if (hit === undefined) {
|
|
102
76
|
// Fallback: first row whose (normalized) text contains this entry.
|
|
103
77
|
const norm = (t: string): string => (t ?? '').replace(/\s+/g, ' ').trim()
|
|
104
|
-
const target = norm(
|
|
78
|
+
const target = norm(texts[index] ?? '')
|
|
105
79
|
hit = [...document.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
|
|
106
80
|
.find((row) => norm(row.textContent ?? '').includes(target))
|
|
107
81
|
}
|
|
@@ -249,7 +223,7 @@ export function ChatToc({ nodes, onWiden }: ChatTocProps): JSX.Element {
|
|
|
249
223
|
// its content up to the cap.
|
|
250
224
|
const [panelStyle, setPanelStyle] = useState<{ top: number; left: number; width: number; maxHeight?: number; height?: number } | null>(null)
|
|
251
225
|
useLayoutEffect(() => {
|
|
252
|
-
if (!open ||
|
|
226
|
+
if (!open || texts.length === 0) return
|
|
253
227
|
const panel = panelRef.current
|
|
254
228
|
if (panel === null) return
|
|
255
229
|
const gap = 8
|
|
@@ -271,7 +245,7 @@ export function ChatToc({ nodes, onWiden }: ChatTocProps): JSX.Element {
|
|
|
271
245
|
? prev
|
|
272
246
|
: next
|
|
273
247
|
))
|
|
274
|
-
}, [open,
|
|
248
|
+
}, [open, texts.length, gripLeft, gripTop, size])
|
|
275
249
|
|
|
276
250
|
// Hover tooltip with the FULL entry text: rows are clamped previews, so the
|
|
277
251
|
// Hover tooltip with the FULL entry text. It appears only when the row's
|
|
@@ -314,7 +288,7 @@ export function ChatToc({ nodes, onWiden }: ChatTocProps): JSX.Element {
|
|
|
314
288
|
>
|
|
315
289
|
☰
|
|
316
290
|
</button>
|
|
317
|
-
{open &&
|
|
291
|
+
{open && texts.length > 0 && (
|
|
318
292
|
<div
|
|
319
293
|
ref={panelRef}
|
|
320
294
|
className="dsh-ph-toc"
|
|
@@ -323,17 +297,17 @@ export function ChatToc({ nodes, onWiden }: ChatTocProps): JSX.Element {
|
|
|
323
297
|
>
|
|
324
298
|
<div className="dsh-ph-toc-title">{T('toc.title')}</div>
|
|
325
299
|
<div className="dsh-ph-toc-list" onScroll={hideTip}>
|
|
326
|
-
{
|
|
300
|
+
{texts.map((text, i) => (
|
|
327
301
|
<button
|
|
328
302
|
type="button"
|
|
329
|
-
key={`${i}:${
|
|
303
|
+
key={`${i}:${text}`}
|
|
330
304
|
className="dsh-ph-toc-item"
|
|
331
305
|
onClick={() => { jumpTo(i) }}
|
|
332
|
-
onMouseEnter={(e) => { enterRow(e,
|
|
306
|
+
onMouseEnter={(e) => { enterRow(e, text) }}
|
|
333
307
|
onMouseLeave={hideTip}
|
|
334
308
|
>
|
|
335
309
|
<span className="dsh-ph-toc-idx">{i + 1}</span>
|
|
336
|
-
<span className="dsh-ph-toc-label">{
|
|
310
|
+
<span className="dsh-ph-toc-label">{text}</span>
|
|
337
311
|
</button>
|
|
338
312
|
))}
|
|
339
313
|
</div>
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
* (Shift+Up still extends selection), no IME composition, machine not
|
|
34
34
|
* adjudicating/submitting, session not removed.
|
|
35
35
|
*/
|
|
36
|
-
import { useCallback, useEffect, useRef, useSyncExternalStore, useState } from 'react'
|
|
36
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore, useState } from 'react'
|
|
37
37
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
38
38
|
// Type-only: the ui-conversation SlotMap merge (the input.right entry) and the
|
|
39
39
|
// session standard kit members (useInput/inputActions).
|
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
} from './feedback.ts'
|
|
48
48
|
import { T } from './i18n.ts'
|
|
49
49
|
import { pullOlderPage } from './sessionCtx.ts'
|
|
50
|
+
import { promptMessage, type PromptMessage } from './nodes.ts'
|
|
50
51
|
import {
|
|
51
52
|
COMPOSER_CARD, editorFocused, editorHost, editorSelectedText, editorSelectionOffsets,
|
|
52
53
|
focusEditor, isEditorTarget, setEditorCaret,
|
|
@@ -85,28 +86,34 @@ const RESET_BROWSE: BrowseState = { index: -1, saved: '', lastSet: null }
|
|
|
85
86
|
const OPEN_MENU = '[role="listbox"]'
|
|
86
87
|
|
|
87
88
|
/** DSH ≥ 0.1.2 moved the conversation nodes off the session snapshot onto the
|
|
88
|
-
* chat view
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
interface
|
|
92
|
-
nodes
|
|
93
|
-
|
|
89
|
+
* chat view (useChat); the lifecycle fields (removed/hasMore/loadingOlder/
|
|
90
|
+
* openState) stay on useSession. Node shapes differ per generation, so reads
|
|
91
|
+
* go through rawNodesOf + promptMessage (nodes.ts). */
|
|
92
|
+
interface ChatLike {
|
|
93
|
+
/** 0.1.1 session snapshot nodes, or a direct array on newer shapes. */
|
|
94
|
+
nodes?: unknown
|
|
95
|
+
/** 0.1.2 web: the old-shaped node array compatibility slice. */
|
|
96
|
+
legacy?: { nodes?: unknown }
|
|
97
|
+
/** 2.0.x desktop: ordered node keys + a store whose .get(key) returns a node. */
|
|
98
|
+
order?: readonly string[]
|
|
94
99
|
removed?: boolean
|
|
95
100
|
hasMore?: boolean
|
|
96
101
|
loadingOlder?: boolean
|
|
97
102
|
openState?: string
|
|
98
103
|
}
|
|
99
|
-
type SelectorHook = (select: (snapshot:
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
function
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
104
|
+
type SelectorHook = (select: (snapshot: ChatLike) => unknown) => unknown
|
|
105
|
+
|
|
106
|
+
/** Pull the ordered node array out of whichever snapshot shape is present. */
|
|
107
|
+
function rawNodesOf(snapshot: ChatLike): readonly unknown[] {
|
|
108
|
+
const legacy = snapshot.legacy?.nodes
|
|
109
|
+
if (Array.isArray(legacy)) return legacy
|
|
110
|
+
const order = snapshot.order
|
|
111
|
+
const store = snapshot.nodes as { get?: (key: string) => unknown } | undefined
|
|
112
|
+
if (Array.isArray(order) && store !== undefined && typeof store.get === 'function') {
|
|
113
|
+
return order.map((key) => store.get?.(key)).filter((n): n is unknown => n !== null && n !== undefined)
|
|
107
114
|
}
|
|
108
|
-
|
|
109
|
-
return
|
|
115
|
+
if (Array.isArray(snapshot.nodes)) return snapshot.nodes
|
|
116
|
+
return []
|
|
110
117
|
}
|
|
111
118
|
|
|
112
119
|
/**
|
|
@@ -124,16 +131,23 @@ export function InputHistory(props: InputHistoryProps) {
|
|
|
124
131
|
// Latest machine/session facts at event time (the listeners mount once).
|
|
125
132
|
const draft = useInput(s => s.draft)
|
|
126
133
|
const phase = useInput(s => s.phase)
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// an
|
|
130
|
-
//
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
// Node shapes differ by DSH generation (old {kind,seq,content} vs 2.0.x
|
|
135
|
+
// chatNode with data.content/anchorSeq, and rc.1's nodes being a store, not
|
|
136
|
+
// an array). rawNodesOf + promptMessage normalize them to {kind, seq, text},
|
|
137
|
+
// so nothing non-iterable or mis-shaped reaches the history or the directory.
|
|
138
|
+
const rawNodes = (nodesHook?.((s: ChatLike) => rawNodesOf(s)) as readonly unknown[] | undefined) ?? []
|
|
139
|
+
const messages = useMemo(
|
|
140
|
+
() => rawNodes.map(promptMessage).filter((m): m is PromptMessage => m !== null),
|
|
141
|
+
[rawNodes],
|
|
142
|
+
)
|
|
143
|
+
// Directory texts: consecutive duplicates collapsed, in conversation order.
|
|
144
|
+
const tocTexts = useMemo(() => {
|
|
145
|
+
const out: string[] = []
|
|
146
|
+
for (const message of messages) {
|
|
147
|
+
if (message.text !== null && out[out.length - 1] !== message.text) out.push(message.text)
|
|
148
|
+
}
|
|
149
|
+
return out
|
|
150
|
+
}, [messages])
|
|
137
151
|
const removed = sessionHook?.((s) => s.removed) as boolean | undefined ?? false
|
|
138
152
|
|
|
139
153
|
// Full-history TOC: when the directory opens it asks us to widen the loaded
|
|
@@ -212,18 +226,16 @@ export function InputHistory(props: InputHistoryProps) {
|
|
|
212
226
|
const seen = seenRef.current
|
|
213
227
|
const history = historyRef.current
|
|
214
228
|
const globalOn = getPrefs().globalHistory
|
|
215
|
-
for (const
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (globalOn ? !history.includes(text) : history[history.length - 1] !== text) {
|
|
222
|
-
history.push(text)
|
|
229
|
+
for (const message of messages) {
|
|
230
|
+
if (seen.has(message.seq)) continue
|
|
231
|
+
seen.add(message.seq)
|
|
232
|
+
if (message.text === null) continue
|
|
233
|
+
if (globalOn ? !history.includes(message.text) : history[history.length - 1] !== message.text) {
|
|
234
|
+
history.push(message.text)
|
|
223
235
|
}
|
|
224
236
|
}
|
|
225
237
|
if (globalOn) saveRing(historyRef.current)
|
|
226
|
-
}, [
|
|
238
|
+
}, [messages])
|
|
227
239
|
|
|
228
240
|
// Any draft change that is not our own history write ends the browse
|
|
229
241
|
// session (bash drops the recalled line when you edit it).
|
|
@@ -644,6 +656,6 @@ export function InputHistory(props: InputHistoryProps) {
|
|
|
644
656
|
}, [])
|
|
645
657
|
|
|
646
658
|
return useSyncExternalStore(subscribePrefs, getPrefs).tocVisible
|
|
647
|
-
? <ChatToc
|
|
659
|
+
? <ChatToc texts={tocTexts} onWiden={requestWiden} />
|
|
648
660
|
: null
|
|
649
661
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tolerant conversation-node reading across DSH generations.
|
|
3
|
+
*
|
|
4
|
+
* ≤ 0.1.1 (web): session nodes are `{ kind, seq, content: ContentBlock[] }`.
|
|
5
|
+
* 0.1.2 rc (web): the same old-shaped array is exposed as the chat snapshot's
|
|
6
|
+
* `legacy.nodes` (and `nodes` holds a ChatNodeStore). 2.0.x (desktop) nodes
|
|
7
|
+
* are `chatNode`-shaped `{ key, kind, ..., anchorSeq, data }` with the text
|
|
8
|
+
* content under `data.content`. Everything below normalizes one node to the
|
|
9
|
+
* three facts the plugin needs: kind, seq, plain text.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface PromptMessage {
|
|
13
|
+
readonly kind: string
|
|
14
|
+
/** Stable order identity for append-once dedup. */
|
|
15
|
+
readonly seq: number
|
|
16
|
+
/** Trimmed plain text of the message, or null when empty/unsupported. */
|
|
17
|
+
readonly text: string | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type NodeLike = {
|
|
21
|
+
kind?: unknown
|
|
22
|
+
seq?: unknown
|
|
23
|
+
anchorSeq?: unknown
|
|
24
|
+
content?: unknown
|
|
25
|
+
data?: unknown
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The node's ordering identity: anchorSeq (new), then seq (old), then data.seq. */
|
|
29
|
+
export function nodeSeq(node: NodeLike): number {
|
|
30
|
+
for (const key of ['anchorSeq', 'seq'] as const) {
|
|
31
|
+
const value = node[key]
|
|
32
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
33
|
+
}
|
|
34
|
+
const data = node.data as { seq?: unknown } | undefined
|
|
35
|
+
return typeof data?.seq === 'number' && Number.isFinite(data.seq) ? data.seq : 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The node's text content blocks (old `content` or new `data.content`). */
|
|
39
|
+
function textBlocks(node: NodeLike): unknown {
|
|
40
|
+
const content = node.content
|
|
41
|
+
if (Array.isArray(content)) return content
|
|
42
|
+
const data = node.data as { content?: unknown } | undefined
|
|
43
|
+
if (data !== undefined && Array.isArray(data.content)) return data.content
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Normalize one node into {kind, seq, text}; null for non-text/unsupported. */
|
|
48
|
+
export function promptMessage(node: unknown): PromptMessage | null {
|
|
49
|
+
const n = node as NodeLike | null | undefined
|
|
50
|
+
if (n === null || n === undefined) return null
|
|
51
|
+
if (n.kind !== 'user' && n.kind !== 'steering') return null
|
|
52
|
+
let text = ''
|
|
53
|
+
for (const block of textBlocks(n) as ReadonlyArray<{ type?: unknown; text?: unknown }>) {
|
|
54
|
+
if (block !== null && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string') {
|
|
55
|
+
text += block.text
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const trimmed = text.trim()
|
|
59
|
+
if (trimmed === '') return null
|
|
60
|
+
return { kind: n.kind as string, seq: nodeSeq(n), text: trimmed }
|
|
61
|
+
}
|