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:
@@ -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 () => []),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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]])
|
||||
})
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user