@khorsheed/dsh-client-message-tools 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.
Files changed (72) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +82 -0
  4. package/README.i18n.yaml +6 -0
  5. package/README.md +82 -0
  6. package/cordis.patch.yml +6 -0
  7. package/lib/client.js +5824 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +644 -0
  10. package/lib/invariant.js +27 -0
  11. package/lib/tsconfig.client.tsbuildinfo +1 -0
  12. package/lib/tsconfig.host.tsbuildinfo +1 -0
  13. package/lib/typert.host.d.ts +3 -0
  14. package/lib/typert.host.js +250 -0
  15. package/lib/typert.remote-client.d.ts +26 -0
  16. package/lib/typert.remote-client.d.ts.map +1 -0
  17. package/lib/typert.remote-client.js +154 -0
  18. package/lib/types/client/ModelChip.d.ts +29 -0
  19. package/lib/types/client/ModelChip.d.ts.map +1 -0
  20. package/lib/types/client/ModelChip.js +86 -0
  21. package/lib/types/client/RestoredMessageView.d.ts +17 -0
  22. package/lib/types/client/RestoredMessageView.d.ts.map +1 -0
  23. package/lib/types/client/RestoredMessageView.js +9 -0
  24. package/lib/types/client/UserMessageView.d.ts +16 -0
  25. package/lib/types/client/UserMessageView.d.ts.map +1 -0
  26. package/lib/types/client/UserMessageView.js +172 -0
  27. package/lib/types/client/WithdrawnDividerView.d.ts +17 -0
  28. package/lib/types/client/WithdrawnDividerView.d.ts.map +1 -0
  29. package/lib/types/client/WithdrawnDividerView.js +59 -0
  30. package/lib/types/client/backfill.d.ts +19 -0
  31. package/lib/types/client/backfill.d.ts.map +1 -0
  32. package/lib/types/client/backfill.js +20 -0
  33. package/lib/types/client/dom-hider.d.ts +61 -0
  34. package/lib/types/client/dom-hider.d.ts.map +1 -0
  35. package/lib/types/client/dom-hider.js +195 -0
  36. package/lib/types/client/edit-in-place.d.ts +107 -0
  37. package/lib/types/client/edit-in-place.d.ts.map +1 -0
  38. package/lib/types/client/edit-in-place.js +97 -0
  39. package/lib/types/client/icons.d.ts +23 -0
  40. package/lib/types/client/icons.d.ts.map +1 -0
  41. package/lib/types/client/icons.js +11 -0
  42. package/lib/types/client/index.d.ts +39 -0
  43. package/lib/types/client/index.d.ts.map +1 -0
  44. package/lib/types/client/index.js +187 -0
  45. package/lib/types/client/locales.d.ts +74 -0
  46. package/lib/types/client/locales.d.ts.map +1 -0
  47. package/lib/types/client/locales.js +65 -0
  48. package/lib/types/client/slots.d.ts +44 -0
  49. package/lib/types/client/slots.d.ts.map +1 -0
  50. package/lib/types/client/slots.js +1 -0
  51. package/lib/types/client/withdraw-backfill.d.ts +25 -0
  52. package/lib/types/client/withdraw-backfill.d.ts.map +1 -0
  53. package/lib/types/client/withdraw-backfill.js +21 -0
  54. package/lib/types/client/withdrawn-node.d.ts +163 -0
  55. package/lib/types/client/withdrawn-node.d.ts.map +1 -0
  56. package/lib/types/client/withdrawn-node.js +297 -0
  57. package/lib/types/index.d.ts +69 -0
  58. package/lib/types/index.d.ts.map +1 -0
  59. package/lib/types/index.js +175 -0
  60. package/lib/types/invariant.d.ts +16 -0
  61. package/lib/types/invariant.d.ts.map +1 -0
  62. package/lib/types/invariant.js +25 -0
  63. package/lib/types/marker.d.ts +94 -0
  64. package/lib/types/marker.d.ts.map +1 -0
  65. package/lib/types/marker.js +131 -0
  66. package/lib/types/types.d.ts +116 -0
  67. package/lib/types/types.d.ts.map +1 -0
  68. package/lib/types/types.js +1 -0
  69. package/lib/types/withdraw.d.ts +112 -0
  70. package/lib/types/withdraw.d.ts.map +1 -0
  71. package/lib/types/withdraw.js +279 -0
  72. package/package.json +108 -0
@@ -0,0 +1,172 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Shadow user-message renderer (keys 'user' and 'steering', priority -1; also
4
+ * the plugin's own 'message-tools-edited' and 'message-tools-restored' rows):
5
+ * a visual clone of the official bubble chrome — ui-conversation ships no
6
+ * public components, so the structure is replicated against ui-primitives
7
+ * platform modules, with historical images rendered through the owner-prop
8
+ * `renderMessageImages` attachment slot (rc8 contract) — plus the
9
+ * copy/edit/withdraw action row and the「已编辑」/「已恢复」labels. User
10
+ * messages inside a withdrawn span render nothing; the withdrawal divider
11
+ * marks the spot.
12
+ */
13
+ import { memo, useEffect, useRef, useState } from 'react';
14
+ import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client';
15
+ import { Button, IconCheckOutline16, IconCopyOutline16, IconEditOutline16, JsonBlock, MessageText, RiskConfirmation, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives';
16
+ import { IconUndoOutline16 } from './icons.js';
17
+ import { foldHiddenRanges, isSeqHidden } from './withdrawn-node.js';
18
+ import { withdrawAndBackfill } from './withdraw-backfill.js';
19
+ import { ModelChip } from './ModelChip.js';
20
+ import css from './UserMessageView.module.css';
21
+ /** Selector identity kept module-level so uSES memoization holds. */
22
+ function selectHiddenRanges(snapshot) {
23
+ return foldHiddenRanges(snapshot.chat.nodes.values());
24
+ }
25
+ /** Split user content into joined text, images, and leftover blocks. */
26
+ function contentParts(content) {
27
+ const texts = [];
28
+ const images = [];
29
+ const rest = [];
30
+ for (const block of content) {
31
+ const b = block;
32
+ if (b.type === 'text' && typeof b.text === 'string')
33
+ texts.push(b.text);
34
+ else if (b.type === 'image' && b.attachment !== undefined) {
35
+ images.push({ attachment: b.attachment });
36
+ }
37
+ else
38
+ rest.push(block);
39
+ }
40
+ return { text: texts.join(''), images, rest };
41
+ }
42
+ /** Plaintext `/name` and `@name` word-boundary tokens decorate as chips; the logged text stays the truth. */
43
+ function projectUserText(text) {
44
+ const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g;
45
+ const parts = [];
46
+ let cursor = 0;
47
+ let m;
48
+ while ((m = re.exec(text)) !== null) {
49
+ /* v8 ignore next -- the `(^|\s)` group always participates in a match, so m[1] is never undefined. */
50
+ const tokenStart = m.index + (m[1]?.length ?? 0);
51
+ /* v8 ignore next -- the token group always participates in a match, so m[2] is never undefined. */
52
+ const label = m[2] ?? '';
53
+ if (tokenStart > cursor)
54
+ parts.push(_jsx(MessageText, { text: text.slice(cursor, tokenStart) }, cursor));
55
+ parts.push(_jsx("span", { className: css.refChip, "data-ref-chip": label.startsWith('@') ? 'subagent' : 'skill', children: label }, tokenStart));
56
+ cursor = tokenStart + label.length;
57
+ }
58
+ if (parts.length === 0)
59
+ return _jsx(MessageText, { text: text });
60
+ if (cursor < text.length)
61
+ parts.push(_jsx(MessageText, { text: text.slice(cursor) }, cursor));
62
+ return _jsx(_Fragment, { children: parts });
63
+ }
64
+ /** Copy action: the copy icon swaps to a short-lived check after a successful write. */
65
+ function CopyButton({ text, t }) {
66
+ const [copied, setCopied] = useState(false);
67
+ const pending = useRef(false);
68
+ const timer = useRef(null);
69
+ const epoch = useRef(0);
70
+ useEffect(() => () => {
71
+ epoch.current += 1;
72
+ pending.current = false;
73
+ if (timer.current !== null)
74
+ clearTimeout(timer.current);
75
+ }, []);
76
+ const onCopy = () => {
77
+ if (copied || pending.current)
78
+ return;
79
+ const current = ++epoch.current;
80
+ pending.current = true;
81
+ void writeClipboard(text).then((ok) => {
82
+ if (current !== epoch.current)
83
+ return;
84
+ pending.current = false;
85
+ if (!ok)
86
+ return;
87
+ setCopied(true);
88
+ timer.current = setTimeout(() => {
89
+ timer.current = null;
90
+ setCopied(false);
91
+ }, 1000);
92
+ });
93
+ };
94
+ return (_jsx(Tooltip, { label: copied ? t('copied') : t('copy'), side: "bottom", children: _jsx("button", { type: "button", className: css.action, "aria-label": copied ? t('copied') : t('copy'), onClick: onCopy, children: copied ? _jsx(IconCheckOutline16, {}) : _jsx(IconCopyOutline16, {}) }) }));
95
+ }
96
+ /** Cap for the auto-grown edit box height; longer text scrolls inside it. */
97
+ const EDITOR_MAX_HEIGHT = 240;
98
+ /**
99
+ * Inline editor (MessageEditBox structure): the textarea is backfilled with
100
+ * the true original text and auto-grows to its content, capped so a long
101
+ * message scrolls inside instead of pushing the row's anchor. The trailing
102
+ * seat renders the real model chip when the session's model directory is
103
+ * available; its margin-right: auto container keeps the buttons
104
+ * right-aligned either way.
105
+ */
106
+ function InlineEditor({ initial, busy, t, onSave, onCancel, models }) {
107
+ const [draft, setDraft] = useState(initial);
108
+ const inputRef = useRef(null);
109
+ const autoGrow = () => {
110
+ const el = inputRef.current;
111
+ /* v8 ignore next -- the textarea ref is attached before the mount/change effects run. */
112
+ if (el === null)
113
+ return;
114
+ el.style.height = 'auto';
115
+ el.style.height = `${Math.min(el.scrollHeight, EDITOR_MAX_HEIGHT)}px`;
116
+ };
117
+ // Grow once on mount (the backfilled text may exceed the default height)
118
+ // and on every edit; the cap keeps the box bounded.
119
+ useEffect(autoGrow, []);
120
+ const save = () => {
121
+ /* v8 ignore next -- the save button is disabled while the draft is blank, and jsdom drops clicks on disabled controls. */
122
+ if (draft.trim() === '')
123
+ return;
124
+ onSave(draft);
125
+ };
126
+ return (_jsxs("div", { className: css.editor, "data-message-tools-edit-box": true, children: [_jsx("textarea", { ref: inputRef, className: css.editorInput, value: draft, "aria-label": t('editor.aria'), placeholder: t('editor.placeholder'), disabled: busy, rows: 1, onChange: (event) => {
127
+ setDraft(event.currentTarget.value);
128
+ autoGrow();
129
+ } }), _jsxs("div", { className: css.editorActions, children: [_jsx("div", { className: css.editorTrailing, children: models !== undefined && _jsx(ModelChip, { ...models }) }), _jsx(Button, { variant: "outline", disabled: busy, onClick: onCancel, children: t('cancel') }), _jsx(Button, { variant: "primary", disabled: busy || draft.trim() === '', onClick: save, children: t('editor.save') })] })] }));
130
+ }
131
+ /** The shadowed user-message view: bubble plus copy/edit/withdraw actions. */
132
+ export const UserMessageView = memo(function UserMessageView({ node, renderMessageImages, t, useSession, editMessage, withdrawMessage, backfillDraft, useModelDirectory, modelsAvailable, loadModels, selectModel, }) {
133
+ const ranges = useSession(selectHiddenRanges, shallowEqual);
134
+ const [editing, setEditing] = useState(false);
135
+ const [confirming, setConfirming] = useState(false);
136
+ const [acknowledged, setAcknowledged] = useState(false);
137
+ const [busy, setBusy] = useState(false);
138
+ const [failed, setFailed] = useState(null);
139
+ const data = node.data;
140
+ if (isSeqHidden(ranges, data.seq))
141
+ return null;
142
+ const { text, images, rest } = contentParts(data.content);
143
+ const truncated = (total) => t('json.truncated', { total });
144
+ const showBubble = text !== '' || rest.length > 0;
145
+ const saveEdit = (newText) => {
146
+ setBusy(true);
147
+ setFailed(null);
148
+ void editMessage(data.seq, newText)
149
+ .then(() => { setEditing(false); })
150
+ .catch(() => { setFailed('edit'); })
151
+ .finally(() => { setBusy(false); });
152
+ };
153
+ const confirmWithdraw = () => {
154
+ setBusy(true);
155
+ setFailed(null);
156
+ // A landed withdrawal auto-backfills the target's original text into the
157
+ // composer draft (never sends); a failed one backfills nothing.
158
+ void withdrawAndBackfill({ withdraw: () => withdrawMessage(data.seq), backfill: backfillDraft }, text)
159
+ .catch(() => { setFailed('withdraw'); })
160
+ .finally(() => {
161
+ setBusy(false);
162
+ setConfirming(false);
163
+ setAcknowledged(false);
164
+ });
165
+ };
166
+ if (editing) {
167
+ return (_jsx("div", { className: css.userRow, children: _jsxs("div", { className: css.userStack, children: [_jsx(InlineEditor, { initial: text, busy: busy, t: t, onSave: saveEdit, onCancel: () => { setEditing(false); }, models: modelsAvailable
168
+ ? { useModelDirectory, loadModels, selectModel, t }
169
+ : undefined }), failed === 'edit' && _jsx("div", { className: css.actionError, role: "status", children: t('error.edit') })] }) }));
170
+ }
171
+ return (_jsxs("div", { className: css.userRow, "data-time-hover-root": true, children: [_jsxs("div", { className: css.userStack, children: [node.kind === 'message-tools-edited' && (_jsx("div", { className: css.editedLabel, children: t('edited.badge') })), node.kind === 'message-tools-restored' && (_jsx("div", { className: css.editedLabel, children: t('withdrawn.restored') })), images.length > 0 ? renderMessageImages({ images, align: 'end' }) : null, showBubble && (_jsxs("div", { className: css.bubble, children: [projectUserText(text), rest.map((block, index) => (_jsx(JsonBlock, { label: t('message.extraBlock'), payload: block, truncatedLabel: truncated }, index)))] }))] }), _jsxs("div", { className: css.actions, children: [_jsx(CopyButton, { text: text, t: t }), _jsx(Tooltip, { label: t('edit'), side: "bottom", children: _jsx("button", { type: "button", className: css.action, "aria-label": t('edit'), onClick: () => { setFailed(null); setEditing(true); }, children: _jsx(IconEditOutline16, {}) }) }), _jsx(Tooltip, { label: t('action.withdraw'), side: "bottom", children: _jsx("button", { type: "button", className: css.action, "aria-label": t('action.withdraw'), onClick: () => { setFailed(null); setAcknowledged(false); setConfirming(true); }, children: _jsx(IconUndoOutline16, {}) }) })] }), failed === 'withdraw' && _jsx("div", { className: css.actionError, role: "status", children: t('error.withdraw') }), confirming && (_jsx(RiskConfirmation, { open: true, title: t('withdraw.title'), description: t('withdraw.description'), acknowledgeLabel: t('withdraw.acknowledge'), cancelLabel: t('cancel'), confirmLabel: t('withdraw.confirm'), acknowledged: acknowledged, disabled: busy, onAcknowledgedChange: setAcknowledged, onCancel: () => { setConfirming(false); setAcknowledged(false); }, onConfirm: confirmWithdraw }))] }));
172
+ });
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Withdrawal divider row ('message-tools-withdrawn' keyed renderer), redone
3
+ * in the official compaction-marker visual language: one dim clickable row
4
+ * (chevron +「已撤回 N 条消息」), expanding in place to a read-only replay of
5
+ * the withdrawn span (user originals + assistant text) with a「恢复到对话
6
+ * 末尾」action. The restore tail-replays the span's replayable entries (user
7
+ * messages + assistant text) — the surface fold is positional, so in-place
8
+ * restoration is impossible — and the divider carries a「已恢复」badge while
9
+ * a live restore row cites the span (withdrawing the restored rows clears
10
+ * the badge and re-enables the action). The「重新编辑」action was removed
11
+ * once withdrawals backfill the draft automatically.
12
+ */
13
+ import { type ReactNode } from 'react';
14
+ import type { WithdrawnDividerViewProps } from './slots.ts';
15
+ /** The withdrawal divider: collapsed marker row plus the expandable replay. */
16
+ export declare function WithdrawnDividerView({ node, t, useSession, restoreMessage, }: WithdrawnDividerViewProps): ReactNode;
17
+ //# sourceMappingURL=WithdrawnDividerView.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"WithdrawnDividerView.d.ts","sourceRoot":"","sources":["../../../src/client/WithdrawnDividerView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAOhD,OAAO,KAAK,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAA;AAG3D,+EAA+E;AAC/E,wBAAgB,oBAAoB,CAAC,EACnC,IAAI,EAAE,CAAC,EAAE,UAAU,EAAE,cAAc,GACpC,EAAE,yBAAyB,GAAG,SAAS,CA0FvC"}
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Withdrawal divider row ('message-tools-withdrawn' keyed renderer), redone
4
+ * in the official compaction-marker visual language: one dim clickable row
5
+ * (chevron +「已撤回 N 条消息」), expanding in place to a read-only replay of
6
+ * the withdrawn span (user originals + assistant text) with a「恢复到对话
7
+ * 末尾」action. The restore tail-replays the span's replayable entries (user
8
+ * messages + assistant text) — the surface fold is positional, so in-place
9
+ * restoration is impossible — and the divider carries a「已恢复」badge while
10
+ * a live restore row cites the span (withdrawing the restored rows clears
11
+ * the badge and re-enables the action). The「重新编辑」action was removed
12
+ * once withdrawals backfill the draft automatically.
13
+ */
14
+ import { useState } from 'react';
15
+ import { Button, IconChevronDownOutline14, IconChevronRightOutline14, MessageText } from '@deepseek-ai/dsh-client-ui-primitives';
16
+ import { IconUndoOutline16 } from './icons.js';
17
+ import { collectWithdrawnEntries, countHiddenInSpan, foldHiddenRanges, hasRestoreForSpan, isRestoreSuperseded, } from './withdrawn-node.js';
18
+ import css from './WithdrawnDividerView.module.css';
19
+ /** The withdrawal divider: collapsed marker row plus the expandable replay. */
20
+ export function WithdrawnDividerView({ node, t, useSession, restoreMessage, }) {
21
+ const data = node.data;
22
+ const [expanded, setExpanded] = useState(false);
23
+ const [entries, setEntries] = useState(null);
24
+ const [busy, setBusy] = useState(false);
25
+ const [failed, setFailed] = useState(false);
26
+ // The node store is a stable live reader: the count/badge selectors read
27
+ // through it, and the expand click folds the replay from the live snapshot
28
+ // (event handlers may read live snapshots; render code subscribes).
29
+ const nodesStore = useSession(snapshot => snapshot.chat.nodes);
30
+ const count = useSession(snapshot => countHiddenInSpan(snapshot.chat.nodes.values(), data.hiddenStartSeq, data.seq));
31
+ // The badge tracks a LIVE restore row: withdrawing the restored rows again
32
+ // clears it. A superseded divider still shows its historical marker, but its
33
+ // restore action is disabled because the later re-withdrawal divider is now
34
+ // the active restore point for the same logical content.
35
+ const restored = useSession((snapshot) => {
36
+ const nodes = snapshot.chat.nodes.values();
37
+ return hasRestoreForSpan(nodes, data.hiddenStartSeq, foldHiddenRanges(nodes));
38
+ });
39
+ const superseded = useSession((snapshot) => {
40
+ const nodes = snapshot.chat.nodes.values();
41
+ return isRestoreSuperseded(nodes, data.hiddenStartSeq, foldHiddenRanges(nodes));
42
+ });
43
+ const toggle = () => {
44
+ if (expanded) {
45
+ setExpanded(false);
46
+ return;
47
+ }
48
+ setEntries(collectWithdrawnEntries(nodesStore.values(), data.hiddenStartSeq, data.seq));
49
+ setExpanded(true);
50
+ };
51
+ const restore = () => {
52
+ setBusy(true);
53
+ setFailed(false);
54
+ void restoreMessage(data.hiddenStartSeq)
55
+ .catch(() => { setFailed(true); })
56
+ .finally(() => { setBusy(false); });
57
+ };
58
+ return (_jsxs("div", { className: css.row, children: [_jsxs("button", { type: "button", className: css.marker, "aria-expanded": expanded, "aria-label": t('withdrawn.expand'), onClick: toggle, children: [_jsx("span", { className: css.leading, "aria-hidden": true, children: expanded ? _jsx(IconChevronDownOutline14, {}) : _jsx(IconChevronRightOutline14, {}) }), _jsx("span", { className: css.title, children: t('withdrawn.divider', { count }) }), restored && (_jsxs("span", { className: css.badge, children: [_jsx(IconUndoOutline16, {}), t('withdrawn.restored')] }))] }), expanded && (_jsxs("div", { className: css.body, children: [entries === null || entries.length === 0 ? (_jsx("div", { className: css.empty, children: t('withdrawn.empty') })) : (entries.map((entry, index) => (_jsxs("div", { className: entry.kind === 'user' ? css.entryUser : css.entryAssistant, children: [_jsx("div", { className: css.entryKind, children: entry.kind === 'user' ? t('withdrawn.entryUser') : t('withdrawn.entryAssistant') }), _jsx(MessageText, { text: entry.text })] }, index)))), _jsxs("div", { className: css.restoreRow, children: [_jsx(Button, { variant: "outline", disabled: busy || restored || superseded, onClick: restore, children: t('withdrawn.restore') }), failed && _jsx("span", { className: css.restoreFailed, role: "status", children: t('withdrawn.restoreFailed') })] })] }))] }));
59
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Composer-draft backfill for the withdrawal divider's「重新编辑」action: the
3
+ * withdrawn span's first user message text is written into the session's
4
+ * composer draft (never auto-sent — the user edits and sends through the
5
+ * official pipeline as a fresh ordinary message, decoupled from the
6
+ * withdrawal history). Kept free of services so the unit tests drive it
7
+ * directly.
8
+ * @module
9
+ */
10
+ /**
11
+ * Merge the backfill text into the current draft: an empty (or blank) draft
12
+ * is filled directly; a non-empty draft gets the text appended on a new line
13
+ * — the user's in-progress input is never silently overwritten.
14
+ * @param current - the current composer draft.
15
+ * @param text - the withdrawn message's original text.
16
+ * @returns the next draft.
17
+ */
18
+ export declare function mergedDraft(current: string, text: string): string;
19
+ //# sourceMappingURL=backfill.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"backfill.d.ts","sourceRoot":"","sources":["../../../src/client/backfill.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAEjE"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Composer-draft backfill for the withdrawal divider's「重新编辑」action: the
3
+ * withdrawn span's first user message text is written into the session's
4
+ * composer draft (never auto-sent — the user edits and sends through the
5
+ * official pipeline as a fresh ordinary message, decoupled from the
6
+ * withdrawal history). Kept free of services so the unit tests drive it
7
+ * directly.
8
+ * @module
9
+ */
10
+ /**
11
+ * Merge the backfill text into the current draft: an empty (or blank) draft
12
+ * is filled directly; a non-empty draft gets the text appended on a new line
13
+ * — the user's in-progress input is never silently overwritten.
14
+ * @param current - the current composer draft.
15
+ * @param text - the withdrawn message's original text.
16
+ * @returns the next draft.
17
+ */
18
+ export function mergedDraft(current, text) {
19
+ return current.trim() === '' ? text : `${current}\n${text}`;
20
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * DOM hider: hide EVERY chat row whose node anchor falls inside a withdrawn
3
+ * span — all node kinds, not just user messages — through a dynamic
4
+ * stylesheet keyed on the row's `data-chat-flow-key` attribute
5
+ * (ChatNodeSeat.tsx). The projection itself offers no suppression seam (see
6
+ * README), so this works at the DOM layer. The attribute is an undocumented
7
+ * surface: the first time a non-empty rule set is computed, the hider probes
8
+ * for it (after a frame, so React has committed). A page restored onto a tab
9
+ * where the chat rows are not mounted yet (the trace tab, or the chat area
10
+ * still mounting) makes that first probe miss, so a miss does NOT disable
11
+ * the hider outright: it enters a bounded retry — a MutationObserver waits
12
+ * for the first row to appear, with a deadline — and only a window that
13
+ * expires with rows still absent disables the hider, with one console.warn,
14
+ * degrading to renderer-only hiding (the shadowed user renderer keeps hiding
15
+ * user messages). Even then the observer stays: a row appearing after the
16
+ * disable (the page sat on a non-chat tab past the window) proves the
17
+ * attribute exists after all, and hiding reactivates. It never throws. Rules track the currently selected
18
+ * session only: flow keys are session-scoped (seq-like ids collide across
19
+ * sessions), and only one chat view is mounted at a time.
20
+ */
21
+ import type { ChatConversationViewNode, ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
22
+ /**
23
+ * Flow keys of every chat node inside a hidden span. The withdrawal divider
24
+ * itself (`message-tools-withdrawn`, anchored at the span's exclusive end) is
25
+ * never included — it is the marker that must stay visible. Restore events
26
+ * (user-message and assistant-text replays) are additionally matched by the
27
+ * built-in message Definition into context-injection rows (every
28
+ * append-surface user/message qualifies), so the context row sharing a
29
+ * restored row's seq is hidden as a duplicate — the plugin's restored rows
30
+ * are the presentation.
31
+ * @param nodes - every materialized chat node of the session.
32
+ * @param ranges - precomputed hidden spans (folded from `nodes` when omitted).
33
+ * @returns the flow keys to hide, in node order.
34
+ */
35
+ export declare function hiddenFlowKeys(nodes: readonly ChatConversationViewNode[], ranges?: readonly number[]): string[];
36
+ /**
37
+ * Render the display rules for one rule set.
38
+ * @param keys - flow keys to hide.
39
+ * @returns the stylesheet text (empty string hides nothing).
40
+ */
41
+ export declare function renderHiderRules(keys: readonly string[]): string;
42
+ /**
43
+ * Tuning of {@link installDomHider}; the defaults match production.
44
+ */
45
+ export interface DomHiderOptions {
46
+ /**
47
+ * Bound on the probe retry window: how long the hider waits for the first
48
+ * `data-chat-flow-key` row to appear before concluding the attribute is
49
+ * gone and disabling itself (default 10s; tests shrink it).
50
+ */
51
+ probeRetryWindowMs?: number;
52
+ }
53
+ /**
54
+ * Install the hider: one shared style element, rebound whenever the selected
55
+ * session changes, rewritten only when the span set or the node count moves.
56
+ * @param ctx - client root context (reads the sessions service).
57
+ * @param options - probe retry tuning.
58
+ * @returns disposer removing the stylesheet and every subscription.
59
+ */
60
+ export declare function installDomHider(ctx: ClientContext, options?: DomHiderOptions): () => void;
61
+ //# sourceMappingURL=dom-hider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dom-hider.d.ts","sourceRoot":"","sources":["../../../src/client/dom-hider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,OAAO,KAAK,EAAE,wBAAwB,EAAE,aAAa,EAAmC,MAAM,wCAAwC,CAAA;AAItI;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,SAAS,wBAAwB,EAAE,EAAE,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CA2B/G;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAIhE;AAED;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,aAAa,EAAE,OAAO,GAAE,eAAoB,GAAG,MAAM,IAAI,CAsI7F"}
@@ -0,0 +1,195 @@
1
+ import { MESSAGE_TOOLS_PLUGIN, messageToolsOp } from '../marker.js';
2
+ import { foldHiddenRanges, isSeqHidden } from './withdrawn-node.js';
3
+ /**
4
+ * Flow keys of every chat node inside a hidden span. The withdrawal divider
5
+ * itself (`message-tools-withdrawn`, anchored at the span's exclusive end) is
6
+ * never included — it is the marker that must stay visible. Restore events
7
+ * (user-message and assistant-text replays) are additionally matched by the
8
+ * built-in message Definition into context-injection rows (every
9
+ * append-surface user/message qualifies), so the context row sharing a
10
+ * restored row's seq is hidden as a duplicate — the plugin's restored rows
11
+ * are the presentation.
12
+ * @param nodes - every materialized chat node of the session.
13
+ * @param ranges - precomputed hidden spans (folded from `nodes` when omitted).
14
+ * @returns the flow keys to hide, in node order.
15
+ */
16
+ export function hiddenFlowKeys(nodes, ranges) {
17
+ const restoredSeqs = new Set(nodes
18
+ .filter(node => node.kind === 'message-tools-restored' || node.kind === 'message-tools-restored-assistant')
19
+ .map(node => node.data.seq));
20
+ const effective = ranges ?? foldHiddenRanges(nodes);
21
+ const keys = [];
22
+ for (const node of nodes) {
23
+ if (node.kind === 'message-tools-withdrawn')
24
+ continue;
25
+ if (node.kind === 'context') {
26
+ const data = node.data;
27
+ const source = data.source;
28
+ // Every message-tools context row is a presentation duplicate or a
29
+ // wake-only trigger: restore replays are rendered by the plugin's own
30
+ // rows, edit triggers are never transcript content, and withdrawal /
31
+ // edit replacements are not append-surface context rows at all.
32
+ if (source?.kind === 'plugin' && source.plugin === MESSAGE_TOOLS_PLUGIN
33
+ || restoredSeqs.has(data.seq ?? -1)
34
+ || messageToolsOp(data.source) === 'edit-trigger') {
35
+ keys.push(node.key);
36
+ continue;
37
+ }
38
+ }
39
+ if (isSeqHidden(effective, node.anchorSeq))
40
+ keys.push(node.key);
41
+ }
42
+ return keys;
43
+ }
44
+ /**
45
+ * Render the display rules for one rule set.
46
+ * @param keys - flow keys to hide.
47
+ * @returns the stylesheet text (empty string hides nothing).
48
+ */
49
+ export function renderHiderRules(keys) {
50
+ return keys
51
+ .map(key => `[data-chat-flow-key=${JSON.stringify(key)}]{display:none!important}`)
52
+ .join('\n');
53
+ }
54
+ /**
55
+ * Install the hider: one shared style element, rebound whenever the selected
56
+ * session changes, rewritten only when the span set or the node count moves.
57
+ * @param ctx - client root context (reads the sessions service).
58
+ * @param options - probe retry tuning.
59
+ * @returns disposer removing the stylesheet and every subscription.
60
+ */
61
+ export function installDomHider(ctx, options = {}) {
62
+ const probeRetryWindowMs = options.probeRetryWindowMs ?? 10_000;
63
+ // rAF so the probe runs after React committed; setTimeout covers jsdom.
64
+ const nextFrame = typeof requestAnimationFrame === 'function'
65
+ ? requestAnimationFrame
66
+ : (callback) => { setTimeout(callback, 16); };
67
+ const style = document.createElement('style');
68
+ style.setAttribute('data-message-tools-hider', '');
69
+ document.head.appendChild(style);
70
+ let activeSession;
71
+ let stopSession;
72
+ let signature = '';
73
+ let probed = false;
74
+ let probeScheduled = false;
75
+ let disabled = false;
76
+ let retrying = false;
77
+ let retryObserver;
78
+ let retryTimer;
79
+ let latest;
80
+ const applyRules = (snapshot) => {
81
+ latest = snapshot;
82
+ const nodes = snapshot.chat.nodes.values();
83
+ const ranges = foldHiddenRanges(nodes);
84
+ const nextSignature = `${ranges.join(',')}:${nodes.length}`;
85
+ if (nextSignature === signature)
86
+ return;
87
+ signature = nextSignature;
88
+ style.textContent = renderHiderRules(hiddenFlowKeys(nodes, ranges));
89
+ };
90
+ const probeFound = () => document.querySelector('[data-chat-flow-key]') !== null;
91
+ const stopRetry = () => {
92
+ retrying = false;
93
+ retryObserver?.disconnect();
94
+ retryObserver = undefined;
95
+ if (retryTimer !== undefined) {
96
+ clearTimeout(retryTimer);
97
+ retryTimer = undefined;
98
+ }
99
+ };
100
+ const passProbe = () => {
101
+ stopRetry();
102
+ probed = true;
103
+ disabled = false;
104
+ /* v8 ignore next -- the probe is only scheduled from onSnapshot, which sets latest first */
105
+ if (latest !== undefined)
106
+ applyRules(latest);
107
+ };
108
+ const failProbe = () => {
109
+ // Window expired with rows still absent: degrade (one warn, no rules) —
110
+ // but KEEP the observer: a row appearing later proves the miss was
111
+ // mounting timing (e.g. the page sat on the trace tab past the window),
112
+ // and hiding reactivates instead of leaking the spans forever.
113
+ if (retryTimer !== undefined) {
114
+ clearTimeout(retryTimer);
115
+ retryTimer = undefined;
116
+ }
117
+ retrying = false;
118
+ disabled = true;
119
+ style.textContent = '';
120
+ console.warn('message-tools: data-chat-flow-key rows not found; full-span chat hiding disabled '
121
+ + '(user messages stay hidden by the shadowed renderer; hiding reactivates if chat rows appear)');
122
+ };
123
+ /**
124
+ * A missed probe is not proof the attribute is gone — the chat rows may
125
+ * simply not be mounted yet (page restored onto the trace tab, chat area
126
+ * still mounting). Watch for the first row within a bounded window; only
127
+ * an expired window with rows still absent disables the hider, and the
128
+ * observer outlives even that (see failProbe).
129
+ */
130
+ const startRetry = () => {
131
+ retrying = true;
132
+ if (typeof MutationObserver === 'function') {
133
+ retryObserver = new MutationObserver(() => { if (probeFound())
134
+ passProbe(); });
135
+ retryObserver.observe(document.body, {
136
+ childList: true, subtree: true, attributes: true, attributeFilter: ['data-chat-flow-key'],
137
+ });
138
+ }
139
+ retryTimer = setTimeout(() => { if (probeFound())
140
+ passProbe();
141
+ else
142
+ failProbe(); }, probeRetryWindowMs);
143
+ };
144
+ const onSnapshot = (snapshot) => {
145
+ latest = snapshot;
146
+ if (disabled)
147
+ return;
148
+ if (probed) {
149
+ applyRules(snapshot);
150
+ return;
151
+ }
152
+ // Probe on the first non-empty rule set, one frame out so React has
153
+ // committed the rows the rules target.
154
+ if (hiddenFlowKeys(snapshot.chat.nodes.values()).length === 0)
155
+ return;
156
+ if (probeScheduled || retrying)
157
+ return;
158
+ probeScheduled = true;
159
+ nextFrame(() => {
160
+ probeScheduled = false;
161
+ if (probeFound()) {
162
+ passProbe();
163
+ return;
164
+ }
165
+ startRetry();
166
+ });
167
+ };
168
+ const bindCurrent = () => {
169
+ const current = ctx.sessions.list.getSnapshot().current;
170
+ if (current === activeSession && stopSession !== undefined)
171
+ return;
172
+ stopSession?.();
173
+ stopSession = undefined;
174
+ activeSession = current;
175
+ signature = '';
176
+ style.textContent = '';
177
+ if (current === undefined)
178
+ return;
179
+ const binding = ctx.sessions.binding(current);
180
+ if (binding === undefined)
181
+ return;
182
+ onSnapshot(binding.session.getSnapshot());
183
+ stopSession = binding.session.subscribe(() => { onSnapshot(binding.session.getSnapshot()); });
184
+ };
185
+ const stopList = ctx.sessions.list.subscribe(bindCurrent);
186
+ const stopProvide = ctx.sessions.currentProvideInfo.subscribe(bindCurrent);
187
+ bindCurrent();
188
+ return () => {
189
+ stopList();
190
+ stopProvide();
191
+ stopSession?.();
192
+ stopRetry();
193
+ style.remove();
194
+ };
195
+ }