dsh-better-display 0.3.8 → 0.3.9
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/CHANGELOG.md +11 -0
- package/DESIGN.md +43 -3
- package/README.en.md +9 -1
- package/README.md +9 -1
- package/docs/dependency-update-policy.md +9 -0
- package/docs/extension-points.md +172 -0
- package/docs/official-rendering-bridge.md +48 -19
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1417 -490
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +378 -144
- package/lib/types/client/AssistantNode.d.ts +20 -0
- package/lib/types/client/AssistantNode.d.ts.map +1 -0
- package/lib/types/client/AssistantNode.js +46 -0
- package/lib/types/client/AssistantNode.js.map +1 -0
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +33 -10
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/ChoreographedFlow.d.ts.map +1 -1
- package/lib/types/client/ChoreographedFlow.js +2 -0
- package/lib/types/client/ChoreographedFlow.js.map +1 -1
- package/lib/types/client/ClosedProcessSummary.d.ts.map +1 -1
- package/lib/types/client/ClosedProcessSummary.js +3 -1
- package/lib/types/client/ClosedProcessSummary.js.map +1 -1
- package/lib/types/client/McpAppFrame.d.ts.map +1 -1
- package/lib/types/client/McpAppFrame.js +5 -11
- package/lib/types/client/McpAppFrame.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +17 -3
- package/lib/types/client/OfficialContent.d.ts.map +1 -1
- package/lib/types/client/OfficialContent.js +33 -7
- package/lib/types/client/OfficialContent.js.map +1 -1
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +43 -71
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/SettingsSection.js +0 -5
- package/lib/types/client/SettingsSection.js.map +1 -1
- package/lib/types/client/StickyLane.d.ts +12 -0
- package/lib/types/client/StickyLane.d.ts.map +1 -1
- package/lib/types/client/StickyLane.js +78 -2
- package/lib/types/client/StickyLane.js.map +1 -1
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/index.d.ts +3 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +6 -0
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/live-turn.d.ts +17 -5
- package/lib/types/client/live-turn.d.ts.map +1 -1
- package/lib/types/client/live-turn.js +106 -76
- package/lib/types/client/live-turn.js.map +1 -1
- package/lib/types/client/motion.d.ts +26 -2
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +136 -10
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +9 -0
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +22 -7
- package/lib/types/client/official-slots.js.map +1 -1
- package/lib/types/client/projection.d.ts +15 -2
- package/lib/types/client/projection.d.ts.map +1 -1
- package/lib/types/client/projection.js +16 -4
- package/lib/types/client/projection.js.map +1 -1
- package/lib/types/client/rewind-cut.d.ts +131 -0
- package/lib/types/client/rewind-cut.d.ts.map +1 -0
- package/lib/types/client/rewind-cut.js +321 -0
- package/lib/types/client/rewind-cut.js.map +1 -0
- package/lib/types/client/text-transform.d.ts +93 -0
- package/lib/types/client/text-transform.d.ts.map +1 -0
- package/lib/types/client/text-transform.js +183 -0
- package/lib/types/client/text-transform.js.map +1 -0
- package/lib/types/client/timeline.d.ts.map +1 -1
- package/lib/types/client/timeline.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +9 -2
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +42 -2
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/turn-slots.d.ts +29 -0
- package/lib/types/client/turn-slots.d.ts.map +1 -0
- package/lib/types/client/turn-slots.js +62 -0
- package/lib/types/client/turn-slots.js.map +1 -0
- package/lib/types/client/types.d.ts +18 -0
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/dsh-better-display.d.ts +4 -1
- package/lib/types/dsh-better-display.d.ts.map +1 -1
- package/lib/types/dsh-better-display.js +56 -7
- package/lib/types/dsh-better-display.js.map +1 -1
- package/lib/types/flags.d.ts +7 -0
- package/lib/types/flags.d.ts.map +1 -0
- package/lib/types/flags.js +37 -0
- package/lib/types/flags.js.map +1 -0
- package/lib/types/log.d.ts +17 -0
- package/lib/types/log.d.ts.map +1 -0
- package/lib/types/log.js +122 -0
- package/lib/types/log.js.map +1 -0
- package/lib/types/metrics.d.ts +18 -0
- package/lib/types/metrics.d.ts.map +1 -0
- package/lib/types/metrics.js +35 -0
- package/lib/types/metrics.js.map +1 -0
- package/package.json +36 -5
- package/scripts/changelog.mjs +20 -0
- package/scripts/check-agents-md.mjs +67 -0
- package/scripts/check-bundle-size.mjs +38 -0
- package/scripts/check-dead-flags.mjs +55 -0
- package/scripts/check-todo-debt.mjs +69 -0
- package/scripts/security-audit-report.mjs +92 -0
- package/scripts/verify-commentary-visible.mjs +627 -0
- package/src/client/AssistantNode.tsx +72 -0
- package/src/client/Blocks.tsx +49 -10
- package/src/client/ChoreographedFlow.tsx +3 -1
- package/src/client/ClosedProcessSummary.tsx +3 -1
- package/src/client/McpAppFrame.tsx +5 -13
- package/src/client/OfficialContent.tsx +47 -9
- package/src/client/Reader.module.css +68 -18
- package/src/client/Reader.tsx +36 -76
- package/src/client/SettingsSection.tsx +1 -7
- package/src/client/StickyLane.tsx +81 -2
- package/src/client/ToolActivity.tsx +1 -1
- package/src/client/index.tsx +8 -1
- package/src/client/live-turn.ts +100 -69
- package/src/client/motion.tsx +135 -7
- package/src/client/official-slots.tsx +23 -7
- package/src/client/projection.ts +16 -4
- package/src/client/rewind-cut.ts +340 -0
- package/src/client/text-transform.ts +265 -0
- package/src/client/timeline.ts +0 -2
- package/src/client/tool-activity.ts +49 -2
- package/src/client/turn-slots.ts +80 -0
- package/src/client/types.ts +19 -0
- package/src/dsh-better-display.ts +58 -7
- package/src/flags.ts +40 -0
- package/src/log.ts +137 -0
- package/src/metrics.ts +50 -0
- package/tests/browser-commentary.tsx +191 -0
- package/tests/chat-flow-hook.test.ts +1 -1
- package/tests/cloud-gate.test.ts +1 -1
- package/tests/context-tool-update.test.ts +1 -1
- package/tests/conversation-scroll.test.ts +1 -1
- package/tests/deliverables.test.ts +1 -1
- package/tests/entry.test.ts +1 -1
- package/tests/fixtures/reader-lane-owners.tsx +46 -0
- package/tests/fixtures/reader-official-users.tsx +64 -0
- package/tests/fixtures/reader-theme-overlap.html +80 -0
- package/tests/fixtures/reader-user-bubble.tsx +222 -0
- package/tests/flags.test.ts +66 -0
- package/tests/fold-choreography.test.ts +32 -8
- package/tests/fold-intensity.test.ts +11 -6
- package/tests/footer-precedence.test.ts +1 -1
- package/tests/history-word-regression.test.ts +1 -1
- package/tests/live-turn.test.ts +125 -54
- package/tests/log.test.ts +99 -0
- package/tests/mcp-apps.test.ts +1 -1
- package/tests/message-chrome.test.ts +1 -1
- package/tests/metrics.test.ts +52 -0
- package/tests/official-slots.test.ts +41 -2
- package/tests/open-file.test.ts +1 -1
- package/tests/pending-submission.test.ts +12 -1
- package/tests/pinned-selection.test.ts +16 -0
- package/tests/platform-media.test.ts +1 -1
- package/tests/projection.test.ts +66 -3
- package/tests/reader-glass-css.test.ts +1 -1
- package/tests/reader-official-users.test.ts +11 -1
- package/tests/reader-sticky-surface.test.ts +377 -0
- package/tests/reader-theme-lanes.test.ts +54 -0
- package/tests/reader-theme-overlap.test.ts +151 -0
- package/tests/reader-user-bubble.test.ts +137 -0
- package/tests/reasoning-follow.test.ts +1 -1
- package/tests/retiring-content.test.ts +98 -6
- package/tests/reveal.test.ts +3 -3
- package/tests/rewind-cut.test.ts +1090 -0
- package/tests/settings-copy.test.ts +1 -1
- package/tests/skill-status.test.ts +1 -1
- package/tests/stock-install.test.ts +1 -1
- package/tests/stream-buffer.test.ts +1 -1
- package/tests/styles.test.ts +30 -3
- package/tests/terminal-input-preview.test.ts +1 -1
- package/tests/text-transform.test.ts +272 -0
- package/tests/timeline.test.ts +1 -1
- package/tests/tool-clock.test.ts +1 -1
- package/tests/tool-diff.test.ts +1 -1
- package/tests/waiting-clock.test.ts +1 -1
- package/tests/word-timeline.test.ts +1 -1
package/src/client/motion.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState }
|
|
|
2
2
|
import type { ReactNode, RefObject } from 'react';
|
|
3
3
|
import css from './Reader.module.css';
|
|
4
4
|
import { StreamMotionContext } from './streaming.js';
|
|
5
|
+
import { reconcileTurnSlots, type TurnSlot } from './turn-slots.js';
|
|
5
6
|
|
|
6
7
|
const EASING = 'cubic-bezier(.22,1,.36,1)';
|
|
7
8
|
|
|
@@ -16,7 +17,7 @@ export function useMotionAllowed(enabled: boolean): boolean {
|
|
|
16
17
|
return enabled && !reduced;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function usePinnedSelection(root: RefObject<HTMLElement>, selector = '[data-reader-answer], [data-reader-process]'): readonly string[] {
|
|
20
|
+
export function usePinnedSelection(root: RefObject<HTMLElement>, selector = '[data-reader-answer], [data-reader-body], [data-reader-process]'): readonly string[] {
|
|
20
21
|
const [keys, setKeys] = useState<readonly string[]>([]);
|
|
21
22
|
useEffect(() => {
|
|
22
23
|
const update = () => {
|
|
@@ -89,7 +90,7 @@ export function StatusText({ text, motion, shimmer = false }: { text: string; mo
|
|
|
89
90
|
|
|
90
91
|
export function Disclosure({ open, onChange, label, controls, buttonRef, ariaLabel }: {
|
|
91
92
|
open: boolean; onChange: (value: boolean) => void; label: ReactNode;
|
|
92
|
-
controls?: string; buttonRef
|
|
93
|
+
controls?: string; buttonRef?: RefObject<HTMLButtonElement>;
|
|
93
94
|
ariaLabel?: string;
|
|
94
95
|
}) {
|
|
95
96
|
const name = ariaLabel ?? '思考与过程';
|
|
@@ -170,12 +171,16 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
|
|
|
170
171
|
}
|
|
171
172
|
|
|
172
173
|
/** Retire only narration that was actually visible; historical rows stay folded. */
|
|
173
|
-
export function RetiringContent({ visible, children }: { visible: boolean; children: ReactNode }) {
|
|
174
|
+
export function RetiringContent({ visible, children, onRetired }: { visible: boolean; children: ReactNode; onRetired?: () => void }) {
|
|
174
175
|
const { enabled } = useContext(StreamMotionContext);
|
|
175
176
|
const root = useRef<HTMLDivElement>(null);
|
|
176
177
|
const animation = useRef<Animation | null>(null);
|
|
177
178
|
const [present, setPresent] = useState(visible);
|
|
178
179
|
const [focusHeld, setFocusHeld] = useState(false);
|
|
180
|
+
// Held in a ref so a caller passing an inline closure does not restart the
|
|
181
|
+
// collapse every render, and so the settle path always sees the latest one.
|
|
182
|
+
const retired = useRef(onRetired);
|
|
183
|
+
retired.current = onRetired;
|
|
179
184
|
useLayoutEffect(() => {
|
|
180
185
|
const element = root.current;
|
|
181
186
|
if (visible) {
|
|
@@ -184,18 +189,49 @@ export function RetiringContent({ visible, children }: { visible: boolean; child
|
|
|
184
189
|
return;
|
|
185
190
|
}
|
|
186
191
|
if (!element) return;
|
|
187
|
-
|
|
188
|
-
|
|
192
|
+
// Reduced motion and a hidden page settle immediately. Focus may defer a
|
|
193
|
+
// collapse that will actually play; it must not pin a reduced-motion rewind.
|
|
194
|
+
// Move it to a surviving turn or the reading column first, or the browser
|
|
195
|
+
// drops it on the document when this subtree unmounts.
|
|
196
|
+
let retiredNow = false;
|
|
197
|
+
const retireNow = () => {
|
|
198
|
+
if (retiredNow) return;
|
|
199
|
+
retiredNow = true;
|
|
200
|
+
animation.current?.cancel(); animation.current = null;
|
|
201
|
+
focusSurvivesRetire(element);
|
|
202
|
+
setPresent(false); retired.current?.();
|
|
203
|
+
};
|
|
204
|
+
if (!enabled || document.hidden) { retireNow(); return; }
|
|
205
|
+
// The retiring-content harness stubs document without addEventListener.
|
|
206
|
+
const canListen = typeof document.addEventListener === 'function';
|
|
207
|
+
const holdsFocus = element.contains(document.activeElement);
|
|
208
|
+
if (holdsFocus || focusHeld) {
|
|
209
|
+
if (holdsFocus) setFocusHeld(true);
|
|
210
|
+
// Focus defers only a collapse that will play. `document.hidden` is not
|
|
211
|
+
// state, so a page hidden while focus waits must retire from here.
|
|
212
|
+
const onHiddenWhileHeld = () => { if (document.hidden) retireNow(); };
|
|
213
|
+
if (canListen) document.addEventListener('visibilitychange', onHiddenWhileHeld);
|
|
214
|
+
return () => { if (canListen) document.removeEventListener('visibilitychange', onHiddenWhileHeld); };
|
|
215
|
+
}
|
|
189
216
|
const from = element.getBoundingClientRect().height;
|
|
190
217
|
animation.current?.cancel(); animation.current = null;
|
|
191
|
-
if (
|
|
218
|
+
if (from < 1) { setPresent(false); retired.current?.(); return; }
|
|
192
219
|
const next = element.animate([{ height: `${from}px`, opacity: 1 }, { height: '0px', opacity: 0 }], { duration: 220, easing: EASING, fill: 'both' });
|
|
193
220
|
animation.current = next;
|
|
194
|
-
|
|
221
|
+
let settled = false;
|
|
222
|
+
const settle = () => {
|
|
223
|
+
if (settled || animation.current !== next) return;
|
|
224
|
+
settled = true;
|
|
225
|
+
animation.current = null; next.cancel(); setPresent(false); retired.current?.();
|
|
226
|
+
};
|
|
195
227
|
next.onfinish = settle;
|
|
228
|
+
// #60: a stalled WAAPI finish must not leave the row mounted.
|
|
196
229
|
const deadline = window.setTimeout(settle, 220 + 240);
|
|
230
|
+
const onHide = () => { if (document.hidden) settle(); };
|
|
231
|
+
if (canListen) document.addEventListener('visibilitychange', onHide);
|
|
197
232
|
return () => {
|
|
198
233
|
window.clearTimeout(deadline);
|
|
234
|
+
if (canListen) document.removeEventListener('visibilitychange', onHide);
|
|
199
235
|
if (animation.current === next) animation.current = null;
|
|
200
236
|
next.cancel();
|
|
201
237
|
};
|
|
@@ -208,6 +244,98 @@ export function RetiringContent({ visible, children }: { visible: boolean; child
|
|
|
208
244
|
</div>;
|
|
209
245
|
}
|
|
210
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Keeps a departing turn mounted for the length of its own collapse.
|
|
249
|
+
*
|
|
250
|
+
* A rewind removes turns from the visible order, and unmounting them outright
|
|
251
|
+
* snapped the transcript: every withdrawn row disappeared in one frame with
|
|
252
|
+
* nothing to scroll. Instead each departing turn is retained, rendered with
|
|
253
|
+
* `visible={false}` so it animates to zero height, and released only once the
|
|
254
|
+
* collapse has settled — so the reader watches the rows fold away and the rows
|
|
255
|
+
* below glide up into their place.
|
|
256
|
+
*
|
|
257
|
+
* Live slots follow the incoming group order, so older history stays above the
|
|
258
|
+
* tail. A collapsing turn stays next to the live neighbour that preceded it;
|
|
259
|
+
* appending it would jump it to the bottom for the length of the animation.
|
|
260
|
+
* Retention is bounded by the animation itself: `onRetired`
|
|
261
|
+
* drops the slot, so a departing turn lives for one collapse and no longer —
|
|
262
|
+
* and a key that comes back later is re-added as a live slot.
|
|
263
|
+
*/
|
|
264
|
+
export function RetiringTurns<T extends { readonly key: string }>({ groups, render }: {
|
|
265
|
+
groups: readonly T[];
|
|
266
|
+
render: (group: T) => ReactNode;
|
|
267
|
+
}) {
|
|
268
|
+
const [slots, setSlots] = useState<readonly TurnSlot<T>[]>(() =>
|
|
269
|
+
groups.map((group) => ({ group, live: true })),
|
|
270
|
+
);
|
|
271
|
+
// Reconcile after every commit rather than during render: a key that left the
|
|
272
|
+
// order becomes `live: false` and keeps its index, which is what lets the
|
|
273
|
+
// collapse play in place instead of jumping the turn to the bottom.
|
|
274
|
+
useLayoutEffect(() => {
|
|
275
|
+
setSlots(current => reconcileTurnSlots(current, groups));
|
|
276
|
+
}, [groups]);
|
|
277
|
+
return (
|
|
278
|
+
<>
|
|
279
|
+
{slots.map((slot) => (
|
|
280
|
+
<RetiringContent
|
|
281
|
+
key={slot.group.key}
|
|
282
|
+
visible={slot.live}
|
|
283
|
+
onRetired={() => setSlots((current) => current.filter((candidate) =>
|
|
284
|
+
candidate.live || candidate.group.key !== slot.group.key))}
|
|
285
|
+
>
|
|
286
|
+
{render(slot.group)}
|
|
287
|
+
</RetiringContent>
|
|
288
|
+
))}
|
|
289
|
+
</>
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A turn that is still on screen, not one this commit is about to unmount. */
|
|
294
|
+
function survivingTurn(node: Element): HTMLElement | null {
|
|
295
|
+
if (!(node instanceof HTMLElement) || node.dataset.readerRetiring === 'retiring') return null;
|
|
296
|
+
const turn = node.hasAttribute('data-reader-turn') ? node : node.querySelector<HTMLElement>('[data-reader-turn]');
|
|
297
|
+
if (turn === null) return null;
|
|
298
|
+
const retiring = turn.closest('[data-reader-retiring]');
|
|
299
|
+
if (retiring instanceof HTMLElement && retiring.dataset.readerRetiring === 'retiring') return null;
|
|
300
|
+
return turn;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Where focus goes when cut content is removed without an animation.
|
|
305
|
+
* Prefer the nearest turn that is still live. Otherwise the reading column
|
|
306
|
+
* (or the host scrollport) so focus does not fall through to the document.
|
|
307
|
+
*/
|
|
308
|
+
function retireFocusTarget(element: HTMLElement): HTMLElement | null {
|
|
309
|
+
const parent = element.parentElement;
|
|
310
|
+
if (parent) {
|
|
311
|
+
const siblings = [...parent.children];
|
|
312
|
+
const index = siblings.indexOf(element);
|
|
313
|
+
for (let cursor = index - 1; cursor >= 0; cursor--) {
|
|
314
|
+
const turn = survivingTurn(siblings[cursor]!);
|
|
315
|
+
if (turn) return turn;
|
|
316
|
+
}
|
|
317
|
+
for (let cursor = index + 1; cursor < siblings.length; cursor++) {
|
|
318
|
+
const turn = survivingTurn(siblings[cursor]!);
|
|
319
|
+
if (turn) return turn;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const column = element.closest<HTMLElement>('[data-chat-flow]');
|
|
323
|
+
if (column && !element.contains(column)) return column;
|
|
324
|
+
const scroll = element.closest<HTMLElement>('[data-conversation-scroll]');
|
|
325
|
+
if (scroll && !element.contains(scroll)) return scroll;
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Move focus out of `element` before it unmounts. A no-op when focus is elsewhere. */
|
|
330
|
+
function focusSurvivesRetire(element: HTMLElement): void {
|
|
331
|
+
const active = document.activeElement;
|
|
332
|
+
if (active === null || !element.contains(active)) return;
|
|
333
|
+
const target = retireFocusTarget(element);
|
|
334
|
+
if (target === null || element.contains(target)) return;
|
|
335
|
+
if (target.tabIndex < 0) target.tabIndex = -1;
|
|
336
|
+
target.focus({ preventScroll: true });
|
|
337
|
+
}
|
|
338
|
+
|
|
211
339
|
// DOM-only behavior: the native Session remains the sole source of business data.
|
|
212
340
|
export function useReadingScroll(root: RefObject<HTMLElement>, motion: boolean): {
|
|
213
341
|
detached: boolean;
|
|
@@ -56,12 +56,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
// 'user' and 'steering'
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
59
|
+
// Reader draws these rows itself. Everything else, including future kinds, is
|
|
60
|
+
// mirrored so the official renderer still runs inside the reading view.
|
|
61
|
+
// 'user' and 'steering' stay mirrored (message-recall buttons and the like).
|
|
62
|
+
// 'assistant-step' is special: the official renderer is priority 0 and Reader
|
|
63
|
+
// keeps that presentation. A strictly lower priority is a keyed-slot
|
|
64
|
+
// replacement (lowest priority renders) and is mirrored for body content.
|
|
65
|
+
const READER_OWNED_NODES = new Set(['tool-call', 'turn-tail', 'turn-process']);
|
|
66
|
+
|
|
67
|
+
/** Official `AssistantNodeView` registers at priority 0. Lower wins. */
|
|
68
|
+
export const OFFICIAL_ASSISTANT_PRIORITY = 0;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mirror a chat-node registration into the Reader seat.
|
|
72
|
+
* Assistant replacements are the entries that would actually replace the
|
|
73
|
+
* official renderer on the host. The official priority itself stays out, so
|
|
74
|
+
* Reader keeps its own answer markdown until a plugin opts in.
|
|
75
|
+
*/
|
|
76
|
+
export function acceptMirroredChatNode(entry: StoredEntry): boolean {
|
|
77
|
+
const key = entry.options.key ?? '';
|
|
78
|
+
if (key === 'assistant-step') return (entry.options.priority ?? OFFICIAL_ASSISTANT_PRIORITY) < OFFICIAL_ASSISTANT_PRIORITY;
|
|
79
|
+
return !READER_OWNED_NODES.has(key);
|
|
80
|
+
}
|
|
65
81
|
const EXPECTED: Record<OfficialFamily, Spec> = {
|
|
66
82
|
actions: { kind: 'list', scope: 'session' },
|
|
67
83
|
tools: { kind: 'keyed', scope: 'session' },
|
|
@@ -201,7 +217,7 @@ export function installOfficialSlots(ctx: Context): () => void {
|
|
|
201
217
|
throw new Error(`Official slot contract changed: ${source}`);
|
|
202
218
|
}
|
|
203
219
|
return mirrorOfficialSlot(slots, source, target, `dsh-better-display.official.${family}`,
|
|
204
|
-
family === 'nodes' ?
|
|
220
|
+
family === 'nodes' ? acceptMirroredChatNode : undefined);
|
|
205
221
|
}));
|
|
206
222
|
}
|
|
207
223
|
} catch (error) {
|
package/src/client/projection.ts
CHANGED
|
@@ -33,6 +33,13 @@ export function boundaryOf(turn: TurnLocation | undefined): TurnBoundary {
|
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* A settled body step before the closing step of a successful turn.
|
|
38
|
+
*
|
|
39
|
+
* This is presentation classification only. Narration is never process content
|
|
40
|
+
* and never folds. The flag only marks body text that is no longer the turn's
|
|
41
|
+
* final answer, so copy and fork stay on that final answer.
|
|
42
|
+
*/
|
|
36
43
|
export function isEarlierNarration(data: AssistantChatData, boundary: TurnBoundary): boolean {
|
|
37
44
|
// New body text, a tool call, or a settled step is not a completed turn.
|
|
38
45
|
if (data.status !== 'settled' || boundary.status !== 'closed' || boundary.reason !== 'completed') return false;
|
|
@@ -49,13 +56,18 @@ export function processChoiceKey(groupKey: string, boundary: TurnBoundary): stri
|
|
|
49
56
|
return `${groupKey}:${boundary.status}:${boundary.reason ?? 'pending'}`;
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
59
|
+
/**
|
|
60
|
+
* A body-only assistant step is not a thinking/process disclosure.
|
|
61
|
+
*
|
|
62
|
+
* Narration (text/image/other blocks) is always visible, so it never counts as
|
|
63
|
+
* foldable process content; only literal reasoning does. Tool calls arrive as
|
|
64
|
+
* their own flow entries and are checked separately by the reader.
|
|
65
|
+
*/
|
|
66
|
+
export function hasProcessContent(node: ChatConversationViewNode | undefined, _boundary: TurnBoundary): boolean {
|
|
54
67
|
if (!node || node.visibility === 'hidden') return false;
|
|
55
68
|
if (node.kind === 'assistant-step') {
|
|
56
69
|
const data = node.data as AssistantChatData;
|
|
57
|
-
return data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '')
|
|
58
|
-
|| (isEarlierNarration(data, boundary) && hasVisibleBody(data.blocks));
|
|
70
|
+
return data.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '');
|
|
59
71
|
}
|
|
60
72
|
return node.kind === 'context' || node.kind === 'model-retry' || node.kind === 'system-prompt' || node.kind === 'turn-process'
|
|
61
73
|
|| node.kind === 'command' || node.kind === 'manual-compaction' || node.kind === 'compaction';
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which chat rows a `/rewind` withdrew, expressed as a pure function of the
|
|
3
|
+
* chat snapshot.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. A rewind never deletes history: the host appends one
|
|
6
|
+
* replacement `user/message` marker whose `surfaceOp` shadows everything after
|
|
7
|
+
* the target, and the transcript must then read as it did *before* the target
|
|
8
|
+
* was sent. The official ChatView is a per-node renderer, so the rewind plugin
|
|
9
|
+
* (`dsh-rewind-plugin`) hides withdrawn rows by DOM: it computes the shadowed
|
|
10
|
+
* anchor seqs from the command node and sets `display:none` on every element
|
|
11
|
+
* carrying the official `data-chat-anchor-key` seat attribute.
|
|
12
|
+
*
|
|
13
|
+
* The Reader replaces ChatView's message list and renders its own row shapes
|
|
14
|
+
* (turn sections, answer articles, tool rows), so it does not republish that
|
|
15
|
+
* seat attribute on every row. DOM hiding therefore reaches only the user and
|
|
16
|
+
* steering bubbles, and every other withdrawn row — the answer, the tool rows,
|
|
17
|
+
* and the `/rewind` command row itself — stayed on screen. That is the reported
|
|
18
|
+
* defect: the command is recorded as a rewind row instead of the Reader rolling
|
|
19
|
+
* back to the state before the message was sent.
|
|
20
|
+
*
|
|
21
|
+
* The Reader decides visibility from React state, so it must apply the same
|
|
22
|
+
* contract itself instead of waiting for a third-party MutationObserver. The
|
|
23
|
+
* marker node is not admitted as a chat node (only append-origin surface events
|
|
24
|
+
* become nodes), so the command node's `outcome.sourceEventSeq` — the marker's
|
|
25
|
+
* log seq — plus the `@<seq>` target in its `args` are the only client-side
|
|
26
|
+
* signal for the shadowed range. This mirrors the plugin's own reader of that
|
|
27
|
+
* contract, and is pinned by `tests/rewind-cut.test.ts` so a plugin or host
|
|
28
|
+
* change cannot silently regress it.
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-better-display/rewind-cut
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** A chat-snapshot view node, read structurally (only the fields this module needs). */
|
|
34
|
+
export interface CutChatNode {
|
|
35
|
+
readonly key: string;
|
|
36
|
+
readonly kind?: string;
|
|
37
|
+
readonly anchorSeq?: number;
|
|
38
|
+
readonly visibility?: string;
|
|
39
|
+
readonly data?: unknown;
|
|
40
|
+
/**
|
|
41
|
+
* Where the host placed the node in the session's location index. Only the
|
|
42
|
+
* owning turn is read from it, and an absent or foreign location is fine.
|
|
43
|
+
*/
|
|
44
|
+
readonly location?: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The minimal chat-snapshot reader the cut logic needs. */
|
|
48
|
+
export interface CutChat {
|
|
49
|
+
readonly order: readonly string[];
|
|
50
|
+
readonly nodes: {
|
|
51
|
+
get(key: string): CutChatNode | undefined;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One closed shadow range `[start, end]` in surface/anchor seq space. */
|
|
56
|
+
export interface CutSpan {
|
|
57
|
+
readonly start: number;
|
|
58
|
+
readonly end: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
62
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
63
|
+
? (value as Record<string, unknown>)
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeSeq(value: unknown): number | undefined {
|
|
68
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The command payload of a `command` chat node, when it is structurally readable. */
|
|
72
|
+
function commandOf(node: CutChatNode): Record<string, unknown> | undefined {
|
|
73
|
+
if (node.kind !== 'command') return undefined;
|
|
74
|
+
return asRecord(node.data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The engine names an executed rewind command node can carry. The plugin
|
|
79
|
+
* registers its command as `rewind-plugin` (`ENGINE_COMMANDS = new
|
|
80
|
+
* Set(['rewind-plugin', 'rewind'])` in dsh-rewind-plugin's
|
|
81
|
+
* `src/client/hidden.ts`), and that is the name the client projection actually
|
|
82
|
+
* publishes — the session log's `command/run.name` is not what the Reader sees.
|
|
83
|
+
* `rewind` stays accepted because older logs and the bili-side proxy record the
|
|
84
|
+
* shorter spelling.
|
|
85
|
+
*/
|
|
86
|
+
const REWIND_COMMANDS: ReadonlySet<string> = new Set(['rewind-plugin', 'rewind']);
|
|
87
|
+
|
|
88
|
+
/** True when a command payload is the rewind engine command, in either spelling. */
|
|
89
|
+
function isRewindCommand(command: Record<string, unknown>): boolean {
|
|
90
|
+
return typeof command.name === 'string' && REWIND_COMMANDS.has(command.name);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The render POSITION a row is anchored at: the view node's `anchorSeq`, else
|
|
95
|
+
* its payload seq.
|
|
96
|
+
*
|
|
97
|
+
* This is deliberately NOT integer-only. The host anchors synthetic rows at
|
|
98
|
+
* fractional offsets off a real seq (`CHAT_SYNTHETIC_SEQ_OFFSETS` in the
|
|
99
|
+
* official ChatView: `turn-process` at `controlAnchorSeq - 0.1`, `turn-tail` at
|
|
100
|
+
* `seq + 0.1`, an interrupted assistant step at `seq - 0.9`). An integer-only
|
|
101
|
+
* read made those rows invisible to the cut, so the last `turn-process` of a
|
|
102
|
+
* withdrawn turn survived on its own. Log seqs (marker, target) stay integer:
|
|
103
|
+
* see `safeSeq`.
|
|
104
|
+
*/
|
|
105
|
+
export function anchorSeqOf(node: CutChatNode): number | undefined {
|
|
106
|
+
const raw = node.anchorSeq ?? asRecord(node.data)?.seq;
|
|
107
|
+
return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 ? raw : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Extract the rewind target seq from a `/rewind` command's structured `args`
|
|
112
|
+
* (e.g. `@5 chat`, `preview @5 both`). Locale-independent: it never parses the
|
|
113
|
+
* host's human outcome copy.
|
|
114
|
+
*/
|
|
115
|
+
export function rewindTargetOfArgs(args: unknown): number | undefined {
|
|
116
|
+
if (typeof args !== 'string') return undefined;
|
|
117
|
+
const match = args.match(/@(\d+)/);
|
|
118
|
+
return match === null ? undefined : Number(match[1]);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** True for the internal candidate-list probe (`/rewind __candidates`). */
|
|
122
|
+
function isCandidateCommand(command: Record<string, unknown>): boolean {
|
|
123
|
+
return typeof command.args === 'string' && command.args.includes('__candidates');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** True for an impact preview (`/rewind preview @5 both`). */
|
|
127
|
+
function isPreviewCommand(command: Record<string, unknown>): boolean {
|
|
128
|
+
return typeof command.args === 'string' && command.args.includes('preview');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Merge overlapping or touching shadow ranges.
|
|
133
|
+
*
|
|
134
|
+
* Spans are kept SEPARATE unless they genuinely overlap: collapsing every cut
|
|
135
|
+
* to `[min target, max marker]` would hide a still-on-surface gap of new traffic
|
|
136
|
+
* between an earlier marker and a later target.
|
|
137
|
+
*/
|
|
138
|
+
export function coalesceCutSpans(spans: readonly CutSpan[]): CutSpan[] {
|
|
139
|
+
if (spans.length < 2) return [...spans];
|
|
140
|
+
const sorted = [...spans].sort((left, right) => left.start - right.start || left.end - right.end);
|
|
141
|
+
const merged: { start: number; end: number }[] = [];
|
|
142
|
+
for (const span of sorted) {
|
|
143
|
+
const last = merged.at(-1);
|
|
144
|
+
if (last !== undefined && span.start <= last.end) {
|
|
145
|
+
if (span.end > last.end) last.end = span.end;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
merged.push({ start: span.start, end: span.end });
|
|
149
|
+
}
|
|
150
|
+
return merged;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* True when a row anchored at `seq` falls inside one of the cut spans.
|
|
155
|
+
*
|
|
156
|
+
* A cut runs from the target seq through the marker's own log seq, inclusive
|
|
157
|
+
* (`target <= anchorSeq && anchorSeq <= marker`), the same comparison
|
|
158
|
+
* `dsh-rewind-plugin`'s `RewindVisibilityBuilder` uses. The upper bound stops
|
|
159
|
+
* at the marker. Extending it to the next integer hides the following turn's
|
|
160
|
+
* process row, which the host anchors at `startSeq - 0.1` and therefore in
|
|
161
|
+
* `[marker, marker + 1)`. Fractional anchors inside the closed range still
|
|
162
|
+
* match: `anchorSeqOf` keeps them. A process or tail row parked outside the
|
|
163
|
+
* range is handled by `summaryTurns`, not by widening the cut. Spans stay
|
|
164
|
+
* disjoint, so new traffic between two rewinds survives.
|
|
165
|
+
*/
|
|
166
|
+
export function cutContains(cuts: readonly CutSpan[], seq: number): boolean {
|
|
167
|
+
let low = 0;
|
|
168
|
+
let high = cuts.length - 1;
|
|
169
|
+
let found = -1;
|
|
170
|
+
while (low <= high) {
|
|
171
|
+
const middle = (low + high) >> 1;
|
|
172
|
+
const cut = cuts[middle];
|
|
173
|
+
if (cut === undefined) return false;
|
|
174
|
+
if (seq < cut.start) high = middle - 1;
|
|
175
|
+
else {
|
|
176
|
+
found = middle;
|
|
177
|
+
low = middle + 1;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const span = found < 0 ? undefined : cuts[found];
|
|
181
|
+
return span !== undefined && seq <= span.end;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The turn id a node belongs to (absent outside a turn). */
|
|
185
|
+
function turnIdOf(node: CutChatNode): unknown | undefined {
|
|
186
|
+
// The location's own `kind` varies (`turn` for a turn's synthetic rows,
|
|
187
|
+
// `step` for one of its messages); only the turn it carries matters here.
|
|
188
|
+
const turn = asRecord(asRecord(node.location)?.turn);
|
|
189
|
+
if (turn === undefined || !Object.hasOwn(turn, 'turn')) return undefined;
|
|
190
|
+
return turn.turn;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Node kinds that SUMMARIZE a turn instead of carrying its content, so the host
|
|
195
|
+
* anchors them at the turn's edges rather than at an event inside it.
|
|
196
|
+
*/
|
|
197
|
+
const TURN_SUMMARY_KINDS: ReadonlySet<string> = new Set(['turn-process', 'turn-tail']);
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* True for a rewind command row the transcript should not keep.
|
|
201
|
+
*
|
|
202
|
+
* Probes (`preview`, `__candidates`) exist only to feed the popover. An
|
|
203
|
+
* executed rewind — success plus a marker seq — is the command that performed
|
|
204
|
+
* the cut, and the plugin hides that row even when its anchor sits outside
|
|
205
|
+
* the numeric range. A failed or still-running command stays visible.
|
|
206
|
+
*/
|
|
207
|
+
function commandHidden(node: CutChatNode): boolean {
|
|
208
|
+
const command = commandOf(node);
|
|
209
|
+
if (command === undefined || !isRewindCommand(command)) return false;
|
|
210
|
+
if (isPreviewCommand(command) || isCandidateCommand(command)) return true;
|
|
211
|
+
const outcome = asRecord(command.outcome);
|
|
212
|
+
return outcome?.kind === 'success' && safeSeq(outcome.sourceEventSeq) !== undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** True when the row's own anchor falls in a cut, or the row is a rewind command to hide. */
|
|
216
|
+
function anchorWithdrawn(node: CutChatNode, cuts: readonly CutSpan[]): boolean {
|
|
217
|
+
if (commandHidden(node)) return true;
|
|
218
|
+
const seq = anchorSeqOf(node);
|
|
219
|
+
return seq !== undefined && cutContains(cuts, seq);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Turns whose process and tail rows must leave.
|
|
224
|
+
*
|
|
225
|
+
* The host opens a turn before its user message is logged and anchors
|
|
226
|
+
* `turn-process` at `controlAnchorSeq - 0.1`, below the first withdrawn
|
|
227
|
+
* event, and `turn-tail` at `turn.end.seq + 0.1`, which can sit past the
|
|
228
|
+
* marker. Containment therefore misses those two rows, and the transcript
|
|
229
|
+
* kept announcing `执行过程 / 本轮已停止,已生成的内容仍保留。`
|
|
230
|
+
*
|
|
231
|
+
* A summary leaves only when every content row of its turn is already
|
|
232
|
+
* withdrawn — the same rule as the plugin's `deriveTurnProcess`. One content
|
|
233
|
+
* row before the target (a mid-turn steering rewind) keeps the summary and
|
|
234
|
+
* that earlier message. A turn with no content rows is left alone.
|
|
235
|
+
*/
|
|
236
|
+
function summaryTurns(chat: CutChat, cuts: readonly CutSpan[]): Set<unknown> {
|
|
237
|
+
const stays = new Map<unknown, boolean>();
|
|
238
|
+
for (const key of chat.order) {
|
|
239
|
+
const node = chat.nodes.get(key);
|
|
240
|
+
if (node === undefined || TURN_SUMMARY_KINDS.has(node.kind ?? '')) continue;
|
|
241
|
+
const id = turnIdOf(node);
|
|
242
|
+
if (id === undefined) continue;
|
|
243
|
+
const visible = node.visibility !== 'hidden' && !anchorWithdrawn(node, cuts);
|
|
244
|
+
stays.set(id, (stays.get(id) ?? false) || visible);
|
|
245
|
+
}
|
|
246
|
+
const withdrawn = new Set<unknown>();
|
|
247
|
+
for (const [id, visible] of stays) if (!visible) withdrawn.add(id);
|
|
248
|
+
return withdrawn;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** True when this row is a summary of a turn whose content is fully withdrawn. */
|
|
252
|
+
function summaryWithdrawn(node: CutChatNode, summaries: ReadonlySet<unknown>): boolean {
|
|
253
|
+
if (!TURN_SUMMARY_KINDS.has(node.kind ?? '')) return false;
|
|
254
|
+
const id = turnIdOf(node);
|
|
255
|
+
return id !== undefined && summaries.has(id);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The shadow ranges a session's executed rewinds produced, newest state last.
|
|
260
|
+
* An executed rewind cuts `[target, marker]` — the target itself is included,
|
|
261
|
+
* because a rewind restores the state *before* that message was sent (its text
|
|
262
|
+
* goes back to the composer).
|
|
263
|
+
*/
|
|
264
|
+
export function rewindCutSpans(chat: CutChat | undefined): CutSpan[] {
|
|
265
|
+
if (chat === undefined) return [];
|
|
266
|
+
const spans: CutSpan[] = [];
|
|
267
|
+
for (const key of chat.order) {
|
|
268
|
+
const node = chat.nodes.get(key);
|
|
269
|
+
if (node === undefined) continue;
|
|
270
|
+
const command = commandOf(node);
|
|
271
|
+
if (command === undefined || !isRewindCommand(command)) continue;
|
|
272
|
+
// Probes never open a span, even when a later host stamps them with a
|
|
273
|
+
// success marker. Matching them would roll the transcript back on preview.
|
|
274
|
+
if (isPreviewCommand(command) || isCandidateCommand(command)) continue;
|
|
275
|
+
const outcome = asRecord(command.outcome);
|
|
276
|
+
if (outcome?.kind !== 'success') continue;
|
|
277
|
+
const marker = safeSeq(outcome.sourceEventSeq);
|
|
278
|
+
if (marker === undefined) continue;
|
|
279
|
+
const target = rewindTargetOfArgs(command.args);
|
|
280
|
+
if (target === undefined || target > marker) continue;
|
|
281
|
+
spans.push({ start: target, end: marker });
|
|
282
|
+
}
|
|
283
|
+
return coalesceCutSpans(spans);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The anchor seqs the Reader must not render: every withdrawn message (the
|
|
288
|
+
* target, everything after it, and the unrendered marker), plus every `/rewind`
|
|
289
|
+
* row that exists only to feed the popover — the candidate-list probe and each
|
|
290
|
+
* impact preview. A SUCCESSFUL executed command row is covered by its own cut;
|
|
291
|
+
* a failed or unsettled one stays visible as an ordinary command record.
|
|
292
|
+
*/
|
|
293
|
+
export function hiddenRewindSeqs(chat: CutChat | undefined): Set<number> {
|
|
294
|
+
const hidden = new Set<number>();
|
|
295
|
+
if (chat === undefined) return hidden;
|
|
296
|
+
const cuts = rewindCutSpans(chat);
|
|
297
|
+
const summaries = summaryTurns(chat, cuts);
|
|
298
|
+
for (const key of chat.order) {
|
|
299
|
+
const node = chat.nodes.get(key);
|
|
300
|
+
if (node === undefined) continue;
|
|
301
|
+
if (!anchorWithdrawn(node, cuts) && !summaryWithdrawn(node, summaries)) continue;
|
|
302
|
+
const seq = anchorSeqOf(node);
|
|
303
|
+
if (seq !== undefined) hidden.add(seq);
|
|
304
|
+
}
|
|
305
|
+
return hidden;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The chat keys the Reader should render, in snapshot order: withdrawn rows and
|
|
310
|
+
* rows the host itself marked hidden are dropped, everything else keeps its
|
|
311
|
+
* position. Returns the snapshot's own `order` array when nothing is filtered,
|
|
312
|
+
* so React selectors see a stable reference.
|
|
313
|
+
*/
|
|
314
|
+
export function visibleChatKeys(chat: CutChat): readonly string[] {
|
|
315
|
+
const cuts = rewindCutSpans(chat);
|
|
316
|
+
const summaries = summaryTurns(chat, cuts);
|
|
317
|
+
const visible: string[] = [];
|
|
318
|
+
for (const key of chat.order) {
|
|
319
|
+
const node = chat.nodes.get(key);
|
|
320
|
+
if (node === undefined) continue;
|
|
321
|
+
if (node.visibility === 'hidden') continue;
|
|
322
|
+
if (anchorWithdrawn(node, cuts) || summaryWithdrawn(node, summaries)) continue;
|
|
323
|
+
visible.push(key);
|
|
324
|
+
}
|
|
325
|
+
return visible.length === chat.order.length ? chat.order : visible;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A comparable signal for the visible row set, built for
|
|
330
|
+
* `useSyncExternalStore`-style subscription: the host re-runs a chat selector
|
|
331
|
+
* on every publish and compares the RESULT by `Object.is`, and a rewind
|
|
332
|
+
* publishes an in-place upsert of an existing command row. The snapshot's
|
|
333
|
+
* `order` array and node store are therefore both reference-stable across a
|
|
334
|
+
* rewind, so a selector on them never re-renders; a string does change exactly
|
|
335
|
+
* when the visible set does. Never return the array itself from a selector —
|
|
336
|
+
* a fresh array fails the `Object.is` check on every publish.
|
|
337
|
+
*/
|
|
338
|
+
export function visibleChatSignature(chat: CutChat): string {
|
|
339
|
+
return visibleChatKeys(chat).join('\u0000');
|
|
340
|
+
}
|