dsh-better-display 0.3.8 → 0.3.10
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 +10 -2
- package/README.md +10 -2
- package/compat/harness-rc2.json +17 -13
- package/docs/dependency-update-policy.md +9 -0
- package/docs/extension-points.md +172 -0
- package/docs/official-rendering-bridge.md +49 -20
- package/docs/openapi.yaml +147 -0
- package/docs/runbooks/blank-reader.md +39 -0
- package/lib/client.js +1802 -872
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +376 -151
- 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 +37 -12
- 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 +45 -77
- 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 +24 -3
- package/lib/types/client/official-slots.d.ts.map +1 -1
- package/lib/types/client/official-slots.js +59 -11
- package/lib/types/client/official-slots.js.map +1 -1
- package/lib/types/client/primitive-labels.d.ts.map +1 -1
- package/lib/types/client/primitive-labels.js +1 -0
- package/lib/types/client/primitive-labels.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 +55 -23
- 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-harness-compat.mjs +4 -2
- package/scripts/check-todo-debt.mjs +69 -0
- package/scripts/link-harness-dependencies.mjs +48 -11
- 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 +52 -14
- package/src/client/Reader.module.css +68 -18
- package/src/client/Reader.tsx +38 -82
- 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 +61 -12
- package/src/client/primitive-labels.ts +1 -0
- 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/browser-official-bridge.tsx +2 -1
- 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 +244 -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 +138 -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 +11 -7
- 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/Reader.tsx
CHANGED
|
@@ -5,15 +5,15 @@ import type { ReactNode, RefObject } from 'react';
|
|
|
5
5
|
import type { ChatConversationViewNode, ChatNode, ChatNodeKind, ChatNodeOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client';
|
|
6
6
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store';
|
|
7
7
|
import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
8
|
-
import { BlockBoundary, Blocks, contentBlocks,
|
|
9
|
-
import {
|
|
8
|
+
import { BlockBoundary, Blocks, contentBlocks, UserMessageActions } from './Blocks.js';
|
|
9
|
+
import { AssistantNode } from './AssistantNode.js';
|
|
10
10
|
import { ToolActivity, ToolMedia } from './ToolActivity.js';
|
|
11
|
-
import {
|
|
12
|
-
import { OFFICIAL_SEATS } from './official-slots.js';
|
|
11
|
+
import { OfficialNode, OfficialTail } from './OfficialContent.js';
|
|
12
|
+
import { OFFICIAL_SEATS, readerGroupAction } from './official-slots.js';
|
|
13
13
|
import { preparingLabel, readerFlow } from './tool-activity.js';
|
|
14
|
-
import { Disclosure, ProcessFragment,
|
|
14
|
+
import { Disclosure, ProcessFragment, RetiringTurns, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
|
|
15
15
|
import { StreamMotionContext } from './streaming.js';
|
|
16
|
-
import {
|
|
16
|
+
import { boundaryOf, forkAnchorSeq, groupNodes, hasProcessContent, processChoiceKey, processExpanded, terminalLabel } from './projection.js';
|
|
17
17
|
import { basename, createProducedFileMentions, dirname, getTurnDeliverables, showDeliverablesRow } from './deliverables.js';
|
|
18
18
|
import { deliverableOpenModeOf, type DeliverableOpenMode } from './open-file.js';
|
|
19
19
|
import { asReadonlyArray, pendingSubmissionImages, type PendingSubmissionEcho } from './pending-submission.js';
|
|
@@ -23,17 +23,18 @@ import { ContextInjectionRow } from './native/ContextInjectionRow.js';
|
|
|
23
23
|
import { TimelineRail } from './TimelineRail.js';
|
|
24
24
|
import { landTurn, scrollerOf } from './conversation-scroll.js';
|
|
25
25
|
import { mergeTimelineItems, type TimelineItem } from './timeline.js';
|
|
26
|
-
import { presentLiveTurn, segmentLiveTurn } from './live-turn.js';
|
|
26
|
+
import { planProcessSeams, presentLiveTurn, segmentLiveTurn } from './live-turn.js';
|
|
27
27
|
import type { LiveStep } from './live-turn.js';
|
|
28
28
|
import { frostedGlassOf } from './fold-intensity.js';
|
|
29
29
|
import { ChoreographedFlow, useFlowChat } from './ChoreographedFlow.js';
|
|
30
30
|
import { ClosedProcessSummary } from './ClosedProcessSummary.js';
|
|
31
|
-
import { StickyLane } from './StickyLane.js';
|
|
31
|
+
import { ReaderLaneScope, StickyLane } from './StickyLane.js';
|
|
32
32
|
import type { ReaderGroup, TurnBoundary } from './projection.js';
|
|
33
33
|
import type { BlockRenderProps, ReaderProps } from './types.js';
|
|
34
34
|
import css from './Reader.module.css';
|
|
35
35
|
import { markdownLabels, truncatedJsonLabel } from './primitive-labels.js';
|
|
36
36
|
import { chatSeatProps } from './chat-seat.js';
|
|
37
|
+
import { visibleChatKeys, visibleChatSignature } from './rewind-cut.js';
|
|
37
38
|
import { ensureStyles } from './styles.js';
|
|
38
39
|
|
|
39
40
|
function isNode<K extends ChatNodeKind>(node: ChatConversationViewNode, kind: K): node is ChatNode<K> {
|
|
@@ -170,52 +171,7 @@ const ProcessNode = memo(function ProcessNode({ useChat, t, nodeKey, open, motio
|
|
|
170
171
|
return content && <ProcessFragment open={open} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>{content}</ProcessFragment>;
|
|
171
172
|
});
|
|
172
173
|
|
|
173
|
-
const
|
|
174
|
-
motion: boolean; onRead: () => void; returnFocusTo: RefObject<HTMLButtonElement>; partStart?: number; folded?: boolean;
|
|
175
|
-
}) {
|
|
176
|
-
const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
|
|
177
|
-
if (!node || node.visibility === 'hidden' || !isNode(node, 'assistant-step')) return null;
|
|
178
|
-
const data = node.data;
|
|
179
|
-
const parts = assistantSegments(data.blocks);
|
|
180
|
-
const earlier = isEarlierNarration(data, boundary);
|
|
181
|
-
const hasToolCalls = data.blocks.some(block => block.kind === 'tool-call');
|
|
182
|
-
const isProcessStep = earlier || folded || hasToolCalls || (boundary.latestStep > 0 && data.step < boundary.latestStep);
|
|
183
|
-
const body = data.blocks.filter(block => block.kind !== 'reasoning' && block.kind !== 'tool-call');
|
|
184
|
-
const visible = partStart === undefined ? parts : parts.filter(part => part.start === partStart);
|
|
185
|
-
return <>{visible.map(part => {
|
|
186
|
-
const index = parts.findIndex(item => item.start === part.start);
|
|
187
|
-
const last = index === parts.length - 1;
|
|
188
|
-
return part.kind === 'reasoning'
|
|
189
|
-
? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey} framed>
|
|
190
|
-
<ReasoningCard step={data.step} active={processOpen && boundary.status === 'open' && data.step === boundary.latestStep} motion={motion} selected={pinned} onRead={onRead}>
|
|
191
|
-
<Blocks {...render} blocks={part.blocks} streaming={data.status === 'running' && last && data.blocks.at(-1)?.kind === 'reasoning'}
|
|
192
|
-
holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
|
|
193
|
-
</ReasoningCard>
|
|
194
|
-
</ProcessFragment>
|
|
195
|
-
: isProcessStep ? <ProcessFragment key={part.start} open={processOpen} motion={motion} onRead={onRead} returnFocusTo={returnFocusTo} nodeKey={nodeKey}>
|
|
196
|
-
<article className={css.processCommentary}>
|
|
197
|
-
<Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
|
|
198
|
-
</article>
|
|
199
|
-
</ProcessFragment>
|
|
200
|
-
: hasVisibleBody(part.blocks) && <RetiringContent key={part.start} visible={pinned || processOpen || (!earlier && !folded)}>
|
|
201
|
-
<article className={css.answer} data-reader-answer data-reader-anchor data-reader-key={nodeKey} data-reader-source-start={part.start} data-answer-status={data.status} data-answer-phase={earlier || folded ? 'process' : 'body'}>
|
|
202
|
-
<Blocks {...render} blocks={part.blocks} streaming={data.status === 'running'} holdFormatting={pinned} startedAt={data.time} interrupted={data.status === 'interrupted'} liveText />
|
|
203
|
-
{last && data.status === 'interrupted' && <span className={css.stopped}>已停止</span>}
|
|
204
|
-
{last && !earlier && !folded && data.status !== 'running' && boundary.status === 'closed' && (
|
|
205
|
-
<CopyAnswer blocks={body} onFork={(() => {
|
|
206
|
-
// The fork anchor must be the durable closing message seq (same as
|
|
207
|
-
// the official turn-tail branch). AssistantChatData carries no seq
|
|
208
|
-
// of its own; passing it would fork the whole session instead.
|
|
209
|
-
const anchor = forkAnchorSeq([data.finalNode, { seq: render.forkSeq }]);
|
|
210
|
-
return render.forkAt && anchor !== undefined ? () => render.forkAt!(anchor) : undefined;
|
|
211
|
-
})()} metrics={render.metrics} extraActions={<OfficialActions official={render.official} messageId={data.finalNode?.messageId} />} />
|
|
212
|
-
)}
|
|
213
|
-
</article>
|
|
214
|
-
</RetiringContent>;
|
|
215
|
-
})}</>;
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, processOpen = false, ...render }: SeatProps) {
|
|
174
|
+
const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, processOpen: _processOpen = false, ...render }: SeatProps) {
|
|
219
175
|
const node = useChat(snapshot => snapshot.nodes.get(nodeKey));
|
|
220
176
|
const [disclosureReset] = useState(() => createSnapshotStore(0));
|
|
221
177
|
if (!node || node.visibility === 'hidden') return null;
|
|
@@ -242,17 +198,13 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
|
|
|
242
198
|
const owner: ChatNodeOwnerProps & Pick<ReaderProps, 'sessionId'> = {
|
|
243
199
|
cwd: render.cwd,
|
|
244
200
|
openFile: render.openFile ?? (() => {}),
|
|
245
|
-
openSkill: () => {},
|
|
246
201
|
forkAt: render.forkAt ?? (() => {}),
|
|
247
202
|
inspectCall: callId => official.openView('trajectory', callId),
|
|
248
203
|
loadImage: official.officialImageLoader,
|
|
249
|
-
renderMessageImages: images => official.renderSlot(OFFICIAL_SEATS.images, {
|
|
250
|
-
...images, loadImage: official.officialImageLoader,
|
|
251
|
-
}),
|
|
252
204
|
fileMentions: official.officialFileMentions,
|
|
253
205
|
sessionId: render.sessionId,
|
|
254
206
|
};
|
|
255
|
-
const options = { hookContext: { turnData: turn?.data, disclosureReset }, fallback };
|
|
207
|
+
const options = { hookContext: { turnData: turn?.data, disclosureReset, useGroupAction: readerGroupAction }, fallback };
|
|
256
208
|
rendered = node.kind === 'user'
|
|
257
209
|
? official.renderSlot(OFFICIAL_SEATS.nodes, { ...owner, node }, { ...options, entryKey: 'user' })
|
|
258
210
|
: official.renderSlot(OFFICIAL_SEATS.nodes, { ...owner, node }, { ...options, entryKey: 'steering' });
|
|
@@ -322,7 +274,7 @@ function subagentCount(snapshot: { nodes: { get(key: string): ChatConversationVi
|
|
|
322
274
|
return 0;
|
|
323
275
|
}
|
|
324
276
|
|
|
325
|
-
function GroupStatus({ group,
|
|
277
|
+
function GroupStatus({ group, useChat, pending, motion }: Pick<ReaderProps, 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
|
|
326
278
|
const text = useChat(snapshot => {
|
|
327
279
|
const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
|
|
328
280
|
if (turn?.status === 'closed') {
|
|
@@ -538,7 +490,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
538
490
|
const firstKind = props.useChat(snapshot => snapshot.nodes.get(group.keys[0])?.kind);
|
|
539
491
|
const startsWithUser = firstKind === 'user';
|
|
540
492
|
const mainKeys = startsWithUser ? group.keys.slice(1) : group.keys;
|
|
541
|
-
const flow = useMemo(() => readerFlow({ ...group, keys: mainKeys }, turn, key => nodes.get(key)), [nodes, group, mainKeys, turn]);
|
|
493
|
+
const flow = useMemo(() => readerFlow({ ...group, keys: mainKeys }, turn, key => nodes.get(key), snapshot.order), [nodes, group, mainKeys, turn, snapshot.order]);
|
|
542
494
|
const steps = useMemo(() => segmentLiveTurn(flow, key => nodes.get(key)), [flow, nodes]);
|
|
543
495
|
const liveItems = useMemo(() => presentLiveTurn(steps, boundary, autoFold), [steps, boundary, autoFold]);
|
|
544
496
|
const openMode = deliverableOpenModeOf(useSyncExternalStore(
|
|
@@ -557,6 +509,12 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
557
509
|
? item.key === key || item.steps.some(step => step.key === key || ('nodeKey' in step && step.nodeKey === key))
|
|
558
510
|
: item.key === key || ('nodeKey' in item.step && item.step.nodeKey === key)));
|
|
559
511
|
const expanded = !autoFold || holdingSelection || processExpanded(expansionChoice, boundary);
|
|
512
|
+
// Seam between narration blocks. Skip a run a live-fold cell already
|
|
513
|
+
// covers, and do not drop the previous body's bottom spacing.
|
|
514
|
+
const seamPlan = useMemo(
|
|
515
|
+
() => planProcessSeams(steps, liveItems, expanded),
|
|
516
|
+
[steps, liveItems, expanded],
|
|
517
|
+
);
|
|
560
518
|
const [foldOpenByKey, setFoldOpenByKey] = useState<Record<string, boolean>>({});
|
|
561
519
|
useLayoutEffect(() => {
|
|
562
520
|
// Restoring automatic folding also releases live "prior steps" disclosures.
|
|
@@ -630,7 +588,9 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
630
588
|
const renderStep = (step: LiveStep, folded: boolean) => {
|
|
631
589
|
const processOpen = folded || expanded;
|
|
632
590
|
if (step.kind === 'reasoning' || step.kind === 'body') return <BlockBoundary>
|
|
633
|
-
<AssistantNode {...shared} boundary={boundary} nodeKey={step.nodeKey} partStart={step.start} pinned={pinnedKeys.includes(step.nodeKey)} processOpen={processOpen} folded={folded} motion={motion} onRead={pinProcess} returnFocusTo={processButton}
|
|
591
|
+
<AssistantNode {...shared} boundary={boundary} nodeKey={step.nodeKey} partStart={step.start} pinned={pinnedKeys.includes(step.nodeKey)} processOpen={processOpen} folded={folded} motion={motion} onRead={pinProcess} returnFocusTo={processButton}
|
|
592
|
+
seamSummary={step.kind === 'body' ? seamPlan.before.get(step.key) : undefined}
|
|
593
|
+
seamAfter={step.kind === 'body' && seamPlan.after.has(step.key)} />
|
|
634
594
|
</BlockBoundary>;
|
|
635
595
|
if (step.kind === 'tool') return <BlockBoundary><ProcessFragment open={processOpen} motion={motion} onRead={pinProcess} returnFocusTo={processButton} nodeKey={step.key} framed>
|
|
636
596
|
<ToolActivity {...shared} entry={step.entry} motion={motion} turnClosed={boundary.status === 'closed'} onRead={pinProcess} />
|
|
@@ -648,17 +608,10 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
648
608
|
{(showStatusLane || showClosedSummary) && <div className={css.turnStickyLanes}>
|
|
649
609
|
{showStatusLane && <StickyLane kind="status" className={css.turnProcessSticky}>
|
|
650
610
|
<Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
|
|
651
|
-
label={<GroupStatus group={group}
|
|
611
|
+
label={<GroupStatus group={group} useChat={props.useChat} pending={interaction} motion={motion} />} />
|
|
652
612
|
</StickyLane>}
|
|
653
613
|
{showClosedSummary && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
|
|
654
|
-
steps={steps.filter(step =>
|
|
655
|
-
if (step.kind === 'user') return false;
|
|
656
|
-
if (step.kind !== 'body') return true;
|
|
657
|
-
const node = nodes.get(step.nodeKey);
|
|
658
|
-
return !!node && isNode(node, 'assistant-step') && (isEarlierNarration(node.data, boundary)
|
|
659
|
-
|| node.data.blocks.some(block => block.kind === 'tool-call')
|
|
660
|
-
|| (boundary.latestStep > 0 && node.data.step < boundary.latestStep));
|
|
661
|
-
})} />}
|
|
614
|
+
steps={steps.filter(step => step.kind !== 'user' && step.kind !== 'body')} />}
|
|
662
615
|
</div>}
|
|
663
616
|
<ChoreographedFlow id={flowId} frame={presentation} motion={motion} enabled={autoFold && boundary.status === 'open' && !holdingSelection}
|
|
664
617
|
urgent={hasTurnError || interaction !== undefined || !sessionRunning} open={foldOpenByKey} processOpen={expanded}
|
|
@@ -666,7 +619,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
666
619
|
</div>
|
|
667
620
|
{/* 状态指示永远排在流程之后:AI 的响应永远出现在最新消息(含补充消息)的下方 */}
|
|
668
621
|
{!hasProcess && boundary.status === 'open' && !isAwaitingModel && <div className={css.disclosure} data-reader-status-only>
|
|
669
|
-
<GroupStatus group={group}
|
|
622
|
+
<GroupStatus group={group} useChat={props.useChat} pending={interaction} motion={motion} />
|
|
670
623
|
</div>}
|
|
671
624
|
{showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
|
|
672
625
|
{boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
|
|
@@ -680,6 +633,9 @@ export function Reader(props: ReaderProps) {
|
|
|
680
633
|
const activatedAt = useRef(Date.now());
|
|
681
634
|
const order = props.useChat(snapshot => snapshot.order);
|
|
682
635
|
const nodes = props.useChat(snapshot => snapshot.nodes);
|
|
636
|
+
// A rewind upserts the command row in place, so order and nodes keep their identity.
|
|
637
|
+
const rewindSignature = props.useChat(snapshot => visibleChatSignature(snapshot));
|
|
638
|
+
const renderKeys = useMemo(() => visibleChatKeys({ order, nodes }), [order, nodes, rewindSignature]);
|
|
683
639
|
const timeline = props.useChat(snapshot => snapshot.timeline);
|
|
684
640
|
const running = props.useSession(snapshot => snapshot.running);
|
|
685
641
|
const pending = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
|
|
@@ -708,7 +664,7 @@ export function Reader(props: ReaderProps) {
|
|
|
708
664
|
rows.push(...local.values());
|
|
709
665
|
return rows;
|
|
710
666
|
}, [inboxSteering, pendingList, visibleSubmissions]);
|
|
711
|
-
const waitAnchor = waitingAnchor(
|
|
667
|
+
const waitAnchor = waitingAnchor(renderKeys, key => nodes.get(key), pendingList);
|
|
712
668
|
const motionPreference = props.useStore(state => state.motion);
|
|
713
669
|
const motion = useMotionAllowed(motionPreference);
|
|
714
670
|
const prefsSnap = useSyncExternalStore(
|
|
@@ -728,14 +684,14 @@ export function Reader(props: ReaderProps) {
|
|
|
728
684
|
}, [autoFold, props.actions]);
|
|
729
685
|
const frostedGlass = frostedGlassOf(prefsSnap);
|
|
730
686
|
const streamMotion = useMemo(() => ({ enabled: motion, activatedAt: activatedAt.current }), [motion]);
|
|
731
|
-
const groups = useMemo(() => groupNodes(
|
|
687
|
+
const groups = useMemo(() => groupNodes(renderKeys, key => nodes.get(key)), [renderKeys, nodes, timeline]);
|
|
732
688
|
const isAwaitingModel = useMemo(() => {
|
|
733
689
|
// 状态机铁律:「深度求索中」与「正在处理/思考」永远互斥。
|
|
734
690
|
// 1. 仅空闲发送(!running)的本地回显窗口:用户按下回车第 0 毫秒立即反馈。
|
|
735
691
|
// 忙碌时的 queued/steering 回显不算等待——模型本来就在工作。
|
|
736
692
|
if (!running && pendingList.length > 0) return true;
|
|
737
693
|
|
|
738
|
-
const lastKey =
|
|
694
|
+
const lastKey = renderKeys.at(-1);
|
|
739
695
|
const lastNode = lastKey ? nodes.get(lastKey) : undefined;
|
|
740
696
|
if (!lastKey || !lastNode) return false;
|
|
741
697
|
|
|
@@ -760,7 +716,7 @@ export function Reader(props: ReaderProps) {
|
|
|
760
716
|
const lastGroup = groups.at(-1);
|
|
761
717
|
const turn = lastGroup?.turn === null || lastGroup?.turn === undefined ? undefined : timeline.turns.get(lastGroup.turn);
|
|
762
718
|
return turn?.status !== 'closed';
|
|
763
|
-
}, [running, pendingList,
|
|
719
|
+
}, [running, pendingList, renderKeys, nodes, groups, timeline]);
|
|
764
720
|
const scroll = useReadingScroll(root, motion);
|
|
765
721
|
const pinnedKeys = usePinnedSelection(root);
|
|
766
722
|
const selectedProcessKeys = usePinnedSelection(root, '[data-reader-process]');
|
|
@@ -868,7 +824,7 @@ export function Reader(props: ReaderProps) {
|
|
|
868
824
|
}
|
|
869
825
|
}, [props.loadThrough, props.loadOlder, scroll.jump, scroll.release, timelineItems]);
|
|
870
826
|
|
|
871
|
-
const lastKey =
|
|
827
|
+
const lastKey = renderKeys.at(-1);
|
|
872
828
|
const lastNode = lastKey ? nodes.get(lastKey) : undefined;
|
|
873
829
|
const lastSubmissionId = pendingList.length ? pendingList[pendingList.length - 1].requestId : null;
|
|
874
830
|
const lastOrderKeyRef = useRef<string | undefined>(lastKey);
|
|
@@ -887,9 +843,9 @@ export function Reader(props: ReaderProps) {
|
|
|
887
843
|
|
|
888
844
|
// ChatView publishes data-chat-flow="" on its column. Skins treat a
|
|
889
845
|
// scrollport without that hook as inspect-only and hide [data-composer-seat].
|
|
890
|
-
return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.4" data-dsh-better-display="0.3.4" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
|
|
846
|
+
return <StreamMotionContext.Provider value={streamMotion}><ReaderLaneScope><div ref={root} className={css.root} data-reader-build="0.3.4" data-dsh-better-display="0.3.4" data-reader-wait-clock-version="input-v1" data-reader-wait-start={waitAnchor.time ?? undefined} data-motion={motion ? 'on' : 'off'} data-reader-glass={frostedGlass || undefined} data-reader-auto-fold={autoFold ? 'on' : 'off'}>
|
|
891
847
|
<TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
|
|
892
|
-
<div className={css.column} data-chat-flow="">
|
|
848
|
+
<div className={css.column} data-chat-flow="" tabIndex={-1}>
|
|
893
849
|
<StickyLane kind="toolbar" className={css.toolbar}>
|
|
894
850
|
<button
|
|
895
851
|
type="button"
|
|
@@ -911,12 +867,12 @@ export function Reader(props: ReaderProps) {
|
|
|
911
867
|
{historyError && <div className={css.notice}>历史记录加载失败,可再次尝试;现有内容未改变。</div>}
|
|
912
868
|
{openError && <div className={css.error} role="alert">会话暂时无法读取:{openError.message}</div>}
|
|
913
869
|
{loading && groups.length === 0 && <p className={css.empty} role="status">正在读取会话…</p>}
|
|
914
|
-
{groups
|
|
870
|
+
<RetiringTurns groups={groups} render={group => <TurnGroup {...props} group={group} motion={motion} autoFold={autoFold} pinnedKeys={pinnedKeys} selectedProcessKeys={selectedProcessKeys} isAwaitingModel={isAwaitingModel && group.key === groups.at(-1)?.key} />} />
|
|
915
871
|
{pendingInputs.map((item, index) => {
|
|
916
872
|
const submission = 'requestId' in item ? item : undefined;
|
|
917
873
|
const text = submission?.text ?? inboxText(item as PendingInboxItem);
|
|
918
874
|
const images = submission ? pendingSubmissionImages(submission) : [];
|
|
919
|
-
const attachmentCount = images.length;
|
|
875
|
+
const attachmentCount = submission ? images.length : inboxAttachmentCount(item as PendingInboxItem);
|
|
920
876
|
const requestId = submission?.requestId ?? `inbox-${index}`;
|
|
921
877
|
return (
|
|
922
878
|
<div key={requestId} className={css.userCluster} data-reader-pending-submission data-pending-steering={submission ? undefined : ''} data-pending-attachment-count={attachmentCount}>
|
|
@@ -949,5 +905,5 @@ export function Reader(props: ReaderProps) {
|
|
|
949
905
|
<svg viewBox="0 0 14 14" width="14" height="14" aria-hidden="true"><path d="M3 5.5 7 9.5 11 5.5" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
|
950
906
|
</button></div>}
|
|
951
907
|
</div>
|
|
952
|
-
</div></StreamMotionContext.Provider>;
|
|
908
|
+
</div></ReaderLaneScope></StreamMotionContext.Provider>;
|
|
953
909
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
2
|
-
import {
|
|
2
|
+
import { frostedGlassOf, type FoldIntensity } from './fold-intensity.js';
|
|
3
3
|
import { deliverableOpenModeOf, type DeliverableOpenMode } from './open-file.js';
|
|
4
4
|
import { settingsCopyFor, type SettingsCopy, type SettingsCopyKey } from './settings-copy.js';
|
|
5
5
|
import { CONVENTIONAL_SKILL_ROOTS, detectGenerativeMcpappsSkill, shortestInstallCommand, type SkillStatusProbe, type SkillStatusSnapshot } from './skill-status.js';
|
|
@@ -49,12 +49,6 @@ function text(props: SettingsProps, copy: SettingsCopy, key: keyof SettingsCopy)
|
|
|
49
49
|
return copy[key];
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const FOLD_STOPS: { value: FoldIntensity; key: 'foldNone' | 'foldStandard' | 'foldSummary' }[] = [
|
|
53
|
-
{ value: 0, key: 'foldNone' },
|
|
54
|
-
{ value: 1, key: 'foldStandard' },
|
|
55
|
-
{ value: 2, key: 'foldSummary' },
|
|
56
|
-
];
|
|
57
|
-
|
|
58
52
|
export function SettingsSection(props: SettingsProps) {
|
|
59
53
|
const copy = props.copy ?? settingsCopyFor(props.languageTag);
|
|
60
54
|
const snap = useSyncExternalStore(
|
|
@@ -1,6 +1,85 @@
|
|
|
1
|
-
import { useLayoutEffect, useRef } from 'react';
|
|
1
|
+
import { createContext, useContext, useLayoutEffect, useRef } from 'react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
|
|
4
|
+
const MIN_LANE_PX = 40;
|
|
5
|
+
type LaneOwners = Map<string, HTMLElement>;
|
|
6
|
+
|
|
7
|
+
const LaneOwnerContext = createContext<LaneOwners | null>(null);
|
|
8
|
+
const ownersByRoot = new WeakMap<HTMLElement, LaneOwners>();
|
|
9
|
+
|
|
10
|
+
/** One map per Reader, so two roots that both contain turn 1 do not share a writer. */
|
|
11
|
+
export function ReaderLaneScope({ children }: { children: ReactNode }) {
|
|
12
|
+
const owners = useRef<LaneOwners>(new Map());
|
|
13
|
+
useLayoutEffect(() => {
|
|
14
|
+
const map = owners.current;
|
|
15
|
+
return () => { map.clear(); };
|
|
16
|
+
}, []);
|
|
17
|
+
return <LaneOwnerContext.Provider value={owners.current}>{children}</LaneOwnerContext.Provider>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function laneScope(el: HTMLElement) {
|
|
21
|
+
return el.closest<HTMLElement>('[data-reader-turn]') ?? el.closest<HTMLElement>('[data-dsh-better-display]');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ownersFor(el: HTMLElement, scoped: LaneOwners | null) {
|
|
25
|
+
if (scoped) return scoped;
|
|
26
|
+
const root = el.closest<HTMLElement>('[data-dsh-better-display]') ?? el;
|
|
27
|
+
let owners = ownersByRoot.get(root);
|
|
28
|
+
if (!owners) {
|
|
29
|
+
owners = new Map();
|
|
30
|
+
ownersByRoot.set(root, owners);
|
|
31
|
+
}
|
|
32
|
+
return owners;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function publishedLanePx(el: HTMLElement) {
|
|
36
|
+
return Math.max(MIN_LANE_PX, Math.ceil(el.getBoundingClientRect().height));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One writer per turn inside a single Reader. A retiring summary cannot clear the lane that replaced it. */
|
|
40
|
+
export function publishLaneHeight(el: HTMLElement, property: string, scoped: LaneOwners | null = null) {
|
|
41
|
+
const scope = laneScope(el);
|
|
42
|
+
if (!scope) return () => {};
|
|
43
|
+
const owners = ownersFor(el, scoped);
|
|
44
|
+
const root = el.closest<HTMLElement>('[data-dsh-better-display]');
|
|
45
|
+
const key = `${property}\0${scope.getAttribute('data-reader-turn') ?? 'root'}`;
|
|
46
|
+
const update = () => {
|
|
47
|
+
if (owners.get(key) !== el) return;
|
|
48
|
+
scope.style.setProperty(property, `${publishedLanePx(el)}px`);
|
|
49
|
+
};
|
|
50
|
+
owners.set(key, el);
|
|
51
|
+
update();
|
|
52
|
+
const observer = new ResizeObserver(update);
|
|
53
|
+
observer.observe(el);
|
|
54
|
+
return () => {
|
|
55
|
+
observer.disconnect();
|
|
56
|
+
if (owners.get(key) !== el) return;
|
|
57
|
+
owners.delete(key);
|
|
58
|
+
scope.style.removeProperty(property);
|
|
59
|
+
if (!scoped && root && owners.size === 0) ownersByRoot.delete(root);
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function usePublishedHeight(property: string) {
|
|
64
|
+
const owners = useContext(LaneOwnerContext);
|
|
65
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
const el = ref.current;
|
|
68
|
+
if (!el) return;
|
|
69
|
+
return publishLaneHeight(el, property, owners);
|
|
70
|
+
}, [owners, property]);
|
|
71
|
+
return ref;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function useSummaryLane(ref: { readonly current: HTMLElement | null }, active: boolean) {
|
|
75
|
+
const owners = useContext(LaneOwnerContext);
|
|
76
|
+
useLayoutEffect(() => {
|
|
77
|
+
const el = ref.current;
|
|
78
|
+
if (!active || !el) return;
|
|
79
|
+
return publishLaneHeight(el, '--reader-summary-height', owners);
|
|
80
|
+
}, [active, owners, ref]);
|
|
81
|
+
}
|
|
82
|
+
|
|
4
83
|
/** Publish real lane geometry; wrapped labels must not collide with statistics. */
|
|
5
84
|
export function StickyLane({ kind, className, children }: { kind: 'toolbar' | 'status'; className: string; children: ReactNode }) {
|
|
6
85
|
const ref = useRef<HTMLDivElement>(null);
|
|
@@ -10,7 +89,7 @@ export function StickyLane({ kind, className, children }: { kind: 'toolbar' | 's
|
|
|
10
89
|
if (!el || !scope) return;
|
|
11
90
|
const property = kind === 'toolbar' ? '--reader-toolbar-height' : '--reader-status-height';
|
|
12
91
|
const update = () => {
|
|
13
|
-
scope.style.setProperty(property, `${el.getBoundingClientRect().height}px`);
|
|
92
|
+
scope.style.setProperty(property, `${Math.max(MIN_LANE_PX, el.getBoundingClientRect().height)}px`);
|
|
14
93
|
if (kind !== 'toolbar') return;
|
|
15
94
|
// The status lane is sized as calc(100% - --reader-control-width), so this
|
|
16
95
|
// must reserve the WHOLE control group. Measuring only the first button
|
|
@@ -6,7 +6,7 @@ import { DiffBlock, DisclosureRow, JsonTree, ReadBlock, SearchBlock, TerminalBlo
|
|
|
6
6
|
import { Blocks, contentBlocks } from './Blocks.js';
|
|
7
7
|
import { OfficialTool } from './OfficialContent.js';
|
|
8
8
|
import { ProcessFragment } from './motion.js';
|
|
9
|
-
import { activityPhase, activitySummary, callDiffHunks, diffTotals, executionFacts, forgetCallClock, objectValue, runningClockBase
|
|
9
|
+
import { activityPhase, activitySummary, callDiffHunks, diffTotals, executionFacts, forgetCallClock, objectValue, runningClockBase } from './tool-activity.js';
|
|
10
10
|
import type { ToolActivityEntry, ToolCategory, ToolPhase } from './tool-activity.js';
|
|
11
11
|
import type { BlockRenderProps } from './types.js';
|
|
12
12
|
import { classifyTool, toolRowModel, VARIANT_TITLES } from './native/tool-call-model.js';
|
package/src/client/index.tsx
CHANGED
|
@@ -19,6 +19,7 @@ import { fileAddressFor, modeFromSnapshot, openDeliverableFile } from './open-fi
|
|
|
19
19
|
import type { ReaderInjected } from './types.js';
|
|
20
20
|
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
|
|
21
21
|
import { installOfficialSlots, officialChildren, type CompositionRegistry } from './official-slots.js';
|
|
22
|
+
import { TEXT_TRANSFORM_SERVICE, createTextTransformRegistry, installTextTransformRegistry } from './text-transform.js';
|
|
22
23
|
|
|
23
24
|
/** Structural face of the sanctioned per-session composer writer. */
|
|
24
25
|
interface ComposerShell {
|
|
@@ -54,8 +55,10 @@ async function openWorkspacePath(
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
export type { ReaderBlockOwner } from './types.js';
|
|
58
|
+
export type { ReaderBlockOwner, ReaderBlockPresentation } from './types.js';
|
|
59
|
+
export type { DisplayCursor, DisplayText, DisplayTextKind, DisplayTextRequest, DisplayTextTransformer, TextTransformRegistry } from './text-transform.js';
|
|
58
60
|
export { McpAppFrame } from './McpAppFrame.js';
|
|
61
|
+
export { TEXT_TRANSFORM_SERVICE, createTextTransformRegistry, originalAnswerText } from './text-transform.js';
|
|
59
62
|
export const name = 'dsh-better-display-client';
|
|
60
63
|
export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
|
|
61
64
|
|
|
@@ -63,6 +66,10 @@ export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'r
|
|
|
63
66
|
ensureStyles();
|
|
64
67
|
|
|
65
68
|
export function apply(ctx: Context): void {
|
|
69
|
+
const textTransform = createTextTransformRegistry();
|
|
70
|
+
const restoreTextTransform = installTextTransformRegistry(textTransform);
|
|
71
|
+
ctx.provide(TEXT_TRANSFORM_SERVICE, textTransform);
|
|
72
|
+
ctx.effect(() => restoreTextTransform);
|
|
66
73
|
const store = createReaderStore();
|
|
67
74
|
// conversation.view is session-scoped, so session persist keys are
|
|
68
75
|
// `dsh.reader.v1.<sessionId>`. One root instance keeps glass, fold
|
package/src/client/live-turn.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { AssistantChatData, ChatConversationViewNode } from '@deepseek-ai/d
|
|
|
2
2
|
import type { AssistantBlock } from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
3
3
|
import { assistantSegments, hasVisibleBody } from './projection.js';
|
|
4
4
|
import type { TurnBoundary } from './projection.js';
|
|
5
|
-
import { activitySummary, stringValue } from './tool-activity.js';
|
|
6
5
|
import type { ReaderFlowEntry, ToolActivityEntry } from './tool-activity.js';
|
|
7
6
|
|
|
8
7
|
export type LiveStep =
|
|
@@ -40,17 +39,6 @@ export function foldSummary(steps: readonly LiveStep[]): string {
|
|
|
40
39
|
return parts.join(' · ') || '此前步骤';
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
/** One chain: fold only when a new reasoning step has prior body/tool/reasoning. */
|
|
44
|
-
export function splitChain(chain: readonly LiveStep[]): { fold: readonly LiveStep[] | null; open: readonly LiveStep[] } {
|
|
45
|
-
const lastReasoning = chain.findLastIndex(step => step.kind === 'reasoning');
|
|
46
|
-
if (lastReasoning < 0) return { fold: null, open: chain };
|
|
47
|
-
const prior = chain.slice(0, lastReasoning);
|
|
48
|
-
if (!prior.length) return { fold: null, open: chain };
|
|
49
|
-
const trigger = prior.some(step => step.kind === 'reasoning' || step.kind === 'body' || step.kind === 'tool');
|
|
50
|
-
if (!trigger) return { fold: null, open: chain };
|
|
51
|
-
return { fold: prior, open: chain.slice(lastReasoning) };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
42
|
function skipReasoning(part: { kind: string; blocks: AssistantBlock[] }): boolean {
|
|
55
43
|
return part.kind === 'reasoning' && !part.blocks.some(block => block.kind === 'reasoning' && block.text.trim() !== '');
|
|
56
44
|
}
|
|
@@ -125,74 +113,117 @@ export function segmentLiveTurn(
|
|
|
125
113
|
return steps;
|
|
126
114
|
}
|
|
127
115
|
|
|
128
|
-
/**
|
|
129
|
-
function
|
|
130
|
-
|
|
131
|
-
const clip = (value: string | undefined, max = 38): string | undefined => {
|
|
132
|
-
if (!value) return undefined;
|
|
133
|
-
const flat = value.replace(/\s+/gu, ' ').trim();
|
|
134
|
-
if (!flat) return undefined;
|
|
135
|
-
return flat.length > max ? flat.slice(0, max - 1) + '\u2026' : flat;
|
|
136
|
-
};
|
|
137
|
-
const base = info.target ? clip(info.target.split(/[/\\]/u).at(-1), 28) : undefined;
|
|
138
|
-
const said = clip(stringValue(info.args, 'description'), 40);
|
|
139
|
-
switch (info.category) {
|
|
140
|
-
case 'read': return '读取 ' + (base ?? '文件');
|
|
141
|
-
case 'write': return clip(info.title, 40) ?? '写入文件';
|
|
142
|
-
case 'terminal': {
|
|
143
|
-
const cmd = clip(info.command, 32);
|
|
144
|
-
return said ?? (cmd ? '运行 ' + cmd : '运行命令');
|
|
145
|
-
}
|
|
146
|
-
case 'search': {
|
|
147
|
-
const needle = clip(stringValue(info.args, 'pattern', 'query'), 26);
|
|
148
|
-
return info.name === 'glob' ? '查找 ' + (needle ?? '文件') : '搜索 ' + (needle ?? '内容');
|
|
149
|
-
}
|
|
150
|
-
case 'web': {
|
|
151
|
-
const query = clip(info.target, 30);
|
|
152
|
-
return (info.name === 'web_search' ? '搜索网页' : '读取网页') + (query ? ' ' + query : '');
|
|
153
|
-
}
|
|
154
|
-
default: {
|
|
155
|
-
// Schema-less tools (run_code and friends) describe themselves in an
|
|
156
|
-
// argument rather than a known field: prefer the model's own
|
|
157
|
-
// description, then the first meaningful line of the code it ran.
|
|
158
|
-
if (said) return said;
|
|
159
|
-
const code = stringValue(info.args, 'code', 'source', 'script');
|
|
160
|
-
if (code) {
|
|
161
|
-
const line = code.split('\n').map(part => part.trim())
|
|
162
|
-
.find(part => part && !/^[)\]}]/.test(part) && !/^(\/\/|\*|\/\*|#)/.test(part));
|
|
163
|
-
if (line) return clip(line.replace(/\s+/gu, ' '), 44) ?? '运行代码';
|
|
164
|
-
}
|
|
165
|
-
return clip(info.title ?? info.name, 32) ?? '工具调用';
|
|
166
|
-
}
|
|
167
|
-
}
|
|
116
|
+
/** Only a run that contains real thinking/tool work is worth folding. */
|
|
117
|
+
function foldableRun(run: readonly LiveStep[]): boolean {
|
|
118
|
+
return run.some(step => step.kind === 'reasoning' || step.kind === 'tool');
|
|
168
119
|
}
|
|
169
120
|
|
|
121
|
+
/**
|
|
122
|
+
* One live chain: narration stays visible, while every earlier reasoning/tool
|
|
123
|
+
* run collapses into its own box at its original position.
|
|
124
|
+
*/
|
|
170
125
|
export function presentLiveTurn(steps: readonly LiveStep[], boundary: TurnBoundary, autoFold = true): LiveTurnItem[] {
|
|
171
126
|
const live = autoFold && liveFoldEnabled(boundary);
|
|
172
127
|
const items: LiveTurnItem[] = [];
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
176
|
-
if (
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
const { fold, open } = splitChain(chain);
|
|
182
|
-
if (fold?.length) {
|
|
183
|
-
items.push({ kind: 'fold', key: `live-fold:${chain[0]!.key}`, steps: fold, summary: foldSummary(fold) });
|
|
128
|
+
const open = (step: LiveStep) => items.push({ kind: 'open', key: step.key, step });
|
|
129
|
+
const fold = (run: readonly LiveStep[]) => {
|
|
130
|
+
if (!run.length) return;
|
|
131
|
+
if (live && foldableRun(run)) {
|
|
132
|
+
items.push({ kind: 'fold', key: `live-fold:${run[0]!.key}`, steps: run, summary: foldSummary(run) });
|
|
133
|
+
} else {
|
|
134
|
+
for (const step of run) open(step);
|
|
184
135
|
}
|
|
185
|
-
for (const step of open) items.push({ kind: 'open', key: step.key, step });
|
|
186
|
-
chain = [];
|
|
187
136
|
};
|
|
137
|
+
let run: LiveStep[] = [];
|
|
138
|
+
const flush = () => { fold(run); run = []; };
|
|
139
|
+
|
|
140
|
+
// A user insert resets the chain: steps before it are never folded by
|
|
141
|
+
// reasoning after it. Split into user-delimited segments and walk each one.
|
|
142
|
+
const segments: LiveStep[][] = [];
|
|
143
|
+
let segment: LiveStep[] = [];
|
|
188
144
|
for (const step of steps) {
|
|
189
145
|
if (step.kind === 'user') {
|
|
190
|
-
|
|
191
|
-
items.push({ kind: 'user', key: step.key, step });
|
|
146
|
+
segments.push(segment); segments.push([step]); segment = [];
|
|
192
147
|
} else {
|
|
193
|
-
|
|
148
|
+
segment.push(step);
|
|
194
149
|
}
|
|
195
150
|
}
|
|
196
|
-
|
|
151
|
+
segments.push(segment);
|
|
152
|
+
|
|
153
|
+
for (const part of segments) {
|
|
154
|
+
if (!part.length) continue;
|
|
155
|
+
const first = part[0]!;
|
|
156
|
+
if (part.length === 1 && first.kind === 'user') {
|
|
157
|
+
items.push({ kind: 'user', key: first.key, step: first });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (!live) {
|
|
161
|
+
for (const step of part) open(step);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// Narration is never folded. Once a later reasoning step exists, every
|
|
165
|
+
// reasoning/tool run before it folds at its own position, so the latest
|
|
166
|
+
// thinking and everything after it stay open while earlier steps collapse —
|
|
167
|
+
// even across narration blocks, which the reader still renders in place.
|
|
168
|
+
const lastReasoning = part.findLastIndex(step => step.kind === 'reasoning');
|
|
169
|
+
if (lastReasoning <= 0) {
|
|
170
|
+
for (const step of part) open(step);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
for (let index = 0; index < part.length; index++) {
|
|
174
|
+
const step = part[index]!;
|
|
175
|
+
if (index < lastReasoning) {
|
|
176
|
+
if (step.kind === 'body') { flush(); open(step); }
|
|
177
|
+
else run.push(step);
|
|
178
|
+
} else {
|
|
179
|
+
if (index === lastReasoning) flush();
|
|
180
|
+
open(step);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
flush();
|
|
184
|
+
}
|
|
197
185
|
return items;
|
|
198
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Summaries for collapsed process that sits between two narration blocks.
|
|
190
|
+
*
|
|
191
|
+
* A live-fold cell already shows that summary while the turn is open. Drawing
|
|
192
|
+
* the seam as well repeats the same counts, so a run whose every step is
|
|
193
|
+
* already inside a fold cell contributes neither a seam nor `after`. `after`
|
|
194
|
+
* is what marks the previous body with `data-reader-seam-after` and drops its
|
|
195
|
+
* bottom padding; skipping the seam must leave that spacing alone.
|
|
196
|
+
*/
|
|
197
|
+
export function planProcessSeams(
|
|
198
|
+
steps: readonly LiveStep[],
|
|
199
|
+
liveItems: readonly LiveTurnItem[],
|
|
200
|
+
expanded: boolean,
|
|
201
|
+
): { before: Map<string, string>; after: Set<string> } {
|
|
202
|
+
const before = new Map<string, string>();
|
|
203
|
+
const after = new Set<string>();
|
|
204
|
+
if (expanded) return { before, after };
|
|
205
|
+
const liveFolded = new Set(
|
|
206
|
+
liveItems.flatMap(item => item.kind === 'fold' ? item.steps.map(step => step.key) : []),
|
|
207
|
+
);
|
|
208
|
+
let previousBody: Extract<LiveStep, { kind: 'body' }> | undefined;
|
|
209
|
+
let process: LiveStep[] = [];
|
|
210
|
+
for (const step of steps) {
|
|
211
|
+
if (step.kind === 'body') {
|
|
212
|
+
const folded = process.filter(item => item.kind !== 'user');
|
|
213
|
+
const coveredByLiveFold = folded.length > 0 && folded.every(item => liveFolded.has(item.key));
|
|
214
|
+
if (!coveredByLiveFold && previousBody
|
|
215
|
+
&& folded.some(item => item.kind === 'reasoning' || item.kind === 'tool' || item.kind === 'other')) {
|
|
216
|
+
before.set(step.key, foldSummary(folded));
|
|
217
|
+
after.add(previousBody.key);
|
|
218
|
+
}
|
|
219
|
+
previousBody = step;
|
|
220
|
+
process = [];
|
|
221
|
+
} else if (step.kind === 'user') {
|
|
222
|
+
previousBody = undefined;
|
|
223
|
+
process = [];
|
|
224
|
+
} else {
|
|
225
|
+
process.push(step);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return { before, after };
|
|
229
|
+
}
|