fix: resolve runtime, review, channel and chat state failures

Keep remote startup cancellable without blocking other sessions and surface local OpenCode submission failures promptly. Preserve supervisor project isolation, pause behavior and valid story destinations.

Restore generated-image channel delivery, Telegram reconnection on unchanged Save and DingTalk business-error retries. Preserve virtualized message disclosures using sparse state and release stale supervisor UI requests.

Add production-path regressions, Electron checks, isolated SQLite validation and audit evidence. Fix the SDK cleanup test fixture lifetime under garbage collection without weakening its assertions.

Validation: 6136 tests passed, 87 skipped; typecheck, lint and diff checks passed. Seven real text-model calls, current-source Linux x64 Host scenarios and large-history Electron comparisons completed.

Release note: Fix stuck runtime requests, inconsistent review results and channel delivery failures, and retain expanded details while browsing long conversations. Remote runtime fixes require an Agent build containing these changes.
This commit is contained in:
mesalogo
2026-10-05 10:25:43 +08:00
parent 126dbc57e2
commit bbf24c282a
49 changed files with 1710 additions and 106 deletions
@@ -178,6 +178,39 @@ afterEach(async () => {
})
describe('ChannelSettingsSection', () => {
it.each(['error', 'stopped', 'running', 'starting', 'disabled'] as const)(
'only reapplies unchanged enabled Telegram settings when pushed state is %s and needs recovery',
async (state) => {
const configured: ChannelSettingsSnapshot = {
...snapshot,
telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted', status: { state: 'running' } }
}
let receive!: (change: ChannelRuntimeStatusChange) => void
const apply = vi.fn(async () => configured)
const onNotify = vi.fn()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: { ...bindingApi(), getSnapshot: async () => configured, apply,
onStatusChanged: (listener: typeof receive) => { receive = listener; return () => undefined } },
settings: settingsApi(), projects: { update: vi.fn(async () => projects[0]) }
} as unknown as DesktopApi
})
renderChannelSettings({ initialChannel: 'telegram', onNotify })
await screen.findByLabelText('TelegramBot Token')
act(() => receive({ channel: 'telegram', status: { state } }))
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
await waitFor(() => expect(onNotify).toHaveBeenCalled())
if (state === 'error' || state === 'stopped') {
expect(apply).toHaveBeenCalledWith({ telegram: {
enabled: true, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
} })
} else {
expect(apply).not.toHaveBeenCalled()
}
}
)
it('keeps pushed channel status newer than loading and updates reconnect errors without resetting drafts', async () => {
let receive!: (change: ChannelRuntimeStatusChange) => void
const unsubscribe = vi.fn()
+4 -1
View File
@@ -1002,6 +1002,9 @@ export function ChannelSettingsSection({
)
return
}
const telegramState = (statusStore.getState().telegram ?? snapshot.telegram.status).state
const restartTelegram = drafts.telegram.enabled &&
(telegramState === 'error' || telegramState === 'stopped')
const input: ChannelSettingsApply = {
...(weixinEnabled === snapshot.weixin.enabled
? {}
@@ -1015,7 +1018,7 @@ export function ChannelSettingsSection({
? { dingtalk: inputFor('dingtalk', drafts.dingtalk) }
: {}),
...(!snapshot.telegram.readOnly &&
channelDraftChanged('telegram', drafts.telegram, snapshot)
(channelDraftChanged('telegram', drafts.telegram, snapshot) || restartTelegram)
? { telegram: inputFor('telegram', drafts.telegram) }
: {})
}
+85
View File
@@ -182,6 +182,91 @@ afterEach(() => {
})
describe('ChatHistoryPane windowing', () => {
it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => {
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
const tool = { callId: 'read', name: 'read', summary: 'Read file', state: 'completed' as const, input: '{"path":"file"}', output: 'File contents' }
Object.assign(messages[0]!, { tools: [tool], subagents: [{ childTaskId: 'child', expertId: 'expert', expertName: 'Expert',
routingMode: 'native', state: 'completed', progress: [{ id: 'child-tool', type: 'tool', tool }] }] })
const { container, chat } = setup(messages)
measure()
const visit = async (index: number) => {
act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) })
await frame()
measure()
}
const disclosure = (index: number) => container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index}"] details`)!
await visit(0)
const original = disclosure(0)
act(() => { original.open = true; fireEvent(original, new Event('toggle')) })
const toggle = (selector: string, open: boolean) => {
const element = container.querySelector<HTMLDetailsElement>(`[data-message-id="m0"] ${selector}`)!
act(() => { element.open = open; fireEvent(element, new Event('toggle')) })
}
toggle('.subagent-status-card', true)
toggle('.subagent-status-card .tool-execution', true)
toggle('.subagent-status-card .tool-execution__input', true)
// Collapsing a subagent unmounts its children even before row eviction.
toggle('.subagent-status-card', false)
toggle('.subagent-status-card', true)
expect(container.querySelector<HTMLDetailsElement>('.subagent-status-card .tool-execution__input')!.open).toBe(true)
for (let index = 1; index <= 12; index++) {
await visit(index * 100)
const details = disclosure(index * 100)
act(() => { details.open = true; fireEvent(details, new Event('toggle')) })
expect(renderedIds(container).length).toBeLessThanOrEqual(maxRenderedMessageCount + 8)
}
expect(original.isConnected).toBe(false)
await visit(0)
expect(disclosure(0)).not.toBe(original)
expect(disclosure(0).open).toBe(true)
expect(container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .subagent-status-card')!.open).toBe(true)
expect(container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .subagent-status-card .tool-execution__input')!.open).toBe(true)
// The parent and child deliberately share a tool call ID, but not state.
expect([...container.querySelectorAll<HTMLDetailsElement>('[data-message-id="m0"] .tool-execution')]
.find(element => !element.closest('.subagent-status-card'))!.open).toBe(false)
act(() => { disclosure(0).open = false; fireEvent(disclosure(0), new Event('toggle')) })
await visit(1500)
await visit(0)
expect(disclosure(0).open).toBe(false)
})
it.each(['legacy', 'blocks'] as const)('preserves %s streaming reasoning defaults and manual choices through eviction and completion', async representation => {
const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' }))
messages[0] = { ...messages[0]!, state: 'streaming' }
if (representation === 'blocks') Object.assign(messages[0]!, { blocks: [{ id: 'reason', type: 'reasoning', content: 'Reasoning' }] })
const { container, chat, props, rerender } = setup(messages)
measure()
const visit = async (index: number) => {
act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) })
await frame(); measure()
}
const details = () => container.querySelector<HTMLDetailsElement>('[data-message-id="m0"] .message-reasoning')!
await visit(0)
expect(details().open).toBe(true)
act(() => { details().open = false; fireEvent(details(), new Event('toggle')) })
for (let index = 1; index <= 10; index++) {
await visit(index * 100)
const other = container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index * 100}"] details`)!
act(() => { other.open = true; fireEvent(other, new Event('toggle')) })
}
expect(details()).toBeNull()
await visit(0)
expect(details().open).toBe(false)
act(() => { details().open = true; fireEvent(details(), new Event('toggle')) })
for (let index = 1; index <= 10; index++) {
await visit(index * 100)
const other = container.querySelector<HTMLDetailsElement>(`[data-message-id="m${index * 100}"] details`)!
act(() => { other.open = true; fireEvent(other, new Event('toggle')) })
}
expect(details()).toBeNull()
// Completion keeps the existing auto-close semantics rather than restoring
// the streaming expansion on the next mount.
const completed = messages.map((message, index) => index === 0 ? { ...message, state: 'complete' as const } : message)
rerender(<ChatHistoryPane {...props} conversation={conversationOf(completed)} />)
await visit(0)
expect(details().open).toBe(false)
})
it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', async () => {
const messages = makeMessages(2_000)
const { container, chat } = setup(messages)
+7 -7
View File
@@ -18,6 +18,7 @@ import type {
import type { ImageOperation } from "../../shared/image-generation-contracts";
import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline";
import { isUnusedConversation, type Conversation } from "./chat-conversation";
import { MessageExpansionProvider } from "./message-expansion";
import {
buildSegments,
maxRenderedMessageCount,
@@ -246,8 +247,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
// Rows kept mounted outside the range: the focused row, the first message
// after the final "load earlier" step.
const [keptMessageIds, setKeptMessageIds] = useState<readonly string[]>([]);
// Rows the user changed (toggled a <details>, typed into a question card):
// their local state would be lost if they unmounted. Bounded, oldest out.
// Keep recent interactions warm (copy feedback, previews, typed answers).
// Disclosure choices also survive eviction in MessageExpansionProvider.
const [interactedMessageIds, setInteractedMessageIds] = useState<readonly string[]>([]);
// Rows holding the start and end of a non-empty text selection.
const [selectionMessageIds, setSelectionMessageIds] = useState<readonly string[]>([]);
@@ -544,9 +545,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
return () => cancelAnimationFrame(frame);
}, [active, captureAnchor, navigationTarget, view, visibleMessageCount]);
// Rows the user interacts with keep their local state (open details, typed
// question answers, "copied" feedback, Mermaid toggles) as they did when
// every loaded row stayed mounted.
// Keep a bounded set of recently interacted rows mounted. Disclosure state
// lives outside the rows, while pending questions are independently pinned.
useEffect(() => {
const list = listRef.current;
if (!active || !list) return;
@@ -1065,7 +1065,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
};
return (
<div
<MessageExpansionProvider messages={messages} messageIndexes={messageIndexById}><div
aria-hidden={active ? undefined : "true"}
className="chat-history-pane"
data-active={active ? "true" : "false"}
@@ -1153,6 +1153,6 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({
<ArrowDown aria-hidden="true" size={18} />
</button>
)}
</div>
</div></MessageExpansionProvider>
);
});
+21 -9
View File
@@ -1,5 +1,6 @@
import { AttachmentResultButton } from './AttachmentResultButton'
import { AttachmentActions, AttachmentStatus } from './AttachmentActions'
import { MessageExpansionScope, useMessageExpansion } from './message-expansion'
import {
Bot,
Check,
@@ -167,14 +168,17 @@ function formatAttachmentSize(size: number): string {
}
function MessageReasoning({
expansionId = 'reasoning',
content,
streaming
}: {
expansionId?: string
content: string
streaming: boolean
}): React.JSX.Element {
const { t } = useTranslation('app')
const contentRef = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useMessageExpansion(expansionId, streaming)
useEffect(() => {
if (!streaming || !contentRef.current) {
@@ -187,7 +191,7 @@ function MessageReasoning({
}, [content, streaming])
return (
<details className="message-reasoning" open={streaming}>
<details className="message-reasoning" open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}>
<summary>
{streaming
? t('chat.reasoning.streaming')
@@ -239,12 +243,15 @@ const ToolDetail = memo(function ToolDetail({ label, content, formatJson = false
)
})
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pick<
const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, ...tool }: Pick<
ToolActivity, 'name' | 'summary' | 'state' | 'input' | 'output' | 'error'
> & {
expansionId: string
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useMessageExpansion(`tool:${expansionId}`)
const [inputExpanded, setInputExpanded] = useMessageExpansion(`tool-input:${expansionId}`)
const summary = tool.summary.trim()
// Only suppress known boilerplate; recovery hints and custom summaries remain visible.
const summaryIdentity = summary.replace(
@@ -258,6 +265,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic
return (
<li>
<details
open={expanded} onToggle={event => setExpanded(event.currentTarget.open)}
className={`tool-execution tool-execution--${tool.state}`}
>
<summary>
@@ -290,7 +298,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic
: tool.state === 'completed' ? 'chat.tools.emptyOutput' : 'chat.tools.noDetails')}</p>
)}
{tool.input && (
<details className="tool-execution__input">
<details className="tool-execution__input" open={inputExpanded} onToggle={event => setInputExpanded(event.currentTarget.open)}>
<summary>{t('chat.tools.input')}</summary>
<ToolDetail label={t('chat.tools.input')} content={tool.input} formatJson onCopy={onCopy} />
</details>
@@ -322,6 +330,7 @@ function ToolExecutionList({
{tools.map((tool) => (
<ToolExecutionRow
key={tool.callId ?? tool.name}
expansionId={tool.callId ?? tool.name}
name={tool.name}
summary={tool.summary}
state={tool.state}
@@ -348,7 +357,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
onCopy: CopyContent
}): React.JSX.Element {
const { t } = useTranslation('app')
const [expanded, setExpanded] = useState(false)
const [expanded, setExpanded] = useMessageExpansion('subagent')
const StateIcon =
subagent.state === 'completed'
? CheckCircle2
@@ -389,6 +398,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
return (
<details
className={`subagent-status-card subagent-status-card--${subagent.state}`}
open={expanded}
onToggle={(event) => setExpanded(event.currentTarget.open)}
>
<summary>
@@ -450,6 +460,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({
: item.block.type === 'reasoning' ? (
<MessageReasoning
key={item.block.id}
expansionId={`reasoning:${item.block.id}`}
content={item.block.content}
streaming={false}
/>
@@ -513,13 +524,12 @@ function SubagentStatusList({
className="subagent-status-list"
>
{subagents.map((subagent) => (
<SubagentStatusCard
key={subagent.childTaskId}
<MessageExpansionScope key={subagent.childTaskId} id={`subagent:${subagent.childTaskId}`}><SubagentStatusCard
renderHtml={renderHtml}
subagent={subagent}
questionFormId={pendingQuestions?.some((question) => question.childTaskId === subagent.childTaskId) ? questionFormId : undefined}
onCopy={onCopy}
/>
/></MessageExpansionScope>
))}
</section>
)
@@ -581,6 +591,7 @@ function ChatMessageRowView({
}: ChatMessageRowProps): React.JSX.Element {
const { t } = useTranslation('app')
const [copied, setCopied] = useState(false)
const [citationsExpanded, setCitationsExpanded] = useMessageExpansion('citations')
const copyResetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => clearTimeout(copyResetTimer.current), [])
@@ -867,6 +878,7 @@ function ChatMessageRowView({
/>
) : item.block.type === 'reasoning' ? (
<MessageReasoning
expansionId={`reasoning:${item.block.id}`}
content={item.block.content}
key={item.block.id}
streaming={message.state === 'streaming'}
@@ -1054,7 +1066,7 @@ function ChatMessageRowView({
)}
{message.sourceReferences &&
message.sourceReferences.length > 0 && (
<details className="message-citations">
<details className="message-citations" open={citationsExpanded} onToggle={event => setCitationsExpanded(event.currentTarget.open)}>
<summary>
{t('chat.citations.view', {
count: message.sourceReferences.length
@@ -1272,7 +1284,7 @@ function ChatMessageRowView({
export const ChatMessageRow = memo(function ChatMessageRow(
props: ChatMessageRowProps
): React.JSX.Element {
return <ChatMessageRowView {...props} message={useLiveMessage(props.message)} />
return <MessageExpansionScope id={props.message.id}><ChatMessageRowView {...props} message={useLiveMessage(props.message)} /></MessageExpansionScope>
})
type ChatMessageWindowRowProps = ChatMessageRowProps & {
@@ -15,6 +15,66 @@ const selectHistory = (id: string) => {
}
describe('SupervisorWorkspace', () => {
it.each(['locale', 'review'] as const)('releases pending source state on %s refresh without letting its late response clear a newer request', async trigger => {
let finishReview!: () => void
const run = vi.fn(() => new Promise<void>(resolve => { finishReview = resolve }))
let finishOld!: (value: unknown) => void
let finishNew!: (value: unknown) => void
const source = vi.fn()
.mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve }))
.mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve }))
const graph = vi.fn(async () => ({ storyLine: null,
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }],
eventSources: [{ event_id: 'event', source_id: 'source' }] }))
window.goodbuddy = { supervision: { overview: async () => [result], graph, source, run } } as never
render(<SupervisorWorkspace tab="graph" />)
await screen.findByRole('button', { name: /Source/ })
if (trigger === 'review') {
fireEvent.click(screen.getByRole('button', { name: '新回顾' }))
fireEvent.click(screen.getByRole('button', { name: '回顾' }))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
}
fireEvent.click(await screen.findByRole('button', { name: /Source/ }))
await waitFor(() => expect(source).toHaveBeenCalledTimes(1))
await act(async () => { if (trigger === 'locale') await changeUiLocale('en-US'); else finishReview() })
const refreshLabel = trigger === 'locale' ? 'Refresh' : '刷新'
await waitFor(() => {
expect(graph).toHaveBeenCalledTimes(2)
expect(screen.getByRole('button', { name: /Source/ })).toBeEnabled()
expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled()
})
fireEvent.click(screen.getByRole('button', { name: /Source/ }))
await act(async () => finishOld({ title: 'Source', content: 'Obsolete source', occurredAt: result.createdAt }))
expect(screen.queryByText('Obsolete source')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Source/ })).toBeDisabled()
await act(async () => finishNew({ title: 'Source', content: 'Current source', occurredAt: result.createdAt }))
expect(screen.getByText('Current source')).toBeVisible()
expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled()
})
it.each(['entity', 'relation'] as const)('releases a pending %s action when locale refresh invalidates it', async kind => {
let finish!: () => void
const action = vi.fn(() => new Promise<void>(resolve => { finish = resolve }))
const graph = vi.fn(async () => ({ storyLine: null,
events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }],
entities: [{ id: 'entity', canonical_label: 'Entity', description: '', confirmation_state: 'automatic' }],
relations: [{ id: 'relation', from_entity_id: 'entity', to_entity_id: 'entity', relation_type: 'related', reason: '', confirmation_state: 'automatic' }],
sources: [], eventEntities: [], eventSources: [] }))
window.goodbuddy = { supervision: { overview: async () => [result], graph, entityAction: action, relationAction: action } } as never
render(<SupervisorWorkspace tab="graph" />)
await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled())
fireEvent.change(screen.getByRole('combobox', { name: '图谱选择' }), { target: { value: kind } })
fireEvent.click(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Entity/ }))
fireEvent.click(screen.getByRole('button', { name: i18nResources['zh-CN'].heartbeat.supervisor.confirm }))
await waitFor(() => expect(action).toHaveBeenCalledOnce())
await act(async () => { await changeUiLocale('en-US') })
await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled())
await act(async () => finish())
expect(graph).toHaveBeenCalledTimes(2)
expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()
})
it.each(['zh-CN', 'en-US'] as const)('submits custom days and keeps reanalysis confirmation consistent in %s', async locale => {
await changeUiLocale(locale)
const copy = i18nResources[locale].heartbeat.supervisor
+1
View File
@@ -158,6 +158,7 @@ export function SupervisorWorkspace({
const refresh = useCallback(async (requestedId = selectedResult.current) => {
if (!api) return
const generation = ++loadGeneration.current
setPending(undefined)
selectedResult.current = requestedId
setLoading(true)
setGraph(emptyGraph)
+110
View File
@@ -0,0 +1,110 @@
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Message } from './ChatTimeline'
import { MessageExpansionProvider, MessageExpansionScope, useMessageExpansion } from './message-expansion'
afterEach(() => { cleanup(); vi.restoreAllMocks() })
function Disclosure({ id, automaticOpen = false }: { id: string; automaticOpen?: boolean }) {
const [open, setOpen] = useMessageExpansion(id, automaticOpen)
return <button aria-label={id} aria-pressed={open} onClick={() => setOpen(!open)} />
}
function captureMaps() {
const maps = new Set<Map<unknown, unknown>>()
const original = Map.prototype.set
vi.spyOn(Map.prototype, 'set').mockImplementation(function (this: Map<unknown, unknown>, key, value) {
if (value && typeof value.open === 'boolean' && typeof value.automaticOpen === 'boolean') maps.add(this)
return original.call(this, key, value)
})
return () => [...maps].reduce((total, map) => total + map.size, 0)
}
describe('message expansion retention', () => {
it('keeps streaming defaults sparse and releases an unmounted manual collapse on completion', async () => {
const entries = captureMaps()
const message: Message = { id: 'm', content: '', reasoning: 'Reason', state: 'streaming', role: 'assistant', createdAt: 1 }
const window = (current: Message, visible: boolean) => <MessageExpansionProvider messages={[current]} messageIndexes={new Map([['m', 0]])}>
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning" automaticOpen={current.state === 'streaming'} /></MessageExpansionScope>}
</MessageExpansionProvider>
const view = render(window(message, true))
expect(entries()).toBe(0)
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(view.getByRole('button'))
expect(entries()).toBe(1)
view.rerender(window(message, false))
view.rerender(window(message, true))
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
view.rerender(window(message, false))
const completed = { ...message, state: 'complete' as const }
await act(async () => view.rerender(window(completed, false)))
expect(entries()).toBe(0)
view.rerender(window(completed, true))
expect(view.getByRole('button')).toHaveAttribute('aria-pressed', 'false')
})
it('removes obsolete child and block identities without treating row eviction as deletion', async () => {
const entries = captureMaps()
const message: Message = { id: 'm', content: '', state: 'complete', role: 'assistant', createdAt: 1,
blocks: [{ id: 'reason', type: 'reasoning', content: 'Reason' }],
subagents: [{ childTaskId: 'child', expertId: 'expert', expertName: 'Expert', routingMode: 'native', state: 'completed' }] }
const window = (current: Message, visible: boolean) => <MessageExpansionProvider messages={[current]} messageIndexes={new Map([['m', 0]])}>
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning:reason" />
<MessageExpansionScope id="subagent:child"><Disclosure id="subagent" /></MessageExpansionScope>
</MessageExpansionScope>}
</MessageExpansionProvider>
const view = render(window(message, true))
fireEvent.click(view.getByRole('button', { name: 'reasoning:reason' }))
fireEvent.click(view.getByRole('button', { name: 'subagent' }))
view.rerender(window(message, false))
expect(entries()).toBe(2)
await act(async () => view.rerender(window({ ...message, blocks: [], subagents: [] }, false)))
expect(entries()).toBe(0)
})
it('visits 10,000 untouched rows without retaining defaults, including absent citations', () => {
const entries = captureMaps()
const messages: Message[] = Array.from({ length: 10_000 }, (_, index) => ({
id: `m${index}`, content: 'Plain message', role: 'assistant', state: 'complete', createdAt: index,
}))
const messageIndexes = new Map(messages.map((message, index) => [message.id, index]))
const window = (start: number) => <MessageExpansionProvider messages={messages} messageIndexes={messageIndexes}>
{messages.slice(start, start + 80).map(message => <MessageExpansionScope key={message.id} id={message.id}>
<Disclosure id="citations" />
</MessageExpansionScope>)}
</MessageExpansionProvider>
const view = render(window(0))
for (let start = 80; start < messages.length; start += 80) view.rerender(window(start))
expect(view.container.querySelectorAll('button').length).toBeLessThanOrEqual(80)
expect(entries()).toBe(0)
})
it('retains only overrides across eviction and cleans removed messages and disclosures', async () => {
const entries = captureMaps()
const messages: Message[] = [{ id: 'm', content: '', role: 'assistant', state: 'complete', createdAt: 1,
reasoning: 'Reason', tools: [{ callId: 'read', name: 'read', summary: '', state: 'completed', input: 'input' }] }]
const window = (items: Message[], visible: boolean) => <MessageExpansionProvider messages={items}
messageIndexes={new Map(items.map((message, index) => [message.id, index]))}>
{visible && <MessageExpansionScope id="m"><Disclosure id="reasoning" /><Disclosure id="tool:read" /><Disclosure id="tool-input:read" /></MessageExpansionScope>}
</MessageExpansionProvider>
const view = render(window(messages, true))
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
fireEvent.click(view.getByRole('button', { name: 'tool:read' }))
fireEvent.click(view.getByRole('button', { name: 'tool-input:read' }))
expect(entries()).toBe(3)
view.rerender(window(messages, false))
expect(entries()).toBe(3)
view.rerender(window(messages, true))
expect(view.getByRole('button', { name: 'reasoning' })).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
expect(entries()).toBe(2)
view.rerender(window(messages, false))
await act(async () => view.rerender(window([{ ...messages[0]!, tools: undefined }], false)))
expect(entries()).toBe(0)
view.rerender(window(messages, true))
fireEvent.click(view.getByRole('button', { name: 'reasoning' }))
expect(entries()).toBe(1)
await act(async () => view.rerender(window([], false)))
expect(entries()).toBe(0)
})
})
+69
View File
@@ -0,0 +1,69 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import type { Message } from './ChatTimeline'
type Expansion = { open: boolean; automaticOpen: boolean }
type Override = Expansion & { messageId: string; scopeId?: string; id: string }
const ExpansionContext = createContext<{
states: Map<string, Override>; path: string; messageId?: string; scopeId?: string
} | undefined>(undefined)
/** Only called for saved overrides, after message updates, never on scroll. */
function currentDefault(message: Message, entry: Override): boolean | undefined {
const child = entry.scopeId ? message.subagents?.find(item => `subagent:${item.childTaskId}` === entry.scopeId) : undefined
if (entry.scopeId && !child) return undefined
const blocks = child ? child.progress : message.blocks
const automaticOpen = !child && message.state === 'streaming'
if (entry.id === 'subagent') return child ? false : undefined
if (entry.id === 'citations') return message.sourceReferences?.length ? false : undefined
if (entry.id === 'reasoning') return !blocks?.length && message.reasoning ? automaticOpen : undefined
if (entry.id.startsWith('reasoning:')) {
return blocks?.some(block => block.type === 'reasoning' && `reasoning:${block.id}` === entry.id) ? automaticOpen : undefined
}
const input = entry.id.startsWith('tool-input:')
const toolId = entry.id.slice(input ? 'tool-input:'.length : 'tool:'.length)
const matches = (tool: NonNullable<Message['tools']>[number]) =>
(tool.callId ?? tool.name) === toolId && (!input || Boolean(tool.input))
const exists = blocks?.length ? blocks.some(block => block.type === 'tool' && matches(block.tool))
: !child && message.tools?.some(matches)
return exists ? false : undefined
}
/** Owned by the history pane, not by rows that scrolling can unmount. */
export function MessageExpansionProvider({ children, messages, messageIndexes }: {
children: ReactNode; messages: readonly Message[]; messageIndexes: ReadonlyMap<string, number>
}) {
const [value] = useState(() => ({ states: new Map<string, Override>(), path: '' }))
useEffect(() => {
for (const [key, entry] of value.states) {
const index = messageIndexes.get(entry.messageId)
const message = index === undefined ? undefined : messages[index]
if (!message || currentDefault(message, entry) !== entry.automaticOpen) value.states.delete(key)
}
}, [messages, messageIndexes, value])
return <ExpansionContext value={value}>{children}</ExpansionContext>
}
export function MessageExpansionScope({ id, children }: { id: string; children: ReactNode }) {
const parent = useContext(ExpansionContext)
const value = useMemo(() => parent && ({ states: parent.states, path: `${parent.path}/${JSON.stringify(id)}`,
messageId: parent.messageId ?? id, scopeId: parent.messageId ? id : undefined }), [parent, id])
return <ExpansionContext value={value}>{children}</ExpansionContext>
}
export function useMessageExpansion(id: string, automaticOpen = false): [boolean, (open: boolean) => void] {
const context = useContext(ExpansionContext)
const key = `${context?.path}/${JSON.stringify(id)}`
const [state, setState] = useState(() => {
const saved = context?.states.get(key)
return saved?.automaticOpen === automaticOpen ? saved : { open: automaticOpen, automaticOpen }
})
// Reasoning still opens while streaming and closes on completion, including
// when that transition happened while its row was outside the window.
if (state.automaticOpen !== automaticOpen) setState({ open: automaticOpen, automaticOpen })
return [state.open, open => {
const next = { open, automaticOpen }
if (open === automaticOpen) context?.states.delete(key)
else if (context?.messageId) context.states.set(key, { ...next, messageId: context.messageId, scopeId: context.scopeId, id })
setState(next)
}]
}