mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
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:
@@ -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',
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user