@khorsheed/dsh-client-message-tools 0.3.3 → 0.3.5

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.
@@ -4,20 +4,25 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
4
4
  * the plugin's own 'message-tools-edited' and 'message-tools-restored' rows):
5
5
  * a visual clone of the official bubble chrome — ui-conversation ships no
6
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.
7
+ * platform modules — plus the copy/edit/withdraw action row and the
8
+ * 「已编辑」/「已恢复」labels. Attached images render through the host's
9
+ * gallery surface (see message-images.ts): the pre-0.2.1 `renderMessageImages`
10
+ * owner prop when the host still passes it, otherwise the root-scoped
11
+ * `conversation.images.content` factory. User messages inside a withdrawn
12
+ * span render nothing; the withdrawal divider marks the spot. A long message
13
+ * folds behind a fade; a small chevron seated in the bubble's bottom-right
14
+ * corner expands / collapses it (see fold.ts).
12
15
  */
13
- import { memo, useEffect, useRef, useState } from 'react';
16
+ import { Fragment, memo, useEffect, useRef, useState } from 'react';
14
17
  import { shallowEqual } from '@deepseek-ai/dsh-client-store';
15
18
  import { Button, JsonBlock, RiskConfirmation, Tooltip, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives';
16
- import { IconCheckOutlineMedium, IconCopyOutlineMedium, IconEditOutlineMedium } from './icons.js';
19
+ import { IconCheckOutlineMedium, IconChevronDownOutlineMedium, IconCopyOutlineMedium, IconEditOutlineMedium } from './icons.js';
17
20
  import { IconUndoOutlineMedium } from './icons-local.js';
18
21
  import { foldHiddenRanges, isSeqHidden } from './withdrawn-node.js';
19
22
  import { chatHookOf } from './chat-hook.js';
23
+ import { renderImageGallery } from './message-images.js';
20
24
  import { withdrawAndBackfill } from './withdraw-backfill.js';
25
+ import { foldable, foldChoices, selectNewestUserSeq } from './fold.js';
21
26
  import { ModelChip } from './ModelChip.js';
22
27
  import css from './UserMessageView.module.css';
23
28
  /** Selector identity kept module-level so uSES memoization holds. */
@@ -41,8 +46,30 @@ function contentParts(content) {
41
46
  }
42
47
  return { text: texts.join(''), images, rest };
43
48
  }
44
- /** Plaintext `/name` and `@name` word-boundary tokens decorate as chips; the logged text stays the truth. */
49
+ /** The host's canonical session mention, `@[title](dsh-session:…)` (the @ menu's session chip, sent as text). */
50
+ const SESSION_MENTION = /@\[((?:\\.|[^\\\]\n])+)\]\(dsh-session:[^)\s]+\)/gu;
51
+ /**
52
+ * A session mention shows as a chip with its title (the host's own user-text
53
+ * rule does the same); the rest of the text keeps the token projection.
54
+ */
45
55
  function projectUserText(text) {
56
+ const parts = [];
57
+ let cursor = 0;
58
+ for (const match of text.matchAll(SESSION_MENTION)) {
59
+ if (match.index > cursor)
60
+ parts.push(_jsx(Fragment, { children: projectTokens(text.slice(cursor, match.index)) }, `t${cursor}`));
61
+ const label = match[1].replace(/\\(.)/gu, '$1');
62
+ parts.push(_jsxs("span", { className: css.refChip, "data-ref-chip": "session", title: label, children: ["@", label] }, `s${match.index}`));
63
+ cursor = match.index + match[0].length;
64
+ }
65
+ if (parts.length === 0)
66
+ return projectTokens(text);
67
+ if (cursor < text.length)
68
+ parts.push(_jsx(Fragment, { children: projectTokens(text.slice(cursor)) }, `t${cursor}`));
69
+ return _jsx(_Fragment, { children: parts });
70
+ }
71
+ /** Plaintext `/name` and `@name` word-boundary tokens decorate as chips; the logged text stays the truth. */
72
+ function projectTokens(text) {
46
73
  const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g;
47
74
  const parts = [];
48
75
  let cursor = 0;
@@ -134,10 +161,14 @@ function InlineEditor({ initial, busy, t, onSave, onCancel, models }) {
134
161
  } }), _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') })] })] }));
135
162
  }
136
163
  /** The shadowed user-message view: bubble plus copy/edit/withdraw actions. */
137
- export const UserMessageView = memo(function UserMessageView({ node, renderMessageImages, t, editMessage, withdrawMessage, backfillDraft, useModelDirectory, modelsAvailable, loadModels, selectModel, ...standard }) {
164
+ export const UserMessageView = memo(function UserMessageView({ node, renderMessageImages, loadImage, renderFactorySlot, t, editMessage, withdrawMessage, backfillDraft, foldScope, useModelDirectory, modelsAvailable, loadModels, selectModel, ...standard }) {
138
165
  // Chat data lives in the `useChat` session standard prop on host 0.1.2
139
166
  // (the helper degrades to the frozen empty snapshot without it).
140
- const ranges = chatHookOf(standard)(selectHiddenRanges, shallowEqual);
167
+ const useChat = chatHookOf(standard);
168
+ const ranges = useChat(selectHiddenRanges, shallowEqual);
169
+ const newestUserSeq = useChat(selectNewestUserSeq);
170
+ // The human's own choice for this message (remembered), else the default.
171
+ const [chosen, setChosen] = useState(() => foldChoices.read(foldScope)[String(node.data.seq)]);
141
172
  const [editing, setEditing] = useState(false);
142
173
  const [confirming, setConfirming] = useState(false);
143
174
  const [acknowledged, setAcknowledged] = useState(false);
@@ -149,6 +180,14 @@ export const UserMessageView = memo(function UserMessageView({ node, renderMessa
149
180
  const { text, images, rest } = contentParts(data.content);
150
181
  const truncated = (total) => t('json.truncated', { total });
151
182
  const showBubble = text !== '' || rest.length > 0;
183
+ const collapsible = foldable(text);
184
+ // The newest user message reads unfolded until the next one arrives.
185
+ const expanded = chosen ?? data.seq >= newestUserSeq;
186
+ const clamped = collapsible && !expanded;
187
+ const toggle = (next) => {
188
+ setChosen(next);
189
+ foldChoices.write(foldScope, data.seq, next);
190
+ };
152
191
  const saveEdit = (newText) => {
153
192
  setBusy(true);
154
193
  setFailed(null);
@@ -175,5 +214,5 @@ export const UserMessageView = memo(function UserMessageView({ node, renderMessa
175
214
  ? { useModelDirectory, loadModels, selectModel, t }
176
215
  : undefined }), failed === 'edit' && _jsx("div", { className: css.actionError, role: "status", children: t('error.edit') })] }) }));
177
216
  }
178
- 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(IconEditOutlineMedium, {}) }) }), _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(IconUndoOutlineMedium, {}) }) })] }), 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'), closeLabel: t('close'), confirmLabel: t('withdraw.confirm'), acknowledged: acknowledged, disabled: busy, onAcknowledgedChange: setAcknowledged, onCancel: () => { setConfirming(false); setAcknowledged(false); }, onConfirm: confirmWithdraw }))] }));
217
+ 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 ? renderImageGallery(renderMessageImages, renderFactorySlot, loadImage, { images, align: 'end' }) : null, showBubble && (_jsxs("div", { className: clamped ? `${css.bubble} ${css.clamped}` : css.bubble, children: [projectUserText(text), rest.map((block, index) => (_jsx(JsonBlock, { label: t('message.extraBlock'), payload: block, truncatedLabel: truncated }, index))), clamped && _jsx("div", { className: css.fade, "aria-hidden": true, onClick: () => { toggle(true); } }), collapsible && (_jsx("div", { className: css.foldBar, children: _jsx(Tooltip, { label: expanded ? t('fold.collapse') : t('fold.expand'), side: "bottom", children: _jsx("button", { type: "button", className: css.foldToggle, "aria-label": expanded ? t('fold.collapse') : t('fold.expand'), "aria-expanded": expanded, onClick: () => { toggle(!expanded); }, children: _jsx(IconChevronDownOutlineMedium, {}) }) }) }))] }))] }), _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(IconEditOutlineMedium, {}) }) }), _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(IconUndoOutlineMedium, {}) }) })] }), 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'), closeLabel: t('close'), confirmLabel: t('withdraw.confirm'), acknowledged: acknowledged, disabled: busy, onAcknowledgedChange: setAcknowledged, onCancel: () => { setConfirming(false); setAcknowledged(false); }, onConfirm: confirmWithdraw }))] }));
179
218
  });
@@ -9,10 +9,13 @@
9
9
  * restoration is impossible — and the divider carries a「已恢复」badge while
10
10
  * a live restore row cites the span (withdrawing the restored rows clears
11
11
  * the badge and re-enables the action). The「重新编辑」action was removed
12
- * once withdrawals backfill the draft automatically.
12
+ * once withdrawals backfill the draft automatically. Replayed images render
13
+ * through the host's gallery surface (see message-images.ts): the pre-0.2.1
14
+ * `renderMessageImages` owner prop when the host still passes it, otherwise
15
+ * the root-scoped `conversation.images.content` factory.
13
16
  */
14
17
  import { type ReactNode } from 'react';
15
18
  import type { WithdrawnDividerViewProps } from './slots.ts';
16
19
  /** The withdrawal divider: collapsed marker row plus the expandable replay. */
17
- export declare function WithdrawnDividerView({ node, t, restoreMessage, renderMessageImages, ...standard }: WithdrawnDividerViewProps): ReactNode;
20
+ export declare function WithdrawnDividerView({ node, t, restoreMessage, renderMessageImages, loadImage, renderFactorySlot, ...standard }: WithdrawnDividerViewProps): ReactNode;
18
21
  //# sourceMappingURL=WithdrawnDividerView.d.ts.map
@@ -10,7 +10,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  * restoration is impossible — and the divider carries a「已恢复」badge while
11
11
  * a live restore row cites the span (withdrawing the restored rows clears
12
12
  * the badge and re-enables the action). The「重新编辑」action was removed
13
- * once withdrawals backfill the draft automatically.
13
+ * once withdrawals backfill the draft automatically. Replayed images render
14
+ * through the host's gallery surface (see message-images.ts): the pre-0.2.1
15
+ * `renderMessageImages` owner prop when the host still passes it, otherwise
16
+ * the root-scoped `conversation.images.content` factory.
14
17
  */
15
18
  import { useState } from 'react';
16
19
  import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
@@ -18,14 +21,11 @@ import { IconChevronDownOutlineMedium, IconChevronRightOutlineMedium } from './i
18
21
  import { IconUndoOutlineMedium } from './icons-local.js';
19
22
  import { collectWithdrawnEntries, countHiddenInSpan, foldHiddenRanges, hasRestoreForSpan, isRestoreSuperseded, } from './withdrawn-node.js';
20
23
  import { chatHookOf } from './chat-hook.js';
24
+ import { renderImageGallery } from './message-images.js';
21
25
  import css from './WithdrawnDividerView.module.css';
22
26
  /** The withdrawal divider: collapsed marker row plus the expandable replay. */
23
- export function WithdrawnDividerView({ node, t, restoreMessage, renderMessageImages, ...standard }) {
27
+ export function WithdrawnDividerView({ node, t, restoreMessage, renderMessageImages, loadImage, renderFactorySlot, ...standard }) {
24
28
  const data = node.data;
25
- // The gallery closure is a required owner prop of every `conversation.chat.node`
26
- // renderer; widening it to `| undefined` keeps the degrade path explicit — a
27
- // composition without the attachment UI replays text only instead of throwing.
28
- const renderImages = renderMessageImages;
29
29
  const [expanded, setExpanded] = useState(false);
30
30
  const [entries, setEntries] = useState(null);
31
31
  const [busy, setBusy] = useState(false);
@@ -65,5 +65,5 @@ export function WithdrawnDividerView({ node, t, restoreMessage, renderMessageIma
65
65
  .catch(() => { setFailed(true); })
66
66
  .finally(() => { setBusy(false); });
67
67
  };
68
- 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(IconChevronDownOutlineMedium, {}) : _jsx(IconChevronRightOutlineMedium, {}) }), _jsx("span", { className: css.title, children: t('withdrawn.divider', { count }) }), restored && (_jsxs("span", { className: css.badge, children: [_jsx(IconUndoOutlineMedium, {}), 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') }), entry.text !== '' && _jsx("div", { className: css.textRun, children: entry.text }), renderImages !== undefined && entry.images.length > 0 && (_jsx("div", { className: css.entryImages, children: renderImages({ images: entry.images, align: 'end' }) }))] }, 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') })] })] }))] }));
68
+ 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(IconChevronDownOutlineMedium, {}) : _jsx(IconChevronRightOutlineMedium, {}) }), _jsx("span", { className: css.title, children: t('withdrawn.divider', { count }) }), restored && (_jsxs("span", { className: css.badge, children: [_jsx(IconUndoOutlineMedium, {}), 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') }), entry.text !== '' && _jsx("div", { className: css.textRun, children: entry.text }), entry.images.length > 0 && (_jsx("div", { className: css.entryImages, children: renderImageGallery(renderMessageImages, renderFactorySlot, loadImage, { images: entry.images, align: 'end' }) }))] }, 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') })] })] }))] }));
69
69
  }
@@ -9,6 +9,9 @@ const EMPTY_CHAT = {
9
9
  nodes: {
10
10
  get: () => undefined,
11
11
  source: () => ({ getSnapshot: () => undefined, subscribe: () => () => { } }),
12
+ // Host 0.2.1 added this required store face; the frozen empty snapshot
13
+ // reports every node as not belonging to the latest exchange.
14
+ bottomSource: () => ({ getSnapshot: () => false, subscribe: () => () => { } }),
12
15
  turnDataSource: () => ({ getSnapshot: () => [], subscribe: () => () => { } }),
13
16
  processSource: () => ({ getSnapshot: () => undefined, subscribe: () => () => { } }),
14
17
  values: () => [],
@@ -2,10 +2,16 @@
2
2
  * DOM hider: hide EVERY chat row whose node anchor falls inside a withdrawn
3
3
  * span — all node kinds, not just user messages — through a dynamic
4
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
5
+ * (ChatNodeSeat.tsx). Host 0.1.7-alpha.1's conversation groups wrap a turn's
6
+ * process rows in a Group seat whose CONTAINER carries the group key as its
7
+ * flow key (ChatGroupSeat.tsx): member rules hide the rows inside, but the
8
+ * container itself (the「已调用工具」fold header) would show through, so the
9
+ * rule set also covers fully-hidden groups' container keys plus the composite
10
+ * `["<nodeKey>","<groupPart>"]` row keys the plain node-key rules never
11
+ * reach. The projection itself offers no suppression seam (see README), so
12
+ * this works at the DOM layer. The attribute is an undocumented surface: the
13
+ * first time a non-empty rule set is computed, the hider probes for it (after
14
+ * a frame, so React has committed). A page restored onto a tab
9
15
  * where the chat rows are not mounted yet (the trace tab, or the chat area
10
16
  * still mounting) makes that first probe miss, so a miss does NOT disable
11
17
  * the hider outright: it enters a bounded retry — a MutationObserver waits
@@ -20,6 +26,7 @@
20
26
  */
21
27
  import type { Context } from '@deepseek-ai/cordis';
22
28
  import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/client';
29
+ import type { ConversationGroupedView } from '@deepseek-ai/dsh-client-ui-conversation/client';
23
30
  /**
24
31
  * Flow keys of every chat node inside a hidden span. The withdrawal divider
25
32
  * itself (`message-tools-withdrawn`, anchored at the span's exclusive end) is
@@ -34,6 +41,21 @@ import type { ChatConversationViewNode } from '@deepseek-ai/dsh-client-ui-chat/c
34
41
  * @returns the flow keys to hide, in node order.
35
42
  */
36
43
  export declare function hiddenFlowKeys(nodes: readonly ChatConversationViewNode[], ranges?: readonly number[]): string[];
44
+ /**
45
+ * Flow keys the grouped rendering (host 0.1.7-alpha.1's conversation groups)
46
+ * adds on top of {@link hiddenFlowKeys}. A Group seat's container row carries
47
+ * the GROUP key as its `data-chat-flow-key`, so member rules leave the fold
48
+ * header visible: hide the container when every member's node key is hidden
49
+ * (a group straddling a span boundary keeps its header for the visible
50
+ * members). A member's composite groupPart row (`["<nodeKey>","<part>"]`,
51
+ * ChatNodeSeat — e.g. the reasoning pane) is never reached by the plain
52
+ * node-key rule either, so hidden members contribute those keys on top,
53
+ * whether or not their group hides as a whole.
54
+ * @param view - the chat target's grouped reader (undefined on pre-group hosts).
55
+ * @param hiddenNodeKeys - node keys the plain rules already hide.
56
+ * @returns additional flow keys to hide, in root-entry order.
57
+ */
58
+ export declare function hiddenGroupFlowKeys(view: ConversationGroupedView<unknown> | undefined, hiddenNodeKeys: ReadonlySet<string>): string[];
37
59
  /**
38
60
  * Render the display rules for one rule set.
39
61
  * @param keys - flow keys to hide.
@@ -53,7 +75,8 @@ export interface DomHiderOptions {
53
75
  }
54
76
  /**
55
77
  * 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.
78
+ * session changes, rewritten only when the span set, the node count, or the
79
+ * hidden group keys move.
57
80
  * @param ctx - client root context (reads the sessions service).
58
81
  * @param options - probe retry tuning.
59
82
  * @returns disposer removing the stylesheet and every subscription.
@@ -49,6 +49,64 @@ export function hiddenFlowKeys(nodes, ranges) {
49
49
  }
50
50
  return keys;
51
51
  }
52
+ /**
53
+ * Flow keys the grouped rendering (host 0.1.7-alpha.1's conversation groups)
54
+ * adds on top of {@link hiddenFlowKeys}. A Group seat's container row carries
55
+ * the GROUP key as its `data-chat-flow-key`, so member rules leave the fold
56
+ * header visible: hide the container when every member's node key is hidden
57
+ * (a group straddling a span boundary keeps its header for the visible
58
+ * members). A member's composite groupPart row (`["<nodeKey>","<part>"]`,
59
+ * ChatNodeSeat — e.g. the reasoning pane) is never reached by the plain
60
+ * node-key rule either, so hidden members contribute those keys on top,
61
+ * whether or not their group hides as a whole.
62
+ * @param view - the chat target's grouped reader (undefined on pre-group hosts).
63
+ * @param hiddenNodeKeys - node keys the plain rules already hide.
64
+ * @returns additional flow keys to hide, in root-entry order.
65
+ */
66
+ export function hiddenGroupFlowKeys(view, hiddenNodeKeys) {
67
+ if (view === undefined || hiddenNodeKeys.size === 0)
68
+ return [];
69
+ const keys = [];
70
+ for (const entry of view.entries) {
71
+ if (entry.kind !== 'group')
72
+ continue;
73
+ const members = view.groupSource(entry.key).getSnapshot()?.members ?? [];
74
+ if (members.length === 0)
75
+ continue;
76
+ let hidden = 0;
77
+ for (const member of members) {
78
+ if (!hiddenNodeKeys.has(member.key))
79
+ continue;
80
+ hidden += 1;
81
+ if (member.groupPart !== undefined && member.groupPart !== 'response') {
82
+ keys.push(JSON.stringify([member.key, member.groupPart]));
83
+ }
84
+ }
85
+ if (hidden === members.length)
86
+ keys.push(entry.key);
87
+ }
88
+ return keys;
89
+ }
90
+ /**
91
+ * The chat target's grouped reader on hosts with conversation groups
92
+ * (0.1.7-alpha.1); earlier hosts carry no `views.grouped` and degrade to
93
+ * undefined — their ungrouped rows are covered by the plain node-key rules.
94
+ * @param ctx - client root context.
95
+ * @param sessionId - owning session.
96
+ * @returns the grouped reader, or undefined when the host or session lacks it.
97
+ */
98
+ function groupedChatViewOf(ctx, sessionId) {
99
+ const uiConversation = ctx.get('uiConversation');
100
+ if (uiConversation === undefined)
101
+ return undefined;
102
+ try {
103
+ const views = uiConversation.binding(sessionId).snapshot.getSnapshot().views;
104
+ return typeof views.grouped === 'function' ? views.grouped('chat') : undefined;
105
+ }
106
+ catch {
107
+ return undefined; // unknown session — the caller's undefined branch
108
+ }
109
+ }
52
110
  /**
53
111
  * Render the display rules for one rule set.
54
112
  * @param keys - flow keys to hide.
@@ -61,7 +119,8 @@ export function renderHiderRules(keys) {
61
119
  }
62
120
  /**
63
121
  * Install the hider: one shared style element, rebound whenever the selected
64
- * session changes, rewritten only when the span set or the node count moves.
122
+ * session changes, rewritten only when the span set, the node count, or the
123
+ * hidden group keys move.
65
124
  * @param ctx - client root context (reads the sessions service).
66
125
  * @param options - probe retry tuning.
67
126
  * @returns disposer removing the stylesheet and every subscription.
@@ -89,11 +148,19 @@ export function installDomHider(ctx, options = {}) {
89
148
  latest = chat;
90
149
  const nodes = chat.nodes.values();
91
150
  const ranges = foldHiddenRanges(nodes);
92
- const nextSignature = `${ranges.join(',')}:${nodes.length}`;
151
+ const nodeKeys = hiddenFlowKeys(nodes, ranges);
152
+ // Group membership only moves with node changes, and the assembler runs
153
+ // group definitions inside the same publish this snapshot reports, so the
154
+ // grouped reader is current here; its keys join the signature so a
155
+ // grouping-only delta still rewrites the rules.
156
+ const groupKeys = activeSession === undefined
157
+ ? []
158
+ : hiddenGroupFlowKeys(groupedChatViewOf(ctx, activeSession), new Set(nodeKeys));
159
+ const nextSignature = `${ranges.join(',')}:${nodes.length}:${groupKeys.join(',')}`;
93
160
  if (nextSignature === signature)
94
161
  return;
95
162
  signature = nextSignature;
96
- style.textContent = renderHiderRules(hiddenFlowKeys(nodes, ranges));
163
+ style.textContent = renderHiderRules([...nodeKeys, ...groupKeys]);
97
164
  };
98
165
  const probeFound = () => document.querySelector('[data-chat-flow-key]') !== null;
99
166
  const stopRetry = () => {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Long user messages fold: a message past {@link FOLD_CHARS} characters or
3
+ * {@link FOLD_LINES} lines starts clamped, except the session's newest user
4
+ * message (what the human just sent stays readable while it is answered; it
5
+ * folds once the next one arrives). The human's own expand/collapse wins and
6
+ * is remembered per message in browser storage — a per-viewer convenience:
7
+ * unreadable storage only means the default applies.
8
+ */
9
+ import type { ChatSlice } from './chat-hook.ts';
10
+ /** Characters past which a message folds (roughly ten rendered lines). */
11
+ export declare const FOLD_CHARS = 600;
12
+ /** Lines past which a message folds even when short in characters. */
13
+ export declare const FOLD_LINES = 12;
14
+ /** Whether a message's text is long enough to fold. */
15
+ export declare function foldable(text: string): boolean;
16
+ /** Selector: the newest user message's seq (-1 when none). Module-level so uSES memoization holds. */
17
+ export declare function selectNewestUserSeq(chat: ChatSlice): number;
18
+ /** The human's remembered expand/collapse choices, by message seq. */
19
+ export declare const foldChoices: {
20
+ read(sessionId: string): Record<string, boolean>;
21
+ write(sessionId: string, seq: number, expanded: boolean): void;
22
+ };
23
+ //# sourceMappingURL=fold.d.ts.map
@@ -0,0 +1,44 @@
1
+ /** Characters past which a message folds (roughly ten rendered lines). */
2
+ export const FOLD_CHARS = 600;
3
+ /** Lines past which a message folds even when short in characters. */
4
+ export const FOLD_LINES = 12;
5
+ /** The kinds that render as the human's own message bubble. */
6
+ const USER_KINDS = new Set(['user', 'steering', 'message-tools-edited', 'message-tools-restored']);
7
+ /** Whether a message's text is long enough to fold. */
8
+ export function foldable(text) {
9
+ return text.length > FOLD_CHARS || text.split('\n').length > FOLD_LINES;
10
+ }
11
+ /** Selector: the newest user message's seq (-1 when none). Module-level so uSES memoization holds. */
12
+ export function selectNewestUserSeq(chat) {
13
+ let newest = -1;
14
+ for (const node of chat.nodes.values()) {
15
+ const seq = node.data?.seq;
16
+ if (USER_KINDS.has(node.kind) && typeof seq === 'number' && seq > newest)
17
+ newest = seq;
18
+ }
19
+ return newest;
20
+ }
21
+ const KEY_PREFIX = 'dsh-message-tools:fold:';
22
+ /** Choices kept per session (newest messages win): bounds the stored map. */
23
+ const KEEP = 200;
24
+ /** The human's remembered expand/collapse choices, by message seq. */
25
+ export const foldChoices = {
26
+ read(sessionId) {
27
+ try {
28
+ const raw = globalThis.localStorage?.getItem(KEY_PREFIX + sessionId);
29
+ const parsed = raw == null ? undefined : JSON.parse(raw);
30
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ },
36
+ write(sessionId, seq, expanded) {
37
+ try {
38
+ const choices = { ...foldChoices.read(sessionId), [String(seq)]: expanded };
39
+ const kept = Object.keys(choices).map(Number).sort((a, b) => b - a).slice(0, KEEP);
40
+ globalThis.localStorage?.setItem(KEY_PREFIX + sessionId, JSON.stringify(Object.fromEntries(kept.map(key => [key, choices[key]]))));
41
+ }
42
+ catch { /* a convenience: never fail the view */ }
43
+ },
44
+ };
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Generated by scripts/sync-icon-artwork.mts from @deepseek-ai/dsh-client-ui-primitives
3
- * (v0.1.7-rc.1, harness 46a7f68b09) — do not edit by hand. The 0.1.5 and rc.1 host lines
3
+ * (v0.2.1-rc.1, harness 1600d8a1e1) — do not edit by hand. The 0.1.5 and rc.1 host lines
4
4
  * share no icon export name, so the rc.1 artwork is inlined here: pure SVG,
5
5
  * no host runtime identity, identical on every line. Re-run the generator
6
6
  * after adding an icon import or bumping the harness checkout.
@@ -176,6 +176,7 @@ export async function apply(ctx) {
176
176
  withdraw: () => withdraw(targetSeq),
177
177
  }, ctx.sessions.binding(sessionId)?.session.getSnapshot().running === true),
178
178
  backfillDraft: (text) => { backfill(sessionId, text); },
179
+ foldScope: sessionId,
179
180
  modelsAvailable: directory !== undefined,
180
181
  loadModels: () => {
181
182
  // Load failures surface on the directory store itself.
@@ -21,6 +21,8 @@ export declare const zh: {
21
21
  'withdrawn.restoreFailed': string;
22
22
  'error.edit': string;
23
23
  'edited.badge': string;
24
+ 'fold.expand': string;
25
+ 'fold.collapse': string;
24
26
  'error.withdraw': string;
25
27
  'model.aria': string;
26
28
  'model.menuAria': string;
@@ -65,6 +67,8 @@ export declare const en: {
65
67
  'withdrawn.restoreFailed': string;
66
68
  'error.edit': string;
67
69
  'edited.badge': string;
70
+ 'fold.expand': string;
71
+ 'fold.collapse': string;
68
72
  'error.withdraw': string;
69
73
  'model.aria': string;
70
74
  'model.menuAria': string;
@@ -21,6 +21,8 @@ export const zh = {
21
21
  'withdrawn.restoreFailed': '恢复失败,请重试',
22
22
  'error.edit': '编辑失败,请重试',
23
23
  'edited.badge': '已编辑',
24
+ 'fold.expand': '展开全部',
25
+ 'fold.collapse': '收起',
24
26
  'error.withdraw': '撤回失败,请重试',
25
27
  'model.aria': '切换模型,当前 {model}',
26
28
  'model.menuAria': '模型与推理等级',
@@ -57,6 +59,8 @@ export const en = {
57
59
  'withdrawn.restoreFailed': 'Could not restore; please retry',
58
60
  'error.edit': 'Could not apply the edit; please retry',
59
61
  'edited.badge': 'Edited',
62
+ 'fold.expand': 'Show all',
63
+ 'fold.collapse': 'Collapse',
60
64
  'error.withdraw': 'Could not withdraw the message; please retry',
61
65
  'model.aria': 'Switch model, current {model}',
62
66
  'model.menuAria': 'Model and reasoning effort',
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cross-host message-image gallery dispatch shared by the two chat-node views.
3
+ * Hosts before 0.2.1-alpha.3 pass a `renderMessageImages` owner prop with the
4
+ * session-authorized loader bound inside; 0.2.1-alpha.3 removed that prop when
5
+ * the images slot split into `conversation.chat.user.images` /
6
+ * `conversation.chat.message.images`, with the official gallery now behind the
7
+ * root-scoped `conversation.images.content` factory (ui-attachment) reached
8
+ * through the framework `renderFactorySlot` seat. The factory outlet renders
9
+ * nothing while the factory is unregistered, which matches the official
10
+ * omit-on-absence behavior of the unfilled images slots.
11
+ */
12
+ import type { ReactNode } from 'react';
13
+ import type { MessageImageLoader, MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client';
14
+ import type { RenderFactorySlot } from '@deepseek-ai/dsh-client-ui-slots';
15
+ /** The gallery input one view owns: the owner props minus the loader. */
16
+ export type MessageImagesInput = Omit<MessageImagesOwnerProps, 'loadImage'>;
17
+ /**
18
+ * Render one image group through the host's gallery surface. The legacy owner
19
+ * prop wins when present: on hosts that still pass it (before 0.2.1-alpha.3)
20
+ * the factory either has no seat (0.1.5) or is never registered, while the
21
+ * prop always carries a working gallery. On 0.2.1-alpha.3+ the prop is absent
22
+ * and the factory renders the official gallery.
23
+ * @param legacy - the pre-0.2.1 owner-prop closure, when the host passes it.
24
+ * @param renderFactorySlot - the framework factory seat.
25
+ * @param loadImage - the session-authorized loader from `ChatNodeOwnerProps`.
26
+ * @param input - image sources and layout hints.
27
+ * @returns the gallery occurrence, or null when the composition has none.
28
+ */
29
+ export declare function renderImageGallery(legacy: ((owner: MessageImagesInput) => ReactNode) | undefined, renderFactorySlot: RenderFactorySlot, loadImage: MessageImageLoader, input: MessageImagesInput): ReactNode;
30
+ //# sourceMappingURL=message-images.d.ts.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Render one image group through the host's gallery surface. The legacy owner
3
+ * prop wins when present: on hosts that still pass it (before 0.2.1-alpha.3)
4
+ * the factory either has no seat (0.1.5) or is never registered, while the
5
+ * prop always carries a working gallery. On 0.2.1-alpha.3+ the prop is absent
6
+ * and the factory renders the official gallery.
7
+ * @param legacy - the pre-0.2.1 owner-prop closure, when the host passes it.
8
+ * @param renderFactorySlot - the framework factory seat.
9
+ * @param loadImage - the session-authorized loader from `ChatNodeOwnerProps`.
10
+ * @param input - image sources and layout hints.
11
+ * @returns the gallery occurrence, or null when the composition has none.
12
+ */
13
+ export function renderImageGallery(legacy, renderFactorySlot, loadImage, input) {
14
+ if (legacy !== undefined)
15
+ return legacy(input);
16
+ return renderFactorySlot('conversation.images.content', { ...input, loadImage });
17
+ }
@@ -2,9 +2,24 @@
2
2
  * Slot-facing types of the message-tools client half: the injected action
3
3
  * face and the composed props of its two `conversation.chat.node` entries.
4
4
  */
5
+ import type { ReactNode } from 'react';
5
6
  import type { ModelSelection } from '@deepseek-ai/dsh-api-remotes/client';
6
7
  import type { ModelDirectoryState } from '@deepseek-ai/dsh-client-ui-model-selection/client';
7
- import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
8
+ import type { MessageImagesOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client';
9
+ import type { HostObservable, InjectFace, PropsLocale, PropsRenderFactories, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
10
+ /**
11
+ * The pre-0.2.1 image-gallery owner prop. Hosts before 0.2.1-alpha.3 pass a
12
+ * `renderMessageImages` closure in `ChatNodeOwnerProps` (the session-authorized
13
+ * loader is bound inside it); 0.2.1-alpha.3 removed the prop when the images
14
+ * slot split into `conversation.chat.user.images` /
15
+ * `conversation.chat.message.images` plus the root-scoped
16
+ * `conversation.images.content` factory. The views prefer this prop when the
17
+ * host still passes it and otherwise render through the factory seat
18
+ * (`PropsRenderFactories`), so one build serves both host lines.
19
+ */
20
+ export interface LegacyMessageImagesProp {
21
+ readonly renderMessageImages?: ((owner: Omit<MessageImagesOwnerProps, 'loadImage'>) => ReactNode) | undefined;
22
+ }
8
23
  /** Injected action face of the shadowed user-message entry. */
9
24
  export interface MessageToolsInjected {
10
25
  hooks: {
@@ -31,14 +46,16 @@ export interface MessageToolsInjected {
31
46
  * the same path.
32
47
  */
33
48
  backfillDraft: (text: string) => void;
49
+ /** The session id the fold choices are remembered under. */
50
+ foldScope: string;
34
51
  }
35
52
  /** Full props of the shadowed 'user'/'steering'/'message-tools-edited'/'message-tools-restored' renderer (all keys share it). */
36
- export type UserMessageViewProps = PropsRuntime<'conversation.chat.node', 'user' | 'steering' | 'message-tools-edited' | 'message-tools-restored'> & InjectFace<MessageToolsInjected> & PropsLocale<'message-tools'>;
53
+ export type UserMessageViewProps = PropsRuntime<'conversation.chat.node', 'user' | 'steering' | 'message-tools-edited' | 'message-tools-restored'> & PropsRenderFactories & InjectFace<MessageToolsInjected> & PropsLocale<'message-tools'> & LegacyMessageImagesProp;
37
54
  /** Injected action face of the withdrawal divider entry. */
38
55
  export interface WithdrawnDividerInjected {
39
56
  /** Restore the span's withdrawn user message as a fresh tail message. */
40
57
  restoreMessage: (targetSeq: number) => Promise<void>;
41
58
  }
42
59
  /** Full props of the withdrawal divider renderer. */
43
- export type WithdrawnDividerViewProps = PropsRuntime<'conversation.chat.node', 'message-tools-withdrawn'> & InjectFace<WithdrawnDividerInjected> & PropsLocale<'message-tools'>;
60
+ export type WithdrawnDividerViewProps = PropsRuntime<'conversation.chat.node', 'message-tools-withdrawn'> & PropsRenderFactories & InjectFace<WithdrawnDividerInjected> & PropsLocale<'message-tools'> & LegacyMessageImagesProp;
44
61
  //# sourceMappingURL=slots.d.ts.map
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "cordis",
8
8
  "messages"
9
9
  ],
10
- "version": "0.3.3",
10
+ "version": "0.3.5",
11
11
  "type": "module",
12
12
  "main": "lib/index.js",
13
13
  "types": "lib/types/index.d.ts",
@@ -16,10 +16,6 @@
16
16
  "types": "./lib/types/index.d.ts",
17
17
  "default": "./lib/index.js"
18
18
  },
19
- "./invariant": {
20
- "types": "./lib/types/invariant.d.ts",
21
- "default": "./lib/invariant.js"
22
- },
23
19
  "./client": {
24
20
  "types": "./lib/types/client/index.d.ts",
25
21
  "default": "./lib/client.js"
@@ -55,7 +51,7 @@
55
51
  },
56
52
  "compat": {
57
53
  "minHost": "0.1.5-rc.1",
58
- "verifiedHost": "0.1.7-rc.2"
54
+ "verifiedHost": "0.2.1-rc.1"
59
55
  }
60
56
  },
61
57
  "license": "MIT",
@@ -64,16 +60,15 @@
64
60
  },
65
61
  "peerDependencies": {
66
62
  "@deepseek-ai/cordis": "^4.0.1",
67
- "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6 || ^0.2.0-rc.1",
68
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.1",
69
- "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6 || ^0.2.0-rc.1",
70
- "@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6 || ^0.2.0-rc.1",
71
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6 || ^0.2.0-rc.1",
72
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1",
73
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6 || ^0.2.0-rc.1",
74
- "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1",
75
- "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1",
76
- "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.2.0-rc.1",
63
+ "@deepseek-ai/dsh-api-remotes": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
64
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
65
+ "@deepseek-ai/dsh-client-ui-conversation": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
66
+ "@deepseek-ai/dsh-client-ui-model-selection": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
67
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
68
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
69
+ "@deepseek-ai/dsh-llm": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
70
+ "@deepseek-ai/dsh-session": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
71
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6 || ^0.2.0-rc.1 || ^0.2.1-rc.1",
77
72
  "react": "^18.2.0"
78
73
  },
79
74
  "files": [
@@ -86,5 +81,8 @@
86
81
  "LICENSE",
87
82
  "locale/*.json",
88
83
  "CHANGELOG.md"
89
- ]
84
+ ],
85
+ "engines": {
86
+ "dsh": ">=0.1.5-rc.1 <0.3.0-0"
87
+ }
90
88
  }