feat(storage): move desktop SQLite ownership into a storage utility process

Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
mesalogo
2026-10-06 15:40:18 +08:00
parent bc677a3718
commit d80d42b032
229 changed files with 13632 additions and 3800 deletions
+1
View File
@@ -931,6 +931,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
cancelParsing: vi.fn(async () => undefined),
sendOriginal: vi.fn(async () => []),
openOriginal: vi.fn(async () => undefined),
readOutput: vi.fn(async () => ({ content: '', cursor: 0, nextCursor: 0, totalBytes: 0, eof: true })),
addResultImages: vi.fn(async () => []),
onDraftChanged: vi.fn(() => () => undefined),
getDraft: vi.fn(async () => []),
+2 -1
View File
@@ -3332,7 +3332,8 @@ function App(): React.JSX.Element {
refreshTimer = window.setTimeout(() => {
refreshTimer = undefined;
refreshQueued = false;
const conversationIds = refreshAll
// One authoritative snapshot replaces a burst of per-conversation display reads.
const conversationIds = refreshAll || pendingConversationIds.size > 1
? undefined
: [...pendingConversationIds];
refreshAll = false;
+33
View File
@@ -79,6 +79,39 @@ function createMessages(): Message[] {
}
describe('ChatTimeline', () => {
it('opens persisted tool and cancelled subagent output through the conversation-scoped reader', async () => {
const readOutput = vi.fn(async ({ handle }: { handle: string }) => ({
content: `stored:${handle}`, cursor: 0, nextCursor: 20, totalBytes: 20, eof: true
}))
vi.stubGlobal('goodbuddy', { context: { readOutput } })
const toolReference = { handle: 'process:stored-tool', nextCursor: 10, totalBytes: 20 }
const childReference = { handle: 'subagent:stored-child', nextCursor: 10, totalBytes: 20 }
const childTaskId = 'child'
const tool = { callId: 'call', name: 'Process', state: 'completed' as const, summary: 'Saved process', output: 'preview', outputReferences: [toolReference] }
const message: Message = { id: 'output-message', role: 'assistant', content: '', state: 'error', createdAt: 1,
terminalStatus: 'cancelled', tools: [tool],
blocks: [{ id: 'tool-block', type: 'tool', tool }, { id: 'child-block', type: 'subagent', childTaskId }],
subagents: [{ childTaskId, routingMode: 'native', state: 'cancelled', actor: { kind: 'direct-model', label: '编程 Subagent' },
output: 'partial', outputReference: childReference }] }
try {
const view = render(<ChatTimeline {...callbacks} artifactById={new Map()} conversationId="output-conversation"
hiddenMessageCount={0} isUnusedConversation={false} locale="zh-CN" messageStartIndex={0} totalMessageCount={1} messages={[message]} />)
for (const details of view.container.querySelectorAll<HTMLDetailsElement>('details')) {
details.open = true
fireEvent(details, new Event('toggle'))
}
expect(readOutput).not.toHaveBeenCalled()
const buttons = screen.getAllByRole('button', { name: '读取完整输出 1' })
expect(buttons).toHaveLength(2)
for (const button of buttons) await act(async () => fireEvent.click(button))
expect(readOutput.mock.calls.map(([input]) => input)).toEqual([
{ conversationId: 'output-conversation', handle: toolReference.handle, cursor: 0 },
{ conversationId: 'output-conversation', handle: childReference.handle, cursor: 0 }
])
expect(screen.getByText(`stored:${childReference.handle}`)).toBeVisible()
} finally { vi.unstubAllGlobals() }
})
it.each(['cancelled', 'failed'] as const)('renders saved %s status and offers editing only with usable input', (terminalStatus) => {
const props = {
...callbacks, artifactById: new Map(), conversationId: 'terminal',
+24 -5
View File
@@ -1,4 +1,5 @@
import { AttachmentResultButton } from './AttachmentResultButton'
import { ToolOutputReader } from './ToolOutputReader'
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
import { MessageExpansionScope, useMessageExpansion } from './message-expansion'
import {
@@ -243,9 +244,10 @@ const ToolDetail = memo(function ToolDetail({ label, content, formatJson = false
)
})
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, ...tool }: Pick<
ToolActivity, 'name' | 'summary' | 'state' | 'input' | 'output' | 'error'
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, conversationId, ...tool }: Pick<
ToolActivity, 'name' | 'summary' | 'state' | 'input' | 'output' | 'error' | 'outputReferences'
> & {
conversationId: string
expansionId: string
onCopy: CopyContent
}): React.JSX.Element {
@@ -303,6 +305,9 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, .
<ToolDetail label={t('chat.tools.input')} content={tool.input} formatJson onCopy={onCopy} />
</details>
)}
{tool.outputReferences?.map((reference, index) => <ToolOutputReader
key={`${conversationId}:${reference.handle}`} conversationId={conversationId}
reference={reference} index={index + 1} onCopy={onCopy} />)}
</div>
</details>
{tool.error && (
@@ -313,9 +318,11 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, .
})
function ToolExecutionList({
conversationId,
tools,
onCopy
}: {
conversationId: string
tools: ToolActivity[]
onCopy: CopyContent
}): React.JSX.Element {
@@ -329,6 +336,7 @@ function ToolExecutionList({
<ol>
{tools.map((tool) => (
<ToolExecutionRow
conversationId={conversationId}
key={tool.callId ?? tool.name}
expansionId={tool.callId ?? tool.name}
name={tool.name}
@@ -336,6 +344,7 @@ function ToolExecutionList({
state={tool.state}
input={tool.input}
output={tool.output}
outputReferences={tool.outputReferences}
error={tool.error}
onCopy={onCopy}
/>
@@ -346,11 +355,13 @@ function ToolExecutionList({
}
const SubagentStatusCard = memo(function SubagentStatusCard({
conversationId,
renderHtml,
subagent,
questionFormId,
onCopy
}: {
conversationId: string
renderHtml: boolean
subagent: SubagentActivity
questionFormId?: string
@@ -450,7 +461,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
<div className="subagent-status-card__progress">
{groupMessageBlocks(progress).map((item) =>
item.kind === 'tools' ? (
<ToolExecutionList key={item.id} onCopy={onCopy} tools={item.tools.map((tool) =>
<ToolExecutionList conversationId={conversationId} key={item.id} onCopy={onCopy} tools={item.tools.map((tool) =>
(tool.state === 'pending' || tool.state === 'running') &&
subagent.state !== 'queued' && subagent.state !== 'running'
? { ...tool, state: subagent.state === 'cancelled' ? 'cancelled' : 'interrupted' }
@@ -497,6 +508,9 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
<p>{subagent.error}</p>
</section>
)}
{subagent.outputReference && <ToolOutputReader
key={`${conversationId}:${subagent.outputReference.handle}`} conversationId={conversationId}
reference={subagent.outputReference} index={1} onCopy={onCopy} />}
</div>
)}
</details>
@@ -504,12 +518,14 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
})
function SubagentStatusList({
conversationId,
renderHtml,
subagents,
pendingQuestions,
questionFormId,
onCopy
}: {
conversationId: string
renderHtml: boolean
subagents: SubagentActivity[]
pendingQuestions?: Message['pendingQuestions']
@@ -525,6 +541,7 @@ function SubagentStatusList({
>
{subagents.map((subagent) => (
<MessageExpansionScope key={subagent.childTaskId} id={`subagent:${subagent.childTaskId}`}><SubagentStatusCard
conversationId={conversationId}
renderHtml={renderHtml}
subagent={subagent}
questionFormId={pendingQuestions?.some((question) => question.childTaskId === subagent.childTaskId) ? questionFormId : undefined}
@@ -863,9 +880,10 @@ function ChatMessageRowView({
{groupMessageBlocks(message.blocks).map((item) =>
item.kind === 'question' ? renderQuestion(item.questionId)
: item.kind === 'tools' ? (
<ToolExecutionList key={item.id} tools={item.tools} onCopy={onCopyMessage} />
<ToolExecutionList conversationId={conversationId} key={item.id} tools={item.tools} onCopy={onCopyMessage} />
) : item.kind === 'subagents' ? (
<SubagentStatusList
conversationId={conversationId}
pendingQuestions={message.pendingQuestions}
questionFormId={questionFormId}
onCopy={onCopyMessage}
@@ -927,6 +945,7 @@ function ChatMessageRowView({
)}
{unorderedSubagents && unorderedSubagents.length > 0 && (
<SubagentStatusList
conversationId={conversationId}
pendingQuestions={message.pendingQuestions}
questionFormId={questionFormId}
onCopy={onCopyMessage}
@@ -1139,7 +1158,7 @@ function ChatMessageRowView({
{(!message.blocks || message.blocks.length === 0) &&
message.tools &&
message.tools.length > 0 && (
<ToolExecutionList tools={message.tools} onCopy={onCopyMessage} />
<ToolExecutionList conversationId={conversationId} tools={message.tools} onCopy={onCopyMessage} />
)}
{message.approval && (
<div className="approval-card">
+5 -1
View File
@@ -1894,5 +1894,9 @@ function ProjectSwitcherView({
)
}
/** Keep the hidden sidebar's project menus and activity rows out of the tree. */
const ProjectSwitcherWhenVisible = (props: ProjectSwitcherProps): React.JSX.Element | null =>
props.visible === false ? null : <ProjectSwitcherView {...props} />
/** Memoized: App re-renders on chat updates; this view re-renders only when its props change. */
export const ProjectSwitcher = memo(ProjectSwitcherView)
export const ProjectSwitcher = memo(ProjectSwitcherWhenVisible)
+65 -18
View File
@@ -1,4 +1,4 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from 'react'
import { useEffect, useEffectEvent, useId, useMemo, useRef, useState, type ReactNode } from 'react'
import { Check, ChevronDown } from 'lucide-react'
import { AnchoredMenu } from './AnchoredMenu'
import { InlineHelp } from './InlineHelp'
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import * as THREE from 'three'
import type { SupervisionAttentionSlot, SupervisionGraphView } from '../../shared/supervision-contracts'
import type { SupervisionExperience, SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, storyWindow, timelineSegments, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode } from './story-graph-3d-model'
import { buildStoryTree, clusterEvents, experienceLinks, findNode, radiusLevels, selectionFocus, storyWindow, timelineSegments, visibleLevel, type ExperienceLink, type StoryCluster, type StoryNode, type StorySelection } from './story-graph-3d-model'
/*
* 3D story view: one helix of time inside, story staves on the outer cylinder.
@@ -21,6 +21,7 @@ type Props = {
attention: SupervisionAttentionSlot[]
timeRange?: { from: string; to: string }
selectedEventId?: string
selectedStoryId?: string
onSelectEvent: (id: string) => void
onSelectStory: (id: string) => void
/** Experiences (W) drawn outside the barrel, linking the staves they formed in and were applied to. */
@@ -63,7 +64,7 @@ function webglAvailable(): boolean {
catch { return false }
}
export default function StoryGraph3D({ toolbar, stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
export default function StoryGraph3D({ toolbar, stories, events, projectNames, attention: attentionProp, timeRange, selectedEventId, selectedStoryId, onSelectEvent, onSelectStory, experiences = noExperiences, selectedExperienceId, onSelectExperience }: Props) {
const { t, i18n } = useTranslation('heartbeat')
const canvasRef = useRef<HTMLCanvasElement>(null)
const overlayRef = useRef<HTMLCanvasElement>(null)
@@ -82,16 +83,37 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
}, [])
const tree = useMemo(() => buildStoryTree(stories, projectNames, { events,
unassigned: t('supervisor.graph3d.unassigned'), unknownProject: t('supervisor.graph3d.unknownProject') }), [stories, events, projectNames, t])
const timelineKey = useMemo(() => JSON.stringify(tree.events), [tree])
const [focusId, setFocusId] = useState('root')
const focus = findNode(tree, focusId) ?? tree
const selectionKey = JSON.stringify([selectedStoryId, selectedEventId, selectedExperienceId])
const [previousSelection, setPreviousSelection] = useState('')
const [request, setRequest] = useState<{ selection: StorySelection }>()
const [resolvedRequest, setResolvedRequest] = useState<typeof request>()
if (previousSelection !== selectionKey) {
setPreviousSelection(selectionKey)
const selection: StorySelection | undefined = selectedExperienceId ? { kind: 'experience', id: selectedExperienceId }
: selectedEventId ? { kind: 'event', id: selectedEventId } : selectedStoryId ? { kind: 'story', id: selectedStoryId } : undefined
// A canvas pick is echoed by the parent; it must not start a second flight.
if (selection && (selection.kind !== request?.selection.kind || selection.id !== request.selection.id)) setRequest({ selection })
else if (!selection) setRequest(undefined)
}
const target = useMemo(() => request && selectionFocus(tree, focus, request.selection, experiences), [tree, focus, request, experiences])
if (target && request !== resolvedRequest) {
setResolvedRequest(request)
if (target.node.id !== focusId) setFocusId(target.node.id)
}
const [hovered, setHovered] = useState<string>()
const camera = useRef<{ yaw: number; tilt: number; zoom: number; pan: number }>({ yaw: VIEWS.oblique.yaw, tilt: VIEWS.oblique.tilt, zoom: 1, pan: 0 })
const animation = useRef<number | undefined>(undefined)
const cancelAnimation = () => { if (animation.current !== undefined) cancelAnimationFrame(animation.current); animation.current = undefined }
const engine = useRef<{ draw: () => void; update: (next: EngineState) => void; dispose: () => void; hit: (x: number, y: number) => { node?: StoryNode; cluster?: StoryCluster; link?: ExperienceLink } | undefined }>(undefined)
const range = useMemo(() => {
const computedRange = useMemo(() => {
if (timeRange) return storyWindow(Date.parse(timeRange.from), Date.parse(timeRange.to))
const times = tree.events.flatMap(event => [event.t, event.end ?? event.t])
return times.length ? storyWindow(Math.min(...times) - 1_800_000, Math.max(...times) + 1_800_000) : undefined
}, [tree, timeRange])
const range = useMemo(() => computedRange, [computedRange?.from, computedRange?.to]) // eslint-disable-line react-hooks/exhaustive-deps
const level = visibleLevel(focus)
const trail: StoryNode[] = []
for (let node: StoryNode | undefined = focus; node; node = node.parent) trail.unshift(node)
@@ -220,7 +242,11 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
}
}
// Experiences: a diamond outside the barrel, arcs from the forming stave to it and on to the applying stave.
for (const link of experienceLinks(state.experiences, staves)) {
const links = experienceLinks(state.experiences, staves)
if (state.experience && !links.some(link => link.id === state.experience)) {
links.push(...experienceLinks(state.experiences.filter(item => item.id === state.experience), staves))
}
for (const link of links) {
const active = link.id === state.experience
const node = on(link.t, link.angle, W_R)
const arc = (end: { stave: StoryNode; t: number }) => {
@@ -298,6 +324,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
resize.observe(canvas)
const zoom = (event: WheelEvent) => {
event.preventDefault()
cancelAnimation()
camera.current.zoom = Math.max(0.5, Math.min(2.5, camera.current.zoom * Math.exp(-event.deltaY * 0.0012)))
draw()
}
@@ -307,7 +334,9 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
update: next => { Object.assign(state, next); staves = visibleLevel(state.focus).children; rebuild(); draw() },
hit: (x, yPos) => {
raycaster.setFromCamera(new THREE.Vector2(x / width * 2 - 1, 1 - yPos / height * 2), view3d)
const hit = raycaster.intersectObjects(meshes, false)[0]
const hits = raycaster.intersectObjects(meshes, false)
// Translucent staves must not swallow clicks on the visible event dots behind them.
const hit = hits.find(hit => hit.object.userData.cluster || hit.object.userData.link) ?? hits[0]
return hit && { node: hit.object.userData.node as StoryNode | undefined, cluster: hit.object.userData.cluster as StoryCluster | undefined,
link: hit.object.userData.link as ExperienceLink | undefined }
},
@@ -315,14 +344,16 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
dispose: () => { canvas.removeEventListener('wheel', zoom); glCanvas.removeEventListener('webglcontextlost', lost); resize.disconnect()
disposables.forEach(item => item.dispose()); renderer.dispose(); renderer.forceContextLoss(); glCanvas.remove() }
}
return () => { engine.current?.dispose(); engine.current = undefined }
return () => { cancelAnimation(); engine.current?.dispose(); engine.current = undefined }
// Focus, hover and selection use update(); data, locale, theme and retries recreate the scene.
}, [supported, range, attention, i18n.resolvedLanguage, attempt, theme]) // eslint-disable-line react-hooks/exhaustive-deps
}, [supported, range, timelineKey, attention, i18n.resolvedLanguage, attempt, theme]) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { engine.current?.update({ focus, hovered, selected: selectedEventId, experiences, experience: selectedExperienceId }) },
[focus, hovered, selectedEventId, range, attention, experiences, selectedExperienceId, theme, attempt])
[focus, hovered, selectedEventId, range, timelineKey, attention, experiences, selectedExperienceId, theme, attempt])
const animate = (goal: Partial<typeof camera.current>) => {
cancelAnimation()
const from = { ...camera.current }
if (goal.yaw !== undefined) goal.yaw = from.yaw + Math.atan2(Math.sin(goal.yaw - from.yaw), Math.cos(goal.yaw - from.yaw))
let started: number | undefined
const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const step = (now: number) => {
@@ -330,13 +361,28 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
const k = reduce ? 1 : Math.min(1, (now - started) / 480), e = k < 0.5 ? 2 * k * k : 1 - (-2 * k + 2) ** 2 / 2
for (const key of ['yaw', 'tilt', 'zoom', 'pan'] as const) if (goal[key] !== undefined) camera.current[key] = from[key] + (goal[key]! - from[key]) * e
engine.current?.draw()
if (k < 1) requestAnimationFrame(step)
animation.current = k < 1 ? requestAnimationFrame(step) : undefined
}
requestAnimationFrame(step)
if (reduce) step(performance.now())
else animation.current = requestAnimationFrame(step)
}
const lastFlight = useRef<typeof request>(undefined)
const focusCamera = useEffectEvent(() => {
if (request === lastFlight.current) return
cancelAnimation()
if (!target || !range || !engine.current) return
lastFlight.current = request
const u = (target.time - range.from) / (range.to - range.from)
const angle = target.angle ?? u * range.turns * TAU
const radius = target.radius ?? 78 + 74 * radiusLevels(range, attention)(u)
const tilt = 0.18
animate({ yaw: angle - Math.PI / 2, tilt, pan: (u - 0.5) * HEIGHT - radius * Math.tan(tilt), zoom: Math.max(0.8, Math.min(1.4, camera.current.zoom)) })
})
useEffect(() => { focusCamera() }, [request, target, range, attempt, theme])
const choose = (name: keyof typeof VIEWS) => animate({ ...VIEWS[name], zoom: 1, pan: 0 })
const open = (node: StoryNode) => {
if (node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)
setRequest({ selection: { kind: 'story', id: node.id } })
if (node.level !== 'root' && node.level !== 'project' && node.level !== 'unassigned') onSelectStory(node.id)
setFocusId(node.id)
setHovered(undefined)
}
@@ -360,7 +406,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
{trail.map((node, index) => <span key={node.id}>
{index > 0 && ' › '}
{index === trail.length - 1 ? <strong>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</strong>
: <button type="button" className="link-button" onClick={() => setFocusId(node.id)}>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</button>}
: <button type="button" className="link-button" onClick={() => open(node)}>{node.level === 'root' ? t('supervisor.graph3d.all') : node.name}</button>}
</span>)}
</nav>
{/* The record list selects details; this menu drills the actual project/feature hierarchy. */}
@@ -383,7 +429,7 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
</div>
<div className="story-graph-3d__viewport">
<canvas ref={canvasRef} tabIndex={0} aria-label={t('supervisor.graph3d.canvas')}
onPointerDown={event => { const [x, y] = pointer(event); drag.current = { x, y, sx: x, sy: y, pan: event.button === 2 || event.shiftKey }; event.currentTarget.setPointerCapture(event.pointerId) }}
onPointerDown={event => { cancelAnimation(); const [x, y] = pointer(event); drag.current = { x, y, sx: x, sy: y, pan: event.button === 2 || event.shiftKey }; event.currentTarget.setPointerCapture(event.pointerId) }}
onPointerMove={event => {
const [x, y] = pointer(event)
if (!drag.current) {
@@ -404,20 +450,21 @@ export default function StoryGraph3D({ toolbar, stories, events, projectNames, a
drag.current = undefined
if (!start || Math.hypot(x - start.sx, y - start.sy) >= 5) return
const hit = engine.current?.hit(x, y)
if (hit?.link) onSelectExperience?.(hit.link.id)
else if (hit?.cluster) { if (hit.cluster.events.length === 1) onSelectEvent(hit.cluster.events[0]!.id); else open(hit.node!) }
if (hit?.link) { setRequest({ selection: { kind: 'experience', id: hit.link.id } }); onSelectExperience?.(hit.link.id) }
else if (hit?.cluster) { if (hit.cluster.events.length === 1) { setRequest({ selection: { kind: 'event', id: hit.cluster.events[0]!.id } }); onSelectEvent(hit.cluster.events[0]!.id) } else open(hit.node!) }
else if (hit?.node) open(hit.node)
}}
onPointerLeave={() => { if (!drag.current) setHovered(undefined) }}
onLostPointerCapture={() => { drag.current = undefined }}
onContextMenu={event => event.preventDefault()}
onKeyDown={event => {
if (event.key === 'Escape' && focus.parent) { setFocusId(focus.parent.id); return }
if (event.key === 'Escape') { cancelAnimation(); if (focus.parent) open(focus.parent); return }
const preset = ({ 1: 'oblique', 2: 'side', 3: 'top' } as const)[event.key as '1' | '2' | '3']
if (preset) { choose(preset); return }
const move: Record<string, () => void> = { ArrowLeft: () => { camera.current.yaw -= 0.12 }, ArrowRight: () => { camera.current.yaw += 0.12 },
ArrowUp: () => { camera.current.tilt = Math.min(Math.PI / 2 - 1e-3, camera.current.tilt + 0.06) }, ArrowDown: () => { camera.current.tilt = Math.max(-Math.PI / 2 + 1e-3, camera.current.tilt - 0.06) } }
if (!move[event.key]) return
event.preventDefault(); move[event.key]!(); engine.current?.draw()
event.preventDefault(); cancelAnimation(); move[event.key]!(); engine.current?.draw()
}} />
<canvas ref={overlayRef} className="story-graph-3d__overlay" aria-hidden="true" />
</div>
+4 -1
View File
@@ -165,9 +165,10 @@ export function SupervisorWorkspace({
if (!api) return
const generation = ++loadGeneration.current
setPending(undefined)
// Keep the current canvas mounted while reloading the same review.
if (requestedId !== selectedResult.current) setGraph(emptyGraph)
selectedResult.current = requestedId
setOverviewLoading(true)
setGraph(emptyGraph)
setGraphResultId(undefined)
setGraphError(undefined)
setResultId(requestedId)
@@ -187,6 +188,7 @@ export function SupervisorWorkspace({
setResults(overview)
if (requestedId && !selected) throw new Error('Requested supervision result is unavailable')
const nextId = requestedId ?? selected?.id
if (nextId !== requestedId) setGraph(emptyGraph)
setResultId(nextId)
selectedResult.current = nextId
} catch {
@@ -854,6 +856,7 @@ export function SupervisorWorkspace({
<StoryGraph3D stories={storyState.view.stories} events={graph.events} projectNames={projectNames} attention={graph.attention ?? noAttention} timeRange={latest?.timeRange}
toolbar={graphTools}
selectedEventId={selection?.kind === 'event' ? selection.id : undefined}
selectedStoryId={selection?.kind === 'story' ? selection.id : undefined}
onSelectEvent={(id) => { if (layout.eventMap.has(id)) select({ kind: 'event', id }) }}
onSelectStory={(id) => select({ kind: 'story', id })}
experiences={storyState.view.experiences}
@@ -0,0 +1,28 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { ToolOutputReader } from './ToolOutputReader'
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }))
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('loads saved output on demand, replaces bounded pages, copies the visible page, and retries errors', async () => {
const readOutput = vi.fn().mockResolvedValueOnce({ content: 'first', cursor: 0, nextCursor: 5, totalBytes: 9, eof: false })
.mockRejectedValueOnce(new Error('Storage unavailable'))
.mockResolvedValueOnce({ content: 'tail', cursor: 5, nextCursor: 9, totalBytes: 9, eof: true })
vi.stubGlobal('goodbuddy', undefined)
Object.defineProperty(window, 'goodbuddy', { configurable: true, value: { context: { readOutput } } })
const onCopy = vi.fn(async () => true)
render(<ToolOutputReader conversationId="conversation" reference={{ handle: 'process:handle', totalBytes: 9, nextCursor: 3 }} index={1} onCopy={onCopy} />)
expect(readOutput).not.toHaveBeenCalled()
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'chat.tools.fullOutput' })))
expect(readOutput).toHaveBeenCalledWith({ conversationId: 'conversation', handle: 'process:handle', cursor: 0 })
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'chat.tools.nextOutputPage' })))
expect(screen.getByRole('alert')).toHaveTextContent('Storage unavailable')
expect(screen.getByText('first')).toBeInTheDocument()
await act(async () => fireEvent.click(screen.getByRole('button', { name: 'chat.tools.nextOutputPage' })))
expect(screen.queryByText('first')).not.toBeInTheDocument()
expect(screen.getByText('tail')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'chat.tools.nextOutputPage' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'chat.tools.copyOutputPage' }))
expect(onCopy).toHaveBeenCalledWith('tail', 'tool')
})
+45
View File
@@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ToolOutputReference } from '../../shared/assistant-contracts'
import type { ConversationOutputPage } from '../../shared/conversation-output'
export function ToolOutputReader({ conversationId, reference, index, onCopy }: {
conversationId: string
reference: ToolOutputReference
index: number
onCopy: (content: string, kind?: 'tool') => Promise<boolean>
}): React.JSX.Element {
const { t } = useTranslation('app')
const [page, setPage] = useState<ConversationOutputPage>()
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const generation = useRef(0)
useEffect(() => () => { generation.current++ }, [])
const load = async (cursor: number): Promise<void> => {
const current = ++generation.current
setBusy(true)
setError('')
try {
const next = await window.goodbuddy.context.readOutput({ conversationId, handle: reference.handle, cursor })
if (current === generation.current) setPage(next)
} catch (reason) {
if (current === generation.current) setError(reason instanceof Error ? reason.message : t('chat.tools.readOutputFailed'))
} finally {
if (current === generation.current) setBusy(false)
}
}
const label = t('chat.tools.fullOutput', { index })
return <section className="tool-detail" aria-label={label}>
<header className="tool-detail__toolbar">
<button type="button" disabled={busy} onClick={() => { void load(0) }}>{label}</button>
{page && <>
<span>{page.cursor}-{page.nextCursor} / {page.totalBytes}</span>
<button type="button" disabled={busy || page.eof} onClick={() => { void load(page.nextCursor) }}>{t('chat.tools.nextOutputPage')}</button>
<button type="button" onClick={() => { void onCopy(page.content, 'tool') }}>{t('chat.tools.copyOutputPage')}</button>
</>}
</header>
{busy && <span role="status">{t('chat.tools.loadingOutput')}</span>}
{error && <p role="alert">{error}</p>}
{page && <pre tabIndex={0} aria-label={label}>{page.content}</pre>}
</section>
}
@@ -0,0 +1,47 @@
import { expect, it, vi } from 'vitest'
import { handleAgentEvent, type AgentEventDependencies } from './agent-event-handler'
import { createConversationStore } from './conversation-store'
import { createLiveMessageStore } from './live-message-store'
import { createTaskStore } from './task-store'
import { createActivityStore } from './activity-store'
import { toConversationMessage, createLocalConversationSaveBatch } from './conversation-persistence'
import { conversationMessageSchema } from '../../shared/assistant-contracts'
import { conversationMessageResourceIds } from '../../shared/conversation-output'
it.each(['completed', 'failed', 'cancelled'] as const)('retains output references in renderer %s events, blocks and local save metadata', state => {
const conversationId = crypto.randomUUID(), messageId = crypto.randomUUID(), requestId = crypto.randomUUID()
const liveMessages = createLiveMessageStore()
const store = createConversationStore([{ id: conversationId, title: 'Output', updatedAt: 0, messages: [
{ id: messageId, role: 'assistant', content: '', state: 'streaming', createdAt: 0, blocks: [] }
] }], liveMessages)
const deps: AgentEventDependencies = {
activeRuns: { current: new Map([[requestId, { conversationId, messageId, runtimeSelectionKey: 'model' }]]) },
activeConversationIdRef: { current: conversationId }, activeProjectIdRef: { current: '' },
hydratingArtifactIds: { current: new Set() }, requestPersistenceFlush: vi.fn(),
tRef: { current: ((key: string) => key) as AgentEventDependencies['tRef']['current'] },
conversationStore: store, liveMessages, setConversations: store.set,
taskStore: createTaskStore(), activityStore: createActivityStore(), setUnreadConversationIds: vi.fn(),
notify: vi.fn(), updateMessage: (id, mid, update) => store.set(current => current.map(conversation =>
conversation.id === id ? { ...conversation, messages: conversation.messages.map(message => message.id === mid ? update(message) : message) } : conversation)),
recordActivity: vi.fn(), loadWorkspaceChanges: vi.fn(), markConversationCompleted: vi.fn(),
setConversationActivity: vi.fn(), releaseConversationQueueAfterRun: vi.fn()
}
const reference = { handle: `process:${crypto.randomUUID()}`, nextCursor: 5, totalBytes: 100000 }
const childReference = { ...reference, handle: `subagent:${crypto.randomUUID()}` }
const output = 'x'.repeat(200000)
handleAgentEvent({ requestId, type: 'tool', callId: 'process', name: 'Process',
state: state === 'cancelled' ? 'completed' : state, summary: 'Output', output, outputReferences: [reference] }, deps)
handleAgentEvent({ requestId, type: 'subagent', childTaskId: crypto.randomUUID(),
actor: { kind: 'direct-model', label: '编程 Subagent' }, routingMode: 'native', state,
output: 'child preview', outputReference: childReference }, deps)
const conversation = store.getState()[0]!
const message = conversationMessageSchema.parse(toConversationMessage(conversation.messages[0]!))
expect(message.tools?.[0]?.outputReferences).toEqual([reference])
expect(message.blocks?.[0]).toMatchObject({ type: 'tool', tool: { outputReferences: [reference], output } })
expect(message.subagents?.[0]?.outputReference).toEqual(childReference)
const batch = createLocalConversationSaveBatch([conversation], new Map(), new Set()).batch
expect(JSON.parse(JSON.stringify(batch))[0].messages[0]).toMatchObject({
tools: [{ outputReferences: [reference], output }], subagents: [{ outputReference: childReference }]
})
expect(conversationMessageResourceIds(message)).toEqual([reference.handle.split(':')[1], childReference.handle.split(':')[1]])
})
+2
View File
@@ -486,6 +486,7 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
summary: event.summary,
input: event.input,
output: event.output,
outputReferences: event.outputReferences,
error: event.error,
};
if (index >= 0) {
@@ -602,6 +603,7 @@ export function handleAgentEvent(event: AgentEvent, deps: AgentEventDependencies
reason: event.reason,
progress: event.progress,
output: event.output,
outputReference: event.outputReference,
error: event.error,
};
const subagent: SubagentActivity =
@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { ConversationListSnapshot, LocalConversationSaveBatch } from "../../shared/assistant-contracts";
import type { Message } from "./ChatTimeline";
import type { Conversation } from "./chat-conversation";
import { createConversationPersistence } from "./conversation-persistence";
import { createConversationPersistence, createLocalConversationSaveBatch, LOCAL_CONVERSATION_SAVE_BYTES } from "./conversation-persistence";
import { storageDataBytes } from "../../shared/storage-data-size";
import { startConversationRefresh, type ConversationRefreshApi } from "./conversation-refresh";
import { createConversationStore } from "./conversation-store";
import { createLiveMessageStore } from "./live-message-store";
@@ -37,6 +38,33 @@ afterEach(() => {
});
describe("conversation persistence", () => {
it("batches by bytes, keeps a single large history complete, and drains every batch on quit", async () => {
const histories = Array.from({ length: 5 }, (_, index) => conversation(`large-${index}`, [
message(`message-${index}`, { content: "x".repeat(2 * 1024 * 1024) }),
]));
const first = createLocalConversationSaveBatch(histories, new Map(), new Set());
expect(first.batch).toHaveLength(1);
expect(storageDataBytes(first.batch, Infinity)).toBeLessThan(LOCAL_CONVERSATION_SAVE_BYTES);
const huge = conversation("huge", [message("huge-message", { content: "y".repeat(20 * 1024 * 1024) })]);
const oversized = createLocalConversationSaveBatch([huge], new Map(), new Set());
expect(oversized.batch[0]!.messages[0]!.content).toBe(huge.messages[0]!.content);
expect(oversized.acknowledgements).toEqual([huge]);
const { persistence, saveLocal } = setup([...histories, huge]);
const stop = persistence.start();
await persistence.flushForQuit();
expect(saveLocal.mock.calls.flatMap(([batch]) => batch.map(row => row.header.id))).toEqual([...histories, huge].map(row => row.id));
expect(saveLocal).toHaveBeenCalledTimes(6);
stop();
});
it("does not turn a failed final byte-bounded batch into a successful quit flush", async () => {
const { persistence } = setup([conversation("failure")], vi.fn(async () => { throw new Error("disk full"); }));
const stop = persistence.start();
await expect(persistence.flushForQuit()).rejects.toThrow("disk full");
expect(persistence.acknowledged().size).toBe(0);
stop();
});
it("saves only changed local conversations and only their changed messages", async () => {
const remote = conversation("r", undefined, { remote: remoteSource });
const { store, persistence, saveLocal } = setup([conversation("a"), conversation("b"), remote]);
+18 -4
View File
@@ -9,6 +9,9 @@ import type { Message } from "./ChatTimeline";
import { sortConversationsForDisplay, type Conversation } from "./chat-conversation";
import type { ConversationStore } from "./conversation-store";
import { mergeMessageImageState } from "./message-image-state";
import { storageDataBytes } from "../../shared/storage-data-size";
export const LOCAL_CONVERSATION_SAVE_BYTES = 8 * 1024 * 1024;
/**
* Local conversation persistence and history loading, outside React.
@@ -79,6 +82,7 @@ export function createLocalConversationSaveBatch(
} {
const batch: LocalConversationSaveBatch = [];
const acknowledgements: Conversation[] = [];
let bytes = 0;
for (const conversation of conversations) {
if (
conversation.remote ||
@@ -91,12 +95,17 @@ export function createLocalConversationSaveBatch(
const previousMessages = new Map(
previous?.messages.map((message) => [message.id, message]) ?? [],
);
batch.push({
const change = {
header: toLocalConversationHeader(conversation),
messages: conversation.messages
.filter((message) => previousMessages.get(message.id) !== message)
.map(toConversationMessage),
});
};
const size = storageDataBytes(change, Infinity);
if (batch.length && bytes + size > LOCAL_CONVERSATION_SAVE_BYTES) break;
// A single large conversation remains complete; storage transports it in frames.
batch.push(change);
bytes += size;
acknowledgements.push(conversation);
if (batch.length === 100) {
break;
@@ -307,7 +316,7 @@ export function createConversationPersistence(options: ConversationPersistenceOp
};
/** Queues a save of every changed local conversation, then releases idle history. */
const persist = (): void => {
const persist = (): Promise<void> => {
const operation = queue.then(async () => {
if (paused) {
return;
@@ -329,6 +338,7 @@ export function createConversationPersistence(options: ConversationPersistenceOp
});
queue = operation.catch(() => undefined);
void operation.catch(() => host.onSaveFailed());
return operation;
};
const ensureHistory = (conversationId: string): Promise<Conversation> => {
@@ -418,8 +428,12 @@ export function createConversationPersistence(options: ConversationPersistenceOp
async flushForQuit(): Promise<void> {
if (!started) return;
flushRequested = false;
persist();
await queue;
while (!paused) {
const { batch } = createLocalConversationSaveBatch(store.getState(), acknowledged, deleting);
if (!batch.length) break;
await persist();
}
},
};
}
+5
View File
@@ -76,6 +76,11 @@ export function startConversationRefresh(deps: ConversationRefreshDependencies):
let refreshQueued = false;
const queueRefresh = (): void => {
// While hidden, the next visible refresh supersedes every display-only
// update. Persistence and agent events continue through their own paths.
if (doc.visibilityState === "hidden") {
return;
}
refreshSequence += 1;
refreshQueued = true;
if (refreshInFlight || refreshTimer !== undefined) {
@@ -472,6 +472,11 @@ export const app = {
waitingOutput: 'Running; no result yet.',
emptyOutput: 'Completed with no returned content.',
copyAction: 'Copy',
fullOutput: 'Read full output {{index}}',
nextOutputPage: 'Next page',
copyOutputPage: 'Copy page',
loadingOutput: 'Loading output...',
readOutputFailed: 'Could not read saved output. Try again.',
copy: 'Copy {{label}}',
copied: 'Tool details copied',
states: {
@@ -456,6 +456,11 @@ export const app = {
waitingOutput: '执行中,尚无结果。',
emptyOutput: '执行完成,无返回内容。',
copyAction: '复制',
fullOutput: '读取完整输出 {{index}}',
nextOutputPage: '下一页',
copyOutputPage: '复制本页',
loadingOutput: '正在读取输出…',
readOutputFailed: '无法读取已保存的输出,请重试。',
copy: '复制{{label}}',
copied: '已复制工具详情',
states: {
+32 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { SupervisionStory } from '../../shared/supervision-story-contracts'
import { buildStoryTree, clusterEvents, experienceLinks, radiusLevels, storyWindow, timelineSegments, visibleLevel } from './story-graph-3d-model'
import { buildStoryTree, clusterEvents, experienceLinks, radiusLevels, selectionFocus, storyWindow, timelineSegments, visibleLevel } from './story-graph-3d-model'
const day = 86_400_000
const at = (d: number) => new Date(Date.parse('2026-09-01T00:00:00Z') + d * day).toISOString()
@@ -12,6 +12,37 @@ const story = (id: string, level: SupervisionStory['level'], days: number[], ext
})
describe('story graph 3d model', () => {
it('resolves external selections across hidden hierarchies without moving visible events to another level', () => {
const tree = buildStoryTree([story('a', 'feature', [1]), story('thread', 'thread', [2], { parentId: 'a' }),
story('b', 'feature', [3], { projectId: 'p2' }), story('cross', 'cross', [4])], new Map())
const a = tree.children[0]!.children[0]!
const b = tree.children[1]!.children[0]!
expect(selectionFocus(tree, tree, { kind: 'event', id: 'b-0' }, [])?.node).toBe(tree)
expect(selectionFocus(tree, a, { kind: 'event', id: 'b-0' }, [])?.node).toBe(b)
expect(selectionFocus(tree, b, { kind: 'event', id: 'thread-0' }, [])?.node).toBe(a.children[0])
const featureEvent = selectionFocus(tree, b, { kind: 'event', id: 'a-0' }, [])!
expect(featureEvent.node).toBe(a.parent)
expect(visibleLevel(featureEvent.node).children.some(node => node.events.some(event => event.id === 'a-0'))).toBe(true)
expect(selectionFocus(tree, b, { kind: 'story', id: 'a' }, [])).toMatchObject({ node: a, angle: (a.a0 + a.a1) / 2, time: (a.start + a.end) / 2 })
expect(selectionFocus(tree, a, { kind: 'story', id: 'cross' }, [])?.node.level).toBe('cross')
expect(selectionFocus(tree, a, { kind: 'event', id: 'missing' }, [])).toBeUndefined()
})
it('finds a hidden experience at its linking level even outside the default six-link limit', () => {
const tree = buildStoryTree([story('a', 'feature', [1]), story('b', 'feature', [3])], new Map())
const experiences = Array.from({ length: 7 }, (_, i) => ({ id: `w${i}`, statement: '', conditions: '', boundaries: '', userEdited: false,
events: ['a-0', 'b-0'].map((id, index) => ({ id, role: index ? 'applied' as const : 'formed' as const, at: at(index ? 3 : 1), note: '', title: '', projectId: 'p1', storyId: null, storyName: null })) }))
expect(experienceLinks(experiences, tree.children[0]!.children).some(link => link.id === 'w6')).toBe(false)
const target = selectionFocus(tree, tree, { kind: 'experience', id: 'w6' }, experiences)
expect(target?.node).toBe(tree.children[0])
expect(target?.time).toBe(Date.parse(at(2)))
expect(selectionFocus(tree, tree, { kind: 'experience', id: 'missing' }, experiences)).toBeUndefined()
const nested = buildStoryTree([story('feature', 'feature', []), story('a', 'thread', [1], { parentId: 'feature' }),
story('b', 'thread', [3], { parentId: 'feature' })], new Map())
const project = nested.children[0]!
expect(selectionFocus(nested, project, { kind: 'experience', id: 'w6' }, experiences)?.node).toBe(project.children[0])
})
it('retains historical IDs and genuinely unassigned events without borrowing current replacements', () => {
const saved = (id: string, project_id: string | null, d: number) => ({ id, project_id, title: 'Same title', description: '', occurred_at: at(d), started_at: at(d - 0.5) })
const events = [saved('old-extraction', 'p1', 1), saved('never-assigned', 'p1', 2), saved('unknown-project', null, 3), saved('exact-member', 'p1', 4)]
+38
View File
@@ -184,6 +184,44 @@ export function findNode(root: StoryNode, id: string): StoryNode | undefined {
return undefined
}
export type StorySelection = { kind: 'story' | 'event' | 'experience'; id: string }
/** Resolve against the displayed hierarchy first; only leave it when the target is hidden. */
export function selectionFocus(root: StoryNode, focus: StoryNode, selection: StorySelection, experiences: SupervisionExperience[]) {
if (selection.kind === 'story') {
const node = findNode(root, selection.id)
return node && { node, angle: (node.a0 + node.a1) / 2, time: (node.start + node.end) / 2, radius: 188 }
}
if (selection.kind === 'event') {
const event = root.events.find(event => event.id === selection.id)
if (!event) return
const contains = (node: StoryNode) => node.events.some(event => event.id === selection.id)
let node = focus
if (!visibleLevel(node).children.some(contains)) {
node = root
while (true) {
const child = node.children.find(child => child.level !== 'cross' && contains(child))
if (!child) break
node = child
}
// A feature can own events in addition to its threads; show its stave for those events.
if (node.children.length && !node.children.some(contains)) node = node.parent ?? root
}
return { node, time: event.t }
}
const experience = experiences.find(item => item.id === selection.id)
if (!experience) return
const levels = [focus]
for (let i = 0; i < levels.length; i++) {
const node = levels[i]!
const link = experienceLinks([experience], visibleLevel(node).children)[0]
if (link) return { node, angle: link.angle, time: link.t, radius: 232 }
// After checking the current view, search every branch from the root.
if (i === 0) levels.push(root)
else levels.push(...node.children.filter(child => child.children.length))
}
}
export type ExperienceLink = {
id: string
statement: string
@@ -63,9 +63,12 @@ describe('state-driven duration snapshots', () => {
getExecutionStats.mockImplementation(() => new Promise<ExecutionStats>((resolve) => pending.push(resolve)))
const { result, rerender } = renderHook(({ id }) => useExecutionStats(id, 'p', true), { initialProps: { id: 'old' } })
rerender({ id: 'new' })
act(() => { listeners.forEach((listener) => listener()) })
await act(async () => { pending[4]!(snapshot(0, 9000)); pending[5]!(snapshot(0, 9000)) })
act(() => { for (let i = 0; i < 100; i++) listeners.forEach((listener) => listener()) })
expect(getExecutionStats).toHaveBeenCalledTimes(4)
await act(async () => { for (const resolve of pending.slice(0, 4)) resolve(snapshot(1, 1000)) })
expect(result.current.conversation).toBeUndefined()
expect(getExecutionStats).toHaveBeenCalledTimes(6)
await act(async () => { pending[4]!(snapshot(0, 9000)); pending[5]!(snapshot(0, 9000)) })
expect(result.current.conversation?.durationMs).toBe(9000)
expect(result.current.project?.runningCount).toBe(0)
})
+9 -1
View File
@@ -15,14 +15,22 @@ export function useExecutionStats(
if (!enabled || !projectId) return
let cancelled = false
let revision = 0
let inFlight = false
let queued = false
const refresh = async (): Promise<void> => {
const current = ++revision
revision++
if (inFlight) { queued = true; return }
inFlight = true
queued = false
const current = revision
const [conversation, project] = await Promise.allSettled([
conversationId
? window.goodbuddy.tasks.getExecutionStats({ conversationId })
: Promise.resolve(undefined),
window.goodbuddy.tasks.getExecutionStats({ projectId })
])
inFlight = false
if (!cancelled && queued) { void refresh(); return }
if (cancelled || current !== revision) return
startTransition(() => {
if (cancelled || current !== revision) return