dsh-prompt-history 1.2.13 → 1.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  | Surface | Status |
24
24
  |---|---|
25
+ | DSH versions | Verified on **0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-rc.1** (web profile); node reading is normalized across generations (old `session.nodes` / 0.1.2 `legacy.nodes` / desktop 2.0.x `order`+store with `data.content`), so desktop 2.0.x is covered too |
25
26
  | Platform | Web GUI only (client plugin; browser-local state; no network, no native code) |
26
27
  | Node | `>=20` |
27
28
  | Model | Any (no model requests — pure UI behavior) |
package/README.md CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  | 表面 | 状态 |
24
24
  |---|---|
25
+ | DSH 版本 | 实测通过 **0.1.1-rc.2 / 0.1.2-rc.1 / 0.1.5-rc.1**(web profile);节点读取做了跨版本归一化(旧 `session.nodes` / 0.1.2 `legacy.nodes` / 2.0.x 桌面 `order`+store、`data.content`),桌面 2.0.x 一并兼容 |
25
26
  | 平台 | 仅 Web GUI(客户端插件;状态存于浏览器本地;无网络请求、无原生代码) |
26
27
  | Node | `>=20` |
27
28
  | 模型 | 任意(不发起模型请求 —— 纯 UI 行为) |
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({ nodes, onWiden }) {
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 || entries.length === 0) {
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 texts = rows.map((row) => norm(row.textContent ?? ""));
437
+ const rowTexts = rows.map((row) => norm(row.textContent ?? ""));
421
438
  const map = [];
422
439
  let cursor = 0;
423
- for (const entry of entries) {
424
- const target = norm(entry.text);
425
- while (cursor < texts.length && !(texts[cursor]?.includes(target) ?? false)) cursor++;
426
- map.push(cursor < texts.length ? cursor : -1);
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, entries]);
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(entries[index]?.text ?? "");
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 || entries.length === 0) return;
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
- entries.length,
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 && entries.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
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: entries.map((entry, i) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
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, entry.text);
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: entry.text
706
+ children: text
690
707
  })]
691
- }, `${i}:${entry.text}`))
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
- /** Extract the trimmed plain text of one user-submitted message node; null when empty. */
756
- function promptText(node) {
757
- if (node.kind !== "user" && node.kind !== "steering") return null;
758
- let text = "";
759
- for (const block of node.content) if (block.type === "text") text += block.text;
760
- const trimmed = text.trim();
761
- return trimmed === "" ? null : trimmed;
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 nodes = nodesHook?.((s) => {
776
- const legacy = s.legacy?.nodes;
777
- if (Array.isArray(legacy)) return legacy;
778
- const direct = s.nodes;
779
- return Array.isArray(direct) ? direct : [];
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 node of nodes) {
868
- if (node.kind !== "user" && node.kind !== "steering") continue;
869
- if (seen.has(node.seq)) continue;
870
- seen.add(node.seq);
871
- const text = promptText(node);
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
- }, [nodes]);
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
- nodes,
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
- export interface TocEntry {
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
- readonly nodes: readonly ConversationNode[];
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({ nodes, onWiden }: ChatTocProps): JSX.Element;
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.13",
3
+ "version": "1.2.15",
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",
@@ -70,23 +70,23 @@
70
70
  "license": "MIT",
71
71
  "peerDependencies": {
72
72
  "@deepseek-ai/cordis": "^4.0.1",
73
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
74
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
75
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6",
76
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
77
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
78
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
73
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.6 <0.2.0",
74
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.6 <0.2.0",
75
+ "@deepseek-ai/dsh-client-ui-conversation": ">=0.1.0-rc.6 <0.2.0",
76
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.6 <0.2.0",
77
+ "@deepseek-ai/dsh-client-ui-slots": ">=0.1.0-rc.6 <0.2.0",
78
+ "@deepseek-ai/dsh-invariants": ">=0.1.0-rc.6 <0.2.0",
79
79
  "react": "^18.2.0",
80
80
  "react-dom": "^18.2.0"
81
81
  },
82
82
  "devDependencies": {
83
83
  "@deepseek-ai/cordis": "^4.0.1",
84
- "@deepseek-ai/dsh-client-locale": "0.1.0-rc.7",
85
- "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.7",
86
- "@deepseek-ai/dsh-client-ui-conversation": "0.1.0-rc.7",
87
- "@deepseek-ai/dsh-client-ui-settings": "0.1.0-rc.7",
88
- "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.7",
89
- "@deepseek-ai/dsh-invariants": "0.1.0-rc.7",
84
+ "@deepseek-ai/dsh-client-locale": "0.1.1-rc.2",
85
+ "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
86
+ "@deepseek-ai/dsh-client-ui-conversation": "0.1.1-rc.2",
87
+ "@deepseek-ai/dsh-client-ui-settings": "0.1.1-rc.2",
88
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.1-rc.2",
89
+ "@deepseek-ai/dsh-invariants": "0.1.1-rc.2",
90
90
  "@types/react": "~18.3.1",
91
91
  "@types/react-dom": "^19.2.5",
92
92
  "react": "^18.2.0",
@@ -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
- export interface TocEntry {
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
- readonly nodes: readonly ConversationNode[]
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({ nodes, onWiden }: ChatTocProps): JSX.Element {
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 || entries.length === 0) {
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 texts = rows.map((row) => norm(row.textContent ?? ''))
51
+ const rowTexts = rows.map((row) => norm(row.textContent ?? ''))
78
52
  const map: number[] = []
79
53
  let cursor = 0
80
- for (const entry of entries) {
81
- const target = norm(entry.text)
82
- while (cursor < texts.length && !(texts[cursor]?.includes(target) ?? false)) cursor++
83
- map.push(cursor < texts.length ? cursor : -1)
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, entries])
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(entries[index]?.text ?? '')
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 || entries.length === 0) return
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, entries.length, gripLeft, gripTop, size])
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 && entries.length > 0 && (
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
- {entries.map((entry, i) => (
300
+ {texts.map((text, i) => (
327
301
  <button
328
302
  type="button"
329
- key={`${i}:${entry.text}`}
303
+ key={`${i}:${text}`}
330
304
  className="dsh-ph-toc-item"
331
305
  onClick={() => { jumpTo(i) }}
332
- onMouseEnter={(e) => { enterRow(e, entry.text) }}
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">{entry.text}</span>
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's legacy slice (`useChat(s => s.legacy.nodes)`); the lifecycle
89
- * fields (removed/hasMore/loadingOlder/openState) stay on useSession. Reads
90
- * either source so the plugin works on both generations of the harness. */
91
- interface SnapshotLike {
92
- nodes?: readonly ConversationNode[]
93
- legacy?: { nodes?: readonly ConversationNode[] }
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: SnapshotLike) => unknown) => unknown
100
-
101
- /** Extract the trimmed plain text of one user-submitted message node; null when empty. */
102
- function promptText(node: ConversationNode): string | null {
103
- if (node.kind !== 'user' && node.kind !== 'steering') return null
104
- let text = ''
105
- for (const block of node.content) {
106
- if (block.type === 'text') text += block.text
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
- const trimmed = text.trim()
109
- return trimmed === '' ? null : trimmed
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
- // On rc.1 the chat snapshot's `nodes` is a ChatNodeStore (an object), NOT an
128
- // array — the conversation-node array lives on `legacy.nodes`. Only accept
129
- // an actual array (legacy slice preferred) so nothing non-iterable reaches
130
- // ChatToc or the history fold.
131
- const nodes = (nodesHook?.((s: SnapshotLike) => {
132
- const legacy = s.legacy?.nodes
133
- if (Array.isArray(legacy)) return legacy
134
- const direct = s.nodes
135
- return Array.isArray(direct) ? direct : []
136
- }) as readonly ConversationNode[] | undefined) ?? []
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 node of nodes) {
216
- if (node.kind !== 'user' && node.kind !== 'steering') continue
217
- if (seen.has(node.seq)) continue
218
- seen.add(node.seq)
219
- const text = promptText(node)
220
- if (text === null) continue
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
- }, [nodes])
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 nodes={nodes} onWiden={requestWiden} />
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
+ }