dsh-better-display 0.2.1 → 0.3.2
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 +24 -0
- package/README.en.md +5 -3
- package/README.md +5 -3
- package/compat/harness-rc2.json +97 -0
- package/docs/official-rendering-bridge.md +65 -0
- package/lib/client.js +6476 -5977
- package/lib/client.js.map +1 -1
- package/lib/dsh-better-display.js +4 -2
- package/lib/types/client/Blocks.d.ts +2 -1
- package/lib/types/client/Blocks.d.ts.map +1 -1
- package/lib/types/client/Blocks.js +2 -2
- package/lib/types/client/Blocks.js.map +1 -1
- package/lib/types/client/OfficialContent.d.ts +25 -0
- package/lib/types/client/OfficialContent.d.ts.map +1 -0
- package/lib/types/client/OfficialContent.js +53 -0
- package/lib/types/client/OfficialContent.js.map +1 -0
- package/lib/types/client/Reader.d.ts.map +1 -1
- package/lib/types/client/Reader.js +42 -11
- package/lib/types/client/Reader.js.map +1 -1
- package/lib/types/client/ToolActivity.d.ts.map +1 -1
- package/lib/types/client/ToolActivity.js +23 -19
- package/lib/types/client/ToolActivity.js.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +23 -33
- package/lib/types/client/index.js.map +1 -1
- package/lib/types/client/markdown/MarkdownText.d.ts +3 -1
- package/lib/types/client/markdown/MarkdownText.d.ts.map +1 -1
- package/lib/types/client/markdown/MarkdownText.js +14 -7
- package/lib/types/client/markdown/MarkdownText.js.map +1 -1
- package/lib/types/client/markdown/render.d.ts +2 -0
- package/lib/types/client/markdown/render.d.ts.map +1 -1
- package/lib/types/client/markdown/render.js +22 -6
- package/lib/types/client/markdown/render.js.map +1 -1
- package/lib/types/client/motion.d.ts.map +1 -1
- package/lib/types/client/motion.js +22 -13
- package/lib/types/client/motion.js.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.d.ts +12 -3
- package/lib/types/client/native/ContextInjectionRow.d.ts.map +1 -1
- package/lib/types/client/native/ContextInjectionRow.js +7 -4
- package/lib/types/client/native/ContextInjectionRow.js.map +1 -1
- package/lib/types/client/native/ReferenceIcon.js +3 -3
- package/lib/types/client/native/ReferenceIcon.js.map +1 -1
- package/lib/types/client/native/tool-call-model.js +1 -1
- package/lib/types/client/native/tool-call-model.js.map +1 -1
- package/lib/types/client/official-slots.d.ts +71 -0
- package/lib/types/client/official-slots.d.ts.map +1 -0
- package/lib/types/client/official-slots.js +183 -0
- package/lib/types/client/official-slots.js.map +1 -0
- package/lib/types/client/platform-media.d.ts +6 -0
- package/lib/types/client/platform-media.d.ts.map +1 -0
- package/lib/types/client/platform-media.js +13 -0
- package/lib/types/client/platform-media.js.map +1 -0
- package/lib/types/client/primitive-labels.d.ts.map +1 -1
- package/lib/types/client/primitive-labels.js +3 -2
- package/lib/types/client/primitive-labels.js.map +1 -1
- package/lib/types/client/settings.js.map +1 -1
- package/lib/types/client/store.d.ts +1 -0
- package/lib/types/client/store.d.ts.map +1 -1
- package/lib/types/client/store.js +1 -0
- package/lib/types/client/store.js.map +1 -1
- package/lib/types/client/tool-activity.d.ts +1 -1
- package/lib/types/client/tool-activity.d.ts.map +1 -1
- package/lib/types/client/tool-activity.js +11 -4
- package/lib/types/client/tool-activity.js.map +1 -1
- package/lib/types/client/types.d.ts +18 -8
- package/lib/types/client/types.d.ts.map +1 -1
- package/lib/types/client/word-motion.d.ts.map +1 -1
- package/lib/types/client/word-motion.js +2 -1
- package/lib/types/client/word-motion.js.map +1 -1
- package/package.json +23 -17
- package/scripts/check-harness-compat.mjs +71 -0
- package/scripts/link-harness-dependencies.mjs +1 -0
- package/scripts/verify-auto-fold.mjs +114 -0
- package/scripts/verify-official-bridge.mjs +121 -0
- package/src/client/Blocks.tsx +2 -1
- package/src/client/OfficialContent.tsx +69 -0
- package/src/client/Reader.module.css +35 -8
- package/src/client/Reader.tsx +44 -17
- package/src/client/ToolActivity.tsx +24 -29
- package/src/client/index.tsx +27 -31
- package/src/client/markdown/MarkdownText.tsx +14 -6
- package/src/client/markdown/render.tsx +23 -7
- package/src/client/motion.tsx +18 -11
- package/src/client/native/ContextInjectionRow.tsx +21 -9
- package/src/client/native/ReferenceIcon.tsx +3 -3
- package/src/client/native/tool-call-model.ts +1 -1
- package/src/client/official-slots.tsx +211 -0
- package/src/client/platform-media.ts +11 -0
- package/src/client/primitive-labels.ts +3 -2
- package/src/client/settings.tsx +1 -1
- package/src/client/store.ts +2 -0
- package/src/client/tool-activity.ts +11 -4
- package/src/client/types.ts +14 -8
- package/src/client/word-motion.tsx +2 -1
- package/tests/browser-auto-fold.tsx +64 -0
- package/tests/browser-official-bridge.tsx +152 -0
- package/tests/official-slots.test.ts +112 -0
- package/tests/platform-media.test.ts +17 -0
package/src/client/Reader.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
|
6
6
|
import { BlockBoundary, Blocks, contentBlocks, CopyAnswer, UserMessageActions } from './Blocks.js';
|
|
7
7
|
import { ReasoningCard } from './ReasoningCard.js';
|
|
8
8
|
import { ToolActivity, ToolMedia } from './ToolActivity.js';
|
|
9
|
+
import { OfficialActions, OfficialNode, OfficialTail } from './OfficialContent.js';
|
|
9
10
|
import { preparingLabel, readerFlow } from './tool-activity.js';
|
|
10
11
|
import { Disclosure, ProcessFragment, RetiringContent, StatusText, useMotionAllowed, usePinnedSelection, useReadingScroll } from './motion.js';
|
|
11
12
|
import { StreamMotionContext } from './streaming.js';
|
|
@@ -181,7 +182,7 @@ const AssistantNode = memo(function AssistantNode({ useChat, nodeKey, boundary,
|
|
|
181
182
|
// of its own; passing it would fork the whole session instead.
|
|
182
183
|
const anchor = forkAnchorSeq([data.finalNode, { seq: render.forkSeq }]);
|
|
183
184
|
return render.forkAt && anchor !== undefined ? () => render.forkAt!(anchor) : undefined;
|
|
184
|
-
})()} metrics={render.metrics} />
|
|
185
|
+
})()} metrics={render.metrics} extraActions={<OfficialActions official={render.official} messageId={data.finalNode?.messageId} />} />
|
|
185
186
|
)}
|
|
186
187
|
</article>
|
|
187
188
|
</RetiringContent>;
|
|
@@ -226,6 +227,7 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
|
|
|
226
227
|
if (isNode(node, 'turn-max-tokens')) return <div className={css.notice}>已到达输出长度限制,回答尚未完整。</div>;
|
|
227
228
|
if (isNode(node, 'model-retry')) return node.data.current.retryState === 'scheduled'
|
|
228
229
|
? <div className={css.notice} role="status">模型请求未成功,正在等待重试。详情保留在执行过程中。</div> : null;
|
|
230
|
+
if (isNode(node, 'command') && render.official) return <OfficialNode {...render} node={node} fallback={<JsonBlock label="命令记录" payload={node.data} truncatedLabel={truncatedJsonLabel} />} />;
|
|
229
231
|
if (isNode(node, 'command')) {
|
|
230
232
|
if (node.data.outcome?.kind === 'error') return <div className={css.error} role="alert">
|
|
231
233
|
<svg className={css.errorIcon} viewBox="0 0 16 16" fill="none" stroke="currentColor">
|
|
@@ -245,10 +247,11 @@ const MainNode = memo(function MainNode({ useChat, nodeKey, boundary, pinned, pr
|
|
|
245
247
|
// host (it appears nowhere in the installed packages), so a slash command arrives
|
|
246
248
|
// as 'command' above and is rendered there. Keeping the branch only made the file
|
|
247
249
|
// look like it handled a case that cannot occur.
|
|
248
|
-
return <div className={css.unknown} data-reader-anchor>
|
|
250
|
+
return <OfficialNode {...render} node={node} fallback={<div className={css.unknown} data-reader-anchor>
|
|
249
251
|
<p>此记录类型暂未接入阅读页:{node.kind}</p>
|
|
252
|
+
{render.official && <button type="button" className={css.textButton} onClick={() => render.official!.openView('chat', node.key)}>在对话中查看</button>}
|
|
250
253
|
<JsonBlock label="查看原始记录" payload={node.data} truncatedLabel={truncatedJsonLabel} />
|
|
251
|
-
</div
|
|
254
|
+
</div>} />;
|
|
252
255
|
});
|
|
253
256
|
|
|
254
257
|
/**
|
|
@@ -269,8 +272,7 @@ function subagentCount(snapshot: { nodes: { get(key: string): ChatConversationVi
|
|
|
269
272
|
return 0;
|
|
270
273
|
}
|
|
271
274
|
|
|
272
|
-
function GroupStatus({ group, sessionId, useChat,
|
|
273
|
-
const pending = useSessionPendingInteraction(snapshot => snapshot.get(sessionId));
|
|
275
|
+
function GroupStatus({ group, sessionId, useChat, pending, motion }: Pick<ReaderProps, 'sessionId' | 'useChat'> & { group: ReaderGroup; motion: boolean; pending: { kind?: string } | undefined }) {
|
|
274
276
|
const text = useChat(snapshot => {
|
|
275
277
|
const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
|
|
276
278
|
if (turn?.status === 'closed') {
|
|
@@ -474,7 +476,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
474
476
|
const snapshot = props.useChat(snapshot => snapshot);
|
|
475
477
|
const nodes = snapshot.nodes;
|
|
476
478
|
const turn = group.turn === null ? undefined : snapshot.timeline.turns.get(group.turn);
|
|
477
|
-
const interaction = props.
|
|
479
|
+
const interaction = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
|
|
478
480
|
const sessionRunning = props.useSession(snapshot => snapshot.running);
|
|
479
481
|
const boundary = useMemo(() => boundaryOf(turn), [turn]);
|
|
480
482
|
const choiceKey = processChoiceKey(group.key, boundary);
|
|
@@ -504,11 +506,11 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
504
506
|
: item.key === key || ('nodeKey' in item.step && item.step.nodeKey === key)));
|
|
505
507
|
const expanded = !autoFold || holdingSelection || processExpanded(expansionChoice, boundary);
|
|
506
508
|
const [foldOpenByKey, setFoldOpenByKey] = useState<Record<string, boolean>>({});
|
|
509
|
+
useLayoutEffect(() => {
|
|
510
|
+
// Restoring automatic folding also releases live "prior steps" disclosures.
|
|
511
|
+
if (autoFold) setFoldOpenByKey(current => Object.keys(current).length ? {} : current);
|
|
512
|
+
}, [autoFold]);
|
|
507
513
|
const deliverables = useMemo(() => getTurnDeliverables(turn, flow), [turn, flow]);
|
|
508
|
-
const fileMentions = useMemo(
|
|
509
|
-
() => deliverables.length > 0 && props.openFile ? createProducedFileMentions(deliverables, props.openFile) : undefined,
|
|
510
|
-
[deliverables, props.openFile],
|
|
511
|
-
);
|
|
512
514
|
const tailData = useMemo(() => {
|
|
513
515
|
for (const key of group.keys) {
|
|
514
516
|
const n = nodes.get(key);
|
|
@@ -516,12 +518,27 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
516
518
|
}
|
|
517
519
|
return undefined;
|
|
518
520
|
}, [group.keys, nodes]);
|
|
521
|
+
const tailOwner = useMemo(() => turn && tailData ? {
|
|
522
|
+
turn, seq: tailData.closing?.finalNode.seq ?? tailData.seq, openFile: props.openFile,
|
|
523
|
+
} : undefined, [turn, tailData, props.openFile]);
|
|
524
|
+
const fileMentions = useMemo(() => {
|
|
525
|
+
const official = tailOwner ? props.officialFileMentions(tailOwner) : undefined;
|
|
526
|
+
const reader = deliverables.length > 0 ? createProducedFileMentions(deliverables, props.openFile) : undefined;
|
|
527
|
+
return official || reader ? { resolve: (value: string) => official?.resolve(value) ?? reader?.resolve(value) } : undefined;
|
|
528
|
+
}, [tailOwner, props.officialFileMentions, deliverables, props.openFile]);
|
|
529
|
+
const official = useMemo(() => ({
|
|
530
|
+
renderSlot: props.renderSlot, renderSlotChain: props.renderSlotChain,
|
|
531
|
+
officialImageLoader: props.officialImageLoader, officialFileMentions: props.officialFileMentions,
|
|
532
|
+
officialPreviewFile: props.officialPreviewFile,
|
|
533
|
+
officialHost: props.officialHost, openView: props.openView,
|
|
534
|
+
}), [props.renderSlot, props.renderSlotChain, props.officialImageLoader, props.officialFileMentions, props.officialPreviewFile, props.officialHost, props.openView]);
|
|
535
|
+
const cwd = props.useSessions(state => state.byId[props.sessionId]?.cwd);
|
|
519
536
|
const runMs = turn?.start && turn?.end ? Math.max(0, turn.end.time - turn.start.time) : undefined;
|
|
520
537
|
const metrics = useMemo(() => ({
|
|
521
538
|
usage: tailData?.tokenUsage,
|
|
522
539
|
runMs,
|
|
523
|
-
tokensPerSecond: tailData?.tokensPerSecond,
|
|
524
|
-
ttftMs: tailData?.ttftMs,
|
|
540
|
+
tokensPerSecond: (tailData as { tokensPerSecond?: number } | undefined)?.tokensPerSecond,
|
|
541
|
+
ttftMs: (tailData as { ttftMs?: number } | undefined)?.ttftMs,
|
|
525
542
|
endedAt: tailData?.closing?.time ?? turn?.end?.time,
|
|
526
543
|
}), [tailData, runMs, turn?.end?.time]);
|
|
527
544
|
const forkSeq = forkAnchorSeq([tailData?.closing?.finalNode]);
|
|
@@ -545,7 +562,8 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
545
562
|
forkSeq,
|
|
546
563
|
fileMentions,
|
|
547
564
|
metrics,
|
|
548
|
-
|
|
565
|
+
official,
|
|
566
|
+
cwd,
|
|
549
567
|
};
|
|
550
568
|
const terminal = terminalLabel(boundary.reason);
|
|
551
569
|
const hasTurnError = flow.some(item => item.kind === 'node' && nodes.get(item.nodeKey)?.kind === 'turn-error');
|
|
@@ -575,7 +593,7 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
575
593
|
{startsWithUser && <BlockBoundary><MainNode {...shared} useChat={props.useChat} boundary={boundary} nodeKey={group.keys[0]} /></BlockBoundary>}
|
|
576
594
|
{hasProcess && !isAwaitingModel && <StickyLane kind="status" className={css.turnProcessSticky}>
|
|
577
595
|
<Disclosure open={expanded} onChange={setExpanded} controls={flowId} buttonRef={processButton}
|
|
578
|
-
label={<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat}
|
|
596
|
+
label={<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />} />
|
|
579
597
|
</StickyLane>}
|
|
580
598
|
{boundary.status === 'closed' && hasProcess && autoFold && <ClosedProcessSummary open={expanded} onChange={setExpanded} controls={flowId}
|
|
581
599
|
steps={steps.filter(step => {
|
|
@@ -591,9 +609,10 @@ const TurnGroup = memo(function TurnGroup({ group, motion, autoFold, pinnedKeys,
|
|
|
591
609
|
onOpenChange={(key, value) => { pinProcess(); setFoldOpenByKey(current => ({ ...current, [key]: value })); }} renderStep={renderStep} />
|
|
592
610
|
{/* 状态指示永远排在流程之后:AI 的响应永远出现在最新消息(含补充消息)的下方 */}
|
|
593
611
|
{!hasProcess && boundary.status === 'open' && !isAwaitingModel && <div className={css.disclosure} data-reader-status-only>
|
|
594
|
-
<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat}
|
|
612
|
+
<GroupStatus group={group} sessionId={props.sessionId} useChat={props.useChat} pending={interaction} motion={motion} />
|
|
595
613
|
</div>}
|
|
596
614
|
{showDeliverablesRow(boundary.status, deliverables) && <DeliverablesRow deliverables={deliverables} openFile={props.openFile} revealFile={props.revealFile} openMode={openMode} />}
|
|
615
|
+
{boundary.status === 'closed' && <BlockBoundary><OfficialTail official={official} owner={tailOwner} produced={deliverables} /></BlockBoundary>}
|
|
597
616
|
{showTerminalNotice && <div className={css.notice} data-reader-terminal>{terminal}</div>}
|
|
598
617
|
</section>;
|
|
599
618
|
});
|
|
@@ -605,7 +624,7 @@ export function Reader(props: ReaderProps) {
|
|
|
605
624
|
const nodes = props.useChat(snapshot => snapshot.nodes);
|
|
606
625
|
const timeline = props.useChat(snapshot => snapshot.timeline);
|
|
607
626
|
const running = props.useSession(snapshot => snapshot.running);
|
|
608
|
-
const pending = props.
|
|
627
|
+
const pending = props.useSessionStatus(snapshot => snapshot.get(props.sessionId)?.pendingInteraction);
|
|
609
628
|
const openError = props.useSession(snapshot => snapshot.openError);
|
|
610
629
|
const loading = props.useSession(snapshot => snapshot.openState === 'loading');
|
|
611
630
|
const hasMore = props.useSession(snapshot => snapshot.hasMore);
|
|
@@ -622,6 +641,14 @@ export function Reader(props: ReaderProps) {
|
|
|
622
641
|
);
|
|
623
642
|
const storeAutoFold = props.useStore(state => state.autoFold);
|
|
624
643
|
const autoFold = prefsSnap?.autoFold ?? (prefsSnap?.foldIntensity !== undefined ? prefsSnap.foldIntensity !== 0 : undefined) ?? storeAutoFold ?? true;
|
|
644
|
+
const previousAutoFold = useRef(autoFold);
|
|
645
|
+
useLayoutEffect(() => {
|
|
646
|
+
const restored = autoFold && !previousAutoFold.current;
|
|
647
|
+
previousAutoFold.current = autoFold;
|
|
648
|
+
// Watch the effective preference so toolbar and Settings changes behave alike.
|
|
649
|
+
// Reading while folding is off must not pin completed process content forever.
|
|
650
|
+
if (restored) props.actions.resetExpanded();
|
|
651
|
+
}, [autoFold, props.actions]);
|
|
625
652
|
const frostedGlass = frostedGlassOf(prefsSnap);
|
|
626
653
|
const streamMotion = useMemo(() => ({ enabled: motion, activatedAt: activatedAt.current }), [motion]);
|
|
627
654
|
const groups = useMemo(() => groupNodes(order, key => nodes.get(key)), [order, nodes, timeline]);
|
|
@@ -788,7 +815,7 @@ export function Reader(props: ReaderProps) {
|
|
|
788
815
|
|
|
789
816
|
// ChatView publishes data-chat-flow="" on its column. Skins treat a
|
|
790
817
|
// scrollport without that hook as inspect-only and hide [data-composer-seat].
|
|
791
|
-
return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-dsh-better-display="0.
|
|
818
|
+
return <StreamMotionContext.Provider value={streamMotion}><div ref={root} className={css.root} data-reader-build="0.3.1" data-dsh-better-display="0.3.1" 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'}>
|
|
792
819
|
<TimelineRail items={timelineItems} activeTurn={activeTurn} busyTurn={busyTurn} onNavigate={onNavigateTurn} />
|
|
793
820
|
<div className={css.column} data-chat-flow="">
|
|
794
821
|
<StickyLane kind="toolbar" className={css.toolbar}>
|
|
@@ -2,8 +2,9 @@ import { memo, useEffect, useId, useMemo, useRef, useState } from 'react';
|
|
|
2
2
|
import type { ToolCallBlock, ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client';
|
|
3
3
|
import type { DiffHunk, ReadBlockLine, SearchFileGroup } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
4
4
|
import { DiffBlock, DisclosureRow, JsonTree, ReadBlock, SearchBlock, TerminalBlock, WebBlock,
|
|
5
|
-
|
|
5
|
+
IconApiOutlineRegular, IconBrowseOutlineRegular, IconEditOutlineRegular, IconSearchOutlineRegular, IconSkillOutlineRegular, IconSparkleRegular } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
6
6
|
import { Blocks, contentBlocks } from './Blocks.js';
|
|
7
|
+
import { OfficialTool } from './OfficialContent.js';
|
|
7
8
|
import { ProcessFragment } from './motion.js';
|
|
8
9
|
import { activityPhase, activitySummary, callDiffHunks, diffTotals, executionFacts, objectValue, toolIdentity } from './tool-activity.js';
|
|
9
10
|
import type { ToolActivityEntry, ToolCategory, ToolPhase } from './tool-activity.js';
|
|
@@ -14,7 +15,7 @@ import { diffBlockLabels, jsonTreeLabels, readBlockLabels, searchBlockLabels, te
|
|
|
14
15
|
import css from './Reader.module.css';
|
|
15
16
|
|
|
16
17
|
const LABEL: Record<ToolPhase, string> = { preparing: '输入生成中', running: '执行中', returned: '已返回', succeeded: '已完成', failed: '失败', interrupted: '已中断' };
|
|
17
|
-
const ICONS = { write:
|
|
18
|
+
const ICONS = { write: IconEditOutlineRegular, read: IconBrowseOutlineRegular, terminal: IconApiOutlineRegular, search: IconSearchOutlineRegular, web: IconSearchOutlineRegular, code: IconApiOutlineRegular, other: IconSparkleRegular } satisfies Record<ToolCategory, unknown>;
|
|
18
19
|
const number = new Intl.NumberFormat('zh-CN');
|
|
19
20
|
const language = (path: string | undefined) => path?.split('.').at(-1);
|
|
20
21
|
const duration = (ms: number) => ms < 1000 ? `${Math.round(ms)} 毫秒` : `${(ms / 1000).toFixed(ms < 10000 ? 1 : 0)} 秒`;
|
|
@@ -80,28 +81,14 @@ function searchFiles(value: unknown): SearchFileGroup[] | null {
|
|
|
80
81
|
return files;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
cwd
|
|
88
|
-
openFile?: (path: string) => Promise<void> | void;
|
|
89
|
-
}) {
|
|
90
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
// If the component renders a collapsed disclosure row (like DiffCard with role="button" and aria-expanded="false"),
|
|
93
|
-
// expand it automatically once so details are immediately visible inside ResultView.
|
|
94
|
-
const row = containerRef.current?.querySelector<HTMLElement>('[role="button"][aria-expanded="false"]');
|
|
95
|
-
if (row) row.click();
|
|
96
|
-
}, []);
|
|
97
|
-
return (
|
|
98
|
-
<div ref={containerRef} data-reader-tool-custom>
|
|
99
|
-
<Component block={block} toolName={toolName} cwd={cwd} openFile={openFile} />
|
|
100
|
-
</div>
|
|
101
|
-
);
|
|
84
|
+
function ResultView(props: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
|
|
85
|
+
const { official, entry, model } = props;
|
|
86
|
+
const fallback = <ResultFallback {...props} />;
|
|
87
|
+
if (!official || !entry.block || model.name === 'render_ui' || model.name === 'show_widget') return fallback;
|
|
88
|
+
return <OfficialTool {...props} official={official} block={entry.block} toolName={model.name} cwd={props.cwd ?? model.cwd} fallback={fallback} />;
|
|
102
89
|
}
|
|
103
90
|
|
|
104
|
-
function
|
|
91
|
+
function ResultFallback({ entry, model, phase, ...render }: BlockRenderProps & { entry: ToolActivityEntry; model: ReturnType<typeof activitySummary>; phase: ToolPhase }) {
|
|
105
92
|
if ((model.name === 'render_ui' || model.name === 'show_widget') && typeof model.args?.html === 'string') {
|
|
106
93
|
return <McpAppFrame html={model.args.html as string} title={typeof model.args.title === 'string' ? (model.args.title as string) : undefined} fillComposer={render.fillComposer} />;
|
|
107
94
|
}
|
|
@@ -114,11 +101,6 @@ function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { ent
|
|
|
114
101
|
const text = block.content.filter(item => item.type === 'text').map(item => item.text).join('\n');
|
|
115
102
|
if (phase === 'interrupted') return <><p className={css.toolDetailNote}>工具已取消,未正常完成。输入和原始返回记录仍可查看。</p><InputView model={model} preparing={false} fillComposer={render.fillComposer} /><pre className={css.toolRaw}>{text}</pre></>;
|
|
116
103
|
|
|
117
|
-
const CustomToolView = render.getToolView?.(model.name);
|
|
118
|
-
if (CustomToolView) {
|
|
119
|
-
return <CustomToolWrapper Component={CustomToolView} block={block} toolName={model.name} cwd={model.cwd} openFile={render.openFile} />;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
104
|
if (model.category === 'terminal') {
|
|
123
105
|
const facts = executionFacts(block);
|
|
124
106
|
const output = text.replace(/\n\[(?:exit code: \d+|killed by signal: [^\]\n]+)\]$/, '');
|
|
@@ -154,6 +136,19 @@ function ResultView({ entry, model, phase, ...render }: BlockRenderProps & { ent
|
|
|
154
136
|
{generatedInput(model.content, model.target, false)}
|
|
155
137
|
</>;
|
|
156
138
|
const content: ToolResultNode['content'] = block.content;
|
|
139
|
+
// A code interpreter's product is its result, not prose. Without this branch
|
|
140
|
+
// the output fell through to the generic document path below, which emits it
|
|
141
|
+
// as reader answer text — so a run_code card sat in the transcript outside
|
|
142
|
+
// every fold, the exact block the reader wanted to be able to close.
|
|
143
|
+
if (model.category === 'code') {
|
|
144
|
+
const output = content.filter(item => item.type === 'text').map(item => item.text).join('\n');
|
|
145
|
+
const facts = executionFacts(block);
|
|
146
|
+
if (output.trim()) return <div data-reader-tool-code>
|
|
147
|
+
<TerminalBlock command={model.command ?? model.name} cwd={model.cwd} output={output}
|
|
148
|
+
exitCode={facts.exitCode} signal={facts.signal} maxLines={18} labels={terminalBlockLabels} />
|
|
149
|
+
</div>;
|
|
150
|
+
return <p className={css.toolDetailNote}>代码已执行,没有可展示的输出。</p>;
|
|
151
|
+
}
|
|
157
152
|
if (content.some(item => item.type === 'text')) return <div className={css.toolDocument}><Blocks {...render} blocks={contentBlocks(content).filter(item => item.kind === 'text')} source="tool" /></div>;
|
|
158
153
|
if (content.length) return <p className={css.toolDetailNote}>图片或扩展内容已在对话中单独展示。</p>;
|
|
159
154
|
return <p className={css.toolDetailNote}>工具没有返回可展示的内容。</p>;
|
|
@@ -184,7 +179,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
|
|
|
184
179
|
return () => document.removeEventListener('selectionchange', track);
|
|
185
180
|
}, []);
|
|
186
181
|
const facts = executionFacts(entry.block);
|
|
187
|
-
const Icon = model.name === 'skill' ?
|
|
182
|
+
const Icon = model.name === 'skill' ? IconSkillOutlineRegular : ICONS[model.category];
|
|
188
183
|
const block = entry.block;
|
|
189
184
|
const native = block ? toolRowModel(model.name, block) : null;
|
|
190
185
|
const skillName = typeof model.args?.name === 'string' ? model.args.name.split('\n')[0] : model.raw.split('\n')[0];
|
|
@@ -265,7 +260,7 @@ export const ToolActivity = memo(function ToolActivityView({ entry, motion, turn
|
|
|
265
260
|
&& previous.entry.draft === next.entry.draft && previous.entry.step === next.entry.step
|
|
266
261
|
&& previous.motion === next.motion && previous.turnClosed === next.turnClosed && previous.depth === next.depth
|
|
267
262
|
&& previous.onRead === next.onRead && previous.renderSlotChain === next.renderSlotChain && previous.loadImage === next.loadImage && previous.fillComposer === next.fillComposer
|
|
268
|
-
&& previous.
|
|
263
|
+
&& previous.official === next.official && previous.cwd === next.cwd && previous.openFile === next.openFile);
|
|
269
264
|
|
|
270
265
|
/** Rich media (images, MCP widgets) rendered outside the folded tool ledger. */
|
|
271
266
|
export function ToolMedia({ block, depth = 0, ...render }: BlockRenderProps & { block: ToolCallBlock; depth?: number }) {
|
package/src/client/index.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import { installBetterDisplaySettings } from './settings.js';
|
|
|
13
13
|
import { fillComposerDom } from './mcp-app.js';
|
|
14
14
|
import { fileAddressFor, modeFromSnapshot, openDeliverableFile } from './open-file.js';
|
|
15
15
|
import type { ReaderInjected } from './types.js';
|
|
16
|
+
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment';
|
|
17
|
+
import { installOfficialSlots, officialChildren, type CompositionRegistry } from './official-slots.js';
|
|
16
18
|
|
|
17
19
|
/** Structural face of the sanctioned per-session composer writer. */
|
|
18
20
|
interface ComposerShell {
|
|
@@ -51,7 +53,7 @@ async function openWorkspacePath(
|
|
|
51
53
|
export type { ReaderBlockOwner } from './types.js';
|
|
52
54
|
export { McpAppFrame } from './McpAppFrame.js';
|
|
53
55
|
export const name = 'dsh-better-display-client';
|
|
54
|
-
export const inject = ['slots', 'sessions', 'conversation', 'remote', 'remote.session'];
|
|
56
|
+
export const inject = ['slots', 'sessions', 'conversation', 'uiConversation', 'remote', 'remote.session'];
|
|
55
57
|
|
|
56
58
|
export function apply(ctx: Context): void {
|
|
57
59
|
const store = createReaderStore();
|
|
@@ -60,14 +62,17 @@ export function apply(ctx: Context): void {
|
|
|
60
62
|
// intensity, and open-mode on the unsuffixed `dsh.reader.v1` key.
|
|
61
63
|
const prefs = store.create();
|
|
62
64
|
installBetterDisplaySettings(ctx, prefs);
|
|
63
|
-
ctx.slots.inject('conversation.
|
|
65
|
+
ctx.slots.inject('conversation.chat.node', function* () {
|
|
64
66
|
yield ctx.slots.register({
|
|
65
67
|
name: 'conversation.view',
|
|
66
68
|
id: 'reader',
|
|
67
69
|
order: -5,
|
|
68
70
|
label: () => '阅读',
|
|
69
71
|
locale: 'chat',
|
|
70
|
-
children: {
|
|
72
|
+
children: {
|
|
73
|
+
'dsh-better-display.block': { kind: 'chain', scope: 'session' },
|
|
74
|
+
...officialChildren(ctx.slots as unknown as CompositionRegistry),
|
|
75
|
+
},
|
|
71
76
|
store,
|
|
72
77
|
inject: (sessionId: SessionId): ReaderInjected => {
|
|
73
78
|
const session = () => {
|
|
@@ -77,13 +82,28 @@ export function apply(ctx: Context): void {
|
|
|
77
82
|
};
|
|
78
83
|
return {
|
|
79
84
|
openPrefs: prefs,
|
|
85
|
+
officialImageLoader: Object.assign(
|
|
86
|
+
(attachment: ImageAttachmentRef) => ctx.uiConversation.imageUrl(sessionId, attachment),
|
|
87
|
+
{ peek: (attachment: ImageAttachmentRef) => ctx.uiConversation.peekImageUrl(sessionId, attachment) },
|
|
88
|
+
),
|
|
89
|
+
officialFileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner, sessionId),
|
|
90
|
+
officialPreviewFile: path => {
|
|
91
|
+
const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd;
|
|
92
|
+
const sidebar = ctx.get('sidebarRight') as SidebarRightFace | undefined;
|
|
93
|
+
if (!sidebar?.openResource) throw new Error('文件预览面板不可用。');
|
|
94
|
+
sidebar.openResource((officialFileAddressFor ?? fileAddressFor)(sessionId, cwd, path));
|
|
95
|
+
},
|
|
96
|
+
officialHost: {
|
|
97
|
+
getSnapshot: () => ctx.remote.$host,
|
|
98
|
+
subscribe: listener => ctx.on('connection/reset', listener),
|
|
99
|
+
},
|
|
80
100
|
loadOlder: async () => { await session().loadOlder(); },
|
|
81
101
|
loadImage: async attachment => {
|
|
82
102
|
const receipt = await session().readAttachment(attachment.attachmentId);
|
|
83
103
|
if (!receipt.ok) throw new Error(receipt.error.message);
|
|
84
104
|
return { data: Uint8Array.from(receipt.value.data), mediaType: receipt.value.attachment.mediaType };
|
|
85
105
|
},
|
|
86
|
-
openFile: async (path
|
|
106
|
+
openFile: async (path, options) => {
|
|
87
107
|
try {
|
|
88
108
|
const cwd = ctx.sessions?.list?.getSnapshot?.()?.byId[sessionId]?.cwd;
|
|
89
109
|
const sidebar = (
|
|
@@ -98,7 +118,7 @@ export function apply(ctx: Context): void {
|
|
|
98
118
|
resolveWorkspacePath,
|
|
99
119
|
openExternal: async (absolutePath) => { await openWorkspacePath(ctx, absolutePath); },
|
|
100
120
|
openSidebar: typeof sidebar?.openResource === 'function'
|
|
101
|
-
? (address) => { sidebar.openResource!(address); }
|
|
121
|
+
? (address) => { sidebar.openResource!(address, options?.line === undefined ? undefined : { params: { line: options.line } }); }
|
|
102
122
|
: undefined,
|
|
103
123
|
fileAddressFor: officialFileAddressFor ?? fileAddressFor,
|
|
104
124
|
warn: (message, extra) => { console.warn(message, extra); },
|
|
@@ -190,35 +210,11 @@ export function apply(ctx: Context): void {
|
|
|
190
210
|
return false;
|
|
191
211
|
}
|
|
192
212
|
},
|
|
193
|
-
|
|
194
|
-
try {
|
|
195
|
-
const slotsService = ctx.slots as unknown as { entriesOfSlot?: (name: string) => unknown[] };
|
|
196
|
-
const entries = slotsService?.entriesOfSlot?.('tool.call.toolview') ?? [];
|
|
197
|
-
const matches = entries.filter((e: any) => {
|
|
198
|
-
const key = e?.options?.key ?? e?.key;
|
|
199
|
-
const comp = e?.component ?? e?.view ?? e?.render ?? (typeof e === 'function' ? e : null);
|
|
200
|
-
return key === toolName && typeof comp === 'function';
|
|
201
|
-
});
|
|
202
|
-
if (matches.length === 0) return null;
|
|
203
|
-
matches.sort((a: any, b: any) => {
|
|
204
|
-
const prioA = a?.options?.priority ?? a?.priority ?? 0;
|
|
205
|
-
const prioB = b?.options?.priority ?? b?.priority ?? 0;
|
|
206
|
-
return prioA - prioB;
|
|
207
|
-
});
|
|
208
|
-
const best = matches[0] as any;
|
|
209
|
-
const prio = best?.options?.priority ?? best?.priority ?? 0;
|
|
210
|
-
const comp = best?.component ?? best?.view ?? best?.render ?? (typeof best === 'function' ? best : null);
|
|
211
|
-
if (prio < 0 || (toolName !== 'edit' && toolName !== 'write')) {
|
|
212
|
-
return comp;
|
|
213
|
-
}
|
|
214
|
-
return null;
|
|
215
|
-
} catch {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
},
|
|
213
|
+
|
|
219
214
|
};
|
|
220
215
|
},
|
|
221
216
|
}, Reader);
|
|
217
|
+
yield installOfficialSlots(ctx);
|
|
222
218
|
yield installReaderEntry(ctx);
|
|
223
219
|
});
|
|
224
220
|
}
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { memo, useMemo, useRef } from 'react'
|
|
16
16
|
import type { ReactNode } from 'react'
|
|
17
|
+
import type { MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
17
18
|
import { IncrementalMarkdownParser } from './incremental.js'
|
|
18
19
|
import { parseGfm, parseGfmWithMath } from './parse.js'
|
|
19
20
|
import {
|
|
@@ -31,6 +32,7 @@ function renderSettled(
|
|
|
31
32
|
text: string,
|
|
32
33
|
codeLabels: MarkdownCodeLabels | undefined,
|
|
33
34
|
fileMentions: MarkdownFileMentions | undefined,
|
|
35
|
+
pathImages: MarkdownPathImages | undefined,
|
|
34
36
|
renderText: MarkdownRenderContext['renderText'],
|
|
35
37
|
renderAtom: MarkdownRenderContext['renderAtom'],
|
|
36
38
|
): ReactNode[] {
|
|
@@ -43,6 +45,7 @@ function renderSettled(
|
|
|
43
45
|
streaming: false,
|
|
44
46
|
codeLabels,
|
|
45
47
|
fileMentions,
|
|
48
|
+
pathImages,
|
|
46
49
|
targets,
|
|
47
50
|
footnoteOrder: [],
|
|
48
51
|
footnoteCounts: new Map(),
|
|
@@ -73,7 +76,7 @@ class StreamingRenderer {
|
|
|
73
76
|
private lastRendered: ReactNode[] = []
|
|
74
77
|
|
|
75
78
|
/** @param codeLabels - Fence copy labels baked into cached elements; the owner replaces the renderer when they change. */
|
|
76
|
-
constructor(private readonly codeLabels: MarkdownCodeLabels | undefined, private readonly renderText: MarkdownRenderContext['renderText'], private readonly renderAtom: MarkdownRenderContext['renderAtom']) {}
|
|
79
|
+
constructor(private readonly codeLabels: MarkdownCodeLabels | undefined, private readonly renderText: MarkdownRenderContext['renderText'], private readonly renderAtom: MarkdownRenderContext['renderAtom'], private readonly pathImages: MarkdownPathImages | undefined) {}
|
|
77
80
|
|
|
78
81
|
/**
|
|
79
82
|
* Render the current accumulated text. Idempotent per text value, so React
|
|
@@ -109,6 +112,7 @@ class StreamingRenderer {
|
|
|
109
112
|
streaming: true,
|
|
110
113
|
codeLabels: this.codeLabels,
|
|
111
114
|
fileMentions: undefined,
|
|
115
|
+
pathImages: this.pathImages,
|
|
112
116
|
targets: frameTargets,
|
|
113
117
|
footnoteOrder: this.frozenFootnoteOrder,
|
|
114
118
|
footnoteCounts: this.frozenFootnoteCounts,
|
|
@@ -129,6 +133,7 @@ class StreamingRenderer {
|
|
|
129
133
|
streaming: true,
|
|
130
134
|
codeLabels: this.codeLabels,
|
|
131
135
|
fileMentions: undefined,
|
|
136
|
+
pathImages: this.pathImages,
|
|
132
137
|
targets: frameTargets,
|
|
133
138
|
footnoteOrder: [...this.frozenFootnoteOrder],
|
|
134
139
|
footnoteCounts: new Map(this.frozenFootnoteCounts),
|
|
@@ -162,26 +167,29 @@ class StreamingRenderer {
|
|
|
162
167
|
* relative links, and unsafe protocols are disabled, while absolute HTTP(S)
|
|
163
168
|
* images render directly.
|
|
164
169
|
*/
|
|
165
|
-
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions, renderText, renderAtom }: {
|
|
170
|
+
export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions, pathImages, renderText, renderAtom }: {
|
|
166
171
|
text: string
|
|
167
172
|
streaming?: boolean
|
|
168
173
|
codeLabels?: MarkdownCodeLabels | undefined
|
|
169
174
|
fileMentions?: MarkdownFileMentions | undefined
|
|
175
|
+
pathImages?: MarkdownPathImages | undefined
|
|
170
176
|
renderText?: MarkdownRenderContext['renderText']
|
|
171
177
|
renderAtom?: MarkdownRenderContext['renderAtom']
|
|
172
178
|
}) {
|
|
173
179
|
const streamRef = useRef<StreamingRenderer | null>(null)
|
|
180
|
+
const streamImagesRef = useRef(pathImages)
|
|
174
181
|
const streamLabelsRef = useRef<MarkdownCodeLabels | undefined>(codeLabels)
|
|
175
182
|
const children = useMemo(() => {
|
|
176
183
|
if (!streaming) {
|
|
177
184
|
streamRef.current = null
|
|
178
|
-
return renderSettled(text, codeLabels, fileMentions, renderText, renderAtom)
|
|
185
|
+
return renderSettled(text, codeLabels, fileMentions, pathImages, renderText, renderAtom)
|
|
179
186
|
}
|
|
180
|
-
if (streamRef.current === null || streamLabelsRef.current !== codeLabels) {
|
|
181
|
-
streamRef.current = new StreamingRenderer(codeLabels, renderText, renderAtom)
|
|
187
|
+
if (streamRef.current === null || streamLabelsRef.current !== codeLabels || streamImagesRef.current !== pathImages) {
|
|
188
|
+
streamRef.current = new StreamingRenderer(codeLabels, renderText, renderAtom, pathImages)
|
|
182
189
|
streamLabelsRef.current = codeLabels
|
|
190
|
+
streamImagesRef.current = pathImages
|
|
183
191
|
}
|
|
184
192
|
return streamRef.current.render(text)
|
|
185
|
-
}, [text, streaming, codeLabels, fileMentions, renderText, renderAtom])
|
|
193
|
+
}, [text, streaming, codeLabels, fileMentions, pathImages, renderText, renderAtom])
|
|
186
194
|
return <div className={css.markdown}>{children}</div>
|
|
187
195
|
})
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* may add node types this renderer has no mapping for.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { Fragment, createElement } from 'react'
|
|
20
|
+
import { Fragment, createElement, useState } from 'react'
|
|
21
21
|
import type { Key, ReactNode } from 'react'
|
|
22
22
|
import clsx from 'clsx'
|
|
23
23
|
import type * as Md from 'mdast'
|
|
@@ -27,6 +27,7 @@ import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
|
27
27
|
import { renderTexToReact } from './katex.js'
|
|
28
28
|
import { McpAppCodeBlock, StreamingMcpAppPlaceholder } from '../McpAppFrame.js'
|
|
29
29
|
import { isMcpAppCodeBlock, extractMcpAppTitle, extractMcpAppHeight } from '../mcp-app.js'
|
|
30
|
+
import type { MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
30
31
|
import type { PositionedBlock } from './incremental.js'
|
|
31
32
|
import css from './MarkdownText.module.css'
|
|
32
33
|
|
|
@@ -136,6 +137,7 @@ export interface MarkdownRenderContext {
|
|
|
136
137
|
readonly inBlockquote?: boolean
|
|
137
138
|
/** Inline-code file mentions; absent wherever no opener vocabulary exists. */
|
|
138
139
|
readonly fileMentions: MarkdownFileMentions | undefined
|
|
140
|
+
readonly pathImages?: MarkdownPathImages
|
|
139
141
|
/** Inside an anchor's children: interactive mentions must not nest there. */
|
|
140
142
|
readonly inLink?: boolean
|
|
141
143
|
/** Reference targets visible to this pass. */
|
|
@@ -301,7 +303,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
|
|
|
301
303
|
case 'linkReference':
|
|
302
304
|
return renderLinkReference(node, key, context)
|
|
303
305
|
case 'image':
|
|
304
|
-
return renderImage(node.url, node.alt ?? '', key)
|
|
306
|
+
return renderImage(node.url, node.alt ?? '', key, context)
|
|
305
307
|
case 'imageReference':
|
|
306
308
|
return renderImageReference(node, key, context)
|
|
307
309
|
case 'footnoteReference':
|
|
@@ -515,17 +517,31 @@ function inlineCodeHttpUrl(value: string): string | undefined {
|
|
|
515
517
|
}
|
|
516
518
|
}
|
|
517
519
|
|
|
518
|
-
function renderImage(url: string, alt: string, key: Key): ReactNode {
|
|
519
|
-
|
|
520
|
+
function renderImage(url: string, alt: string, key: Key, context: MarkdownRenderContext): ReactNode {
|
|
521
|
+
// RC2 semantics, including the revalidation of a trusted vocabulary result.
|
|
522
|
+
const remote = remoteImageUrl(sanitizeUrl(normalizeUri(url)))
|
|
523
|
+
const rewritten = remote === undefined ? context.pathImages?.resolve(url) : undefined
|
|
524
|
+
let imageSrc = remote
|
|
525
|
+
if (rewritten !== undefined) {
|
|
526
|
+
try {
|
|
527
|
+
if (['http:', 'https:', 'blob:', 'data:'].includes(new URL(rewritten).protocol)) imageSrc = rewritten
|
|
528
|
+
} catch { /* A vocabulary result must be an absolute URL. */ }
|
|
529
|
+
}
|
|
520
530
|
if (imageSrc === undefined) {
|
|
521
531
|
return <span key={key} className={css.imageAlt}>{alt}</span>
|
|
522
532
|
}
|
|
533
|
+
return <MarkdownImage key={`${key}:${imageSrc}`} src={imageSrc} alt={alt} destination={url} />
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function MarkdownImage({ src, alt, destination }: { src: string; alt: string; destination: string }): ReactNode {
|
|
537
|
+
const [failed, setFailed] = useState(false)
|
|
538
|
+
if (failed) return <span className={css.imageAlt}>{alt || destination}</span>
|
|
523
539
|
return (
|
|
524
540
|
<img
|
|
525
|
-
key={key}
|
|
526
541
|
className={css.image}
|
|
527
|
-
src={
|
|
542
|
+
src={src}
|
|
528
543
|
alt={alt}
|
|
544
|
+
onError={() => { setFailed(true) }}
|
|
529
545
|
loading="lazy"
|
|
530
546
|
decoding="async"
|
|
531
547
|
referrerPolicy="no-referrer"
|
|
@@ -563,7 +579,7 @@ function renderImageReference(
|
|
|
563
579
|
): ReactNode {
|
|
564
580
|
const definition = context.targets.definitions.get(node.identifier.toUpperCase())
|
|
565
581
|
if (definition === undefined) return `![${node.alt ?? ''}${referenceSuffix(node)}`
|
|
566
|
-
return renderImage(definition.url, node.alt ?? '', key)
|
|
582
|
+
return renderImage(definition.url, node.alt ?? '', key, context)
|
|
567
583
|
}
|
|
568
584
|
|
|
569
585
|
function renderFootnoteReference(
|
package/src/client/motion.tsx
CHANGED
|
@@ -126,9 +126,6 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
|
|
|
126
126
|
setPresent(open);
|
|
127
127
|
return;
|
|
128
128
|
}
|
|
129
|
-
if (from > target && motion) {
|
|
130
|
-
document.body.dataset.readerFolding = 'true';
|
|
131
|
-
}
|
|
132
129
|
const animation = element.animate(
|
|
133
130
|
from > target
|
|
134
131
|
? [
|
|
@@ -149,7 +146,6 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
|
|
|
149
146
|
if (settled) return;
|
|
150
147
|
settled = true;
|
|
151
148
|
if (running.current === animation) running.current = null;
|
|
152
|
-
delete document.body.dataset.readerFolding;
|
|
153
149
|
animation.cancel();
|
|
154
150
|
setPresent(open);
|
|
155
151
|
};
|
|
@@ -164,7 +160,6 @@ export function ProcessFragment({ open, motion, onRead, returnFocusTo, nodeKey,
|
|
|
164
160
|
return () => {
|
|
165
161
|
window.clearTimeout(deadline);
|
|
166
162
|
running.current?.cancel();
|
|
167
|
-
delete document.body.dataset.readerFolding;
|
|
168
163
|
};
|
|
169
164
|
}, [open, motion, returnFocusTo]);
|
|
170
165
|
if (!open && !present) return null;
|
|
@@ -299,16 +294,28 @@ export function useReadingScroll(root: RefObject<HTMLElement>, motion: boolean):
|
|
|
299
294
|
if (followFrame !== 0 || layoutDepth > 0) return;
|
|
300
295
|
// Our easing frames must not be mistaken for a user leaving the bottom.
|
|
301
296
|
if (lastWrittenTop !== null && Math.abs(scroll.scrollTop - lastWrittenTop) < 1) return;
|
|
297
|
+
// Bind the follow intent to the position observed HERE. Every scroll that
|
|
298
|
+
// reaches this point is the viewport moving under someone else's hand: a
|
|
299
|
+
// wheel, a drag, a key, or the browser clamping the offset after a shrink.
|
|
300
|
+
//
|
|
301
|
+
// The previous form inferred detaching from a strictly upward delta against
|
|
302
|
+
// lastWrittenTop, which left two holes. That baseline is nulled by
|
|
303
|
+
// cancelFollow/onWheel/onTouch/onKey, and the guard above already returned
|
|
304
|
+
// for any scroll whose delta is under 1px — so a reader parked mid-transcript
|
|
305
|
+
// by a scrollbar drag stayed following === true AND pinned === true. The next
|
|
306
|
+
// content growth then took the re-attach branch and yanked the viewport to
|
|
307
|
+
// the bottom, discarding what they were reading. Reading the position
|
|
308
|
+
// directly needs no baseline: a fold's clamp lands at the very bottom and
|
|
309
|
+
// still keeps following, while any other offset detaches.
|
|
310
|
+
const bottom = atBottom();
|
|
311
|
+
pinned.current = bottom;
|
|
312
|
+
if (!bottom) { cancelAnimationFrame(followFrame); followFrame = 0; scheduleCapture(); }
|
|
302
313
|
if (!following.current) {
|
|
303
314
|
// Only an explicit return to the bottom resumes following.
|
|
304
|
-
if (
|
|
315
|
+
if (bottom) { following.current = true; setDetached(false); }
|
|
305
316
|
return;
|
|
306
317
|
}
|
|
307
|
-
|
|
308
|
-
// upward move by the reader detaches the tail.
|
|
309
|
-
if (lastWrittenTop !== null && scroll.scrollTop < lastWrittenTop - 1) {
|
|
310
|
-
following.current = false; setDetached(true); pinned.current = false; cancelAnimationFrame(followFrame); followFrame = 0; scheduleCapture();
|
|
311
|
-
}
|
|
318
|
+
if (!bottom) { following.current = false; setDetached(true); }
|
|
312
319
|
};
|
|
313
320
|
const onWheel = (event: WheelEvent) => {
|
|
314
321
|
cancelAnimationFrame(followFrame); followFrame = 0; lastWrittenTop = null;
|