fix(desktop): keep expanded SubAgent run tool cards across live refresh

The SubAgent run detail tab polls every 2s while the run is live, and
mapHistoryMessagesToUiMessages minted a fresh nextId() for every
thinking/tool_use/tool_result block on each pass. The new ids changed
the ToolCallGroup/ToolCallTree React keys, remounting ToolCallBlock and
dropping its local expanded state, so any tool card the user expanded
collapsed again on the next poll.

Derive the UI message id from the transcript identity instead:
msg.id (always set by the server-side entriesToMessages boundary) plus
the block index is deterministic across remaps and unique within the
source message, so polling keeps keys stable and expansion survives.
This commit is contained in:
程序员阿江(Relakkes)
2026-08-02 02:19:25 +08:00
parent 52dce5917a
commit eacced7ad4
3 changed files with 106 additions and 5 deletions
@@ -192,6 +192,71 @@ describe('SubagentRunPage', () => {
expect(screen.getByTestId('subagent-conversation')).toHaveTextContent('export const ready = true')
})
it('keeps an expanded tool call open after a live run refresh', async () => {
const firstRefresh = deferred<SubagentRunResponse>()
const liveRun = (updatedAt: string) => subagentRun({
status: 'running',
prompt: 'Inspect live tools',
updatedAt,
messages: [
{
id: 'child-tool-use',
type: 'tool_use',
content: [
{
type: 'tool_use',
id: 'child-bash-1',
name: 'Bash',
input: { command: 'pwd' },
},
{
type: 'tool_use',
id: 'child-glob-1',
name: 'Glob',
input: { pattern: '*' },
},
],
timestamp: TRANSCRIPT_TIMESTAMP,
},
{
id: 'child-tool-results',
type: 'tool_result',
content: [
{
type: 'tool_result',
tool_use_id: 'child-bash-1',
content: '/workspace',
},
{
type: 'tool_result',
tool_use_id: 'child-glob-1',
content: 'src',
},
],
timestamp: TRANSCRIPT_TIMESTAMP,
},
],
})
vi.mocked(subagentsApi.getRunByTool)
.mockResolvedValueOnce(liveRun(TRANSCRIPT_TIMESTAMP))
.mockReturnValueOnce(firstRefresh.promise)
render(<SubagentRunPage sourceSessionId="session-1" toolUseId="tool-1" title="SubAgent" />)
fireEvent.click(await screen.findByRole('button', { name: /ran a command, found files/i }))
fireEvent.click(screen.getByRole('button', { name: /Bash.*pwd/i }))
expect(document.querySelector('[data-shell-output]')).toHaveTextContent('/workspace')
await waitFor(() => expect(subagentsApi.getRunByTool).toHaveBeenCalledTimes(2), { timeout: 2500 })
await act(async () => {
firstRefresh.resolve(liveRun('2026-07-03T10:20:13.000Z'))
await firstRefresh.promise
})
expect(document.querySelector('[data-shell-output]')).toHaveTextContent('/workspace')
})
it('discovers a live task id that arrives after the detail tab opens', async () => {
vi.mocked(subagentsApi.getRunByTool).mockResolvedValue(subagentRun({
status: 'running',
+36
View File
@@ -695,6 +695,42 @@ describe('chatStore history mapping', () => {
])
})
it('keeps transcript block ids stable across repeated history mapping', () => {
const messages: MessageEntry[] = [
{
id: 'assistant-tools-1',
type: 'tool_use',
timestamp: '2026-04-06T00:00:00.000Z',
content: [
{ type: 'thinking', thinking: 'Inspecting the workspace' },
{ type: 'tool_use', name: 'Bash', id: 'bash-1', input: { command: 'pwd' } },
{ type: 'tool_use', name: 'Glob', id: 'glob-1', input: { pattern: '*' } },
],
},
{
id: 'user-tool-results-1',
type: 'tool_result',
timestamp: '2026-04-06T00:00:01.000Z',
content: [
{ type: 'tool_result', tool_use_id: 'bash-1', content: '/workspace', is_error: false },
{ type: 'tool_result', tool_use_id: 'glob-1', content: 'src', is_error: false },
],
},
]
const firstIds = mapHistoryMessagesToUiMessages(messages).map((message) => message.id)
const secondIds = mapHistoryMessagesToUiMessages(messages).map((message) => message.id)
expect(secondIds).toEqual(firstIds)
expect(firstIds).toEqual([
'assistant-tools-1-block-0',
'assistant-tools-1-block-1',
'assistant-tools-1-block-2',
'user-tool-results-1-block-0',
'user-tool-results-1-block-1',
])
})
it('restores slash-command metadata as readable history while skipping malformed breadcrumbs', () => {
const messages: MessageEntry[] = [
{
+5 -5
View File
@@ -4535,12 +4535,12 @@ export function mapHistoryMessagesToUiMessages(
continue
}
if ((msg.type === 'assistant' || msg.type === 'tool_use') && Array.isArray(msg.content)) {
for (const block of msg.content as AssistantHistoryBlock[]) {
if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: nextId(), type: 'thinking', content: block.thinking, timestamp })
for (const [blockIndex, block] of (msg.content as AssistantHistoryBlock[]).entries()) {
if (block.type === 'thinking' && block.thinking) uiMessages.push({ id: `${msg.id}-block-${blockIndex}`, type: 'thinking', content: block.thinking, timestamp })
else if (block.type === 'text' && block.text) {
pushAssistantHistoryText(uiMessages, block.text, timestamp, msg.model, msg.id || undefined)
}
else if (block.type === 'tool_use') uiMessages.push({ id: nextId(), type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
else if (block.type === 'tool_use') uiMessages.push({ id: `${msg.id}-block-${blockIndex}`, type: 'tool_use', toolName: block.name ?? 'unknown', toolUseId: block.id ?? '', input: block.input, timestamp, parentToolUseId: msg.parentToolUseId })
}
continue
}
@@ -4550,7 +4550,7 @@ export function mapHistoryMessagesToUiMessages(
const attachments: UIAttachment[] = []
const imageSourcePaths: string[] = []
const hasImageBlock = (msg.content as UserHistoryBlock[]).some((block) => block.type === 'image')
for (const block of msg.content as UserHistoryBlock[]) {
for (const [blockIndex, block] of (msg.content as UserHistoryBlock[]).entries()) {
if (block.type === 'text' && block.text && isTeammateMessage(block.text)) {
modelTextParts.push(block.text)
if (!includeTeammateMessages) continue
@@ -4568,7 +4568,7 @@ export function mapHistoryMessagesToUiMessages(
else if (block.type === 'image') attachments.push(normalizeHistoryImageAttachment(block))
else if (block.type === 'file') attachments.push({ type: 'file', name: block.name || 'file' })
else if (block.type === 'tool_result') uiMessages.push({
id: nextId(),
id: `${msg.id}-block-${blockIndex}`,
type: 'tool_result',
toolUseId: block.tool_use_id ?? '',
content: normalizeHistoryToolResultContent(block.content, msg.toolUseResult),