diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 4096e7588..18886e989 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -109,6 +109,18 @@ function normalizeUserOutput( const messageContent = message.content + // Skip system-injected messages that were logged as type:'user' but are + // not text the human actually typed (task notifications, command caveats, etc.) + if (typeof messageContent === 'string') { + const trimmed = messageContent.trimStart() + if ( + trimmed.startsWith('') || + trimmed.startsWith('') + ) { + return null + } + } + if (isSidechain && typeof messageContent === 'string') { return { id: messageId, diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index 962737d27..1d40714e1 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -73,9 +73,28 @@ export function reduceTimeline( } if (msg.role === 'agent') { + // When the message contains a Task tool_use, Claude often writes the + // prompt as a text block before the tool_use block. We only want to + // suppress that exact prompt text — not every text block in the message. + const taskToolCall = msg.content.find( + (c) => c.type === 'tool-call' && c.name === 'Task' + ) + const taskPromptText: string | null = (() => { + if (!taskToolCall || taskToolCall.type !== 'tool-call') return null + const input = taskToolCall.input + if (typeof input === 'object' && input !== null && 'prompt' in input) { + const p = (input as { prompt: unknown }).prompt + if (typeof p === 'string') return p + } + return null + })() + for (let idx = 0; idx < msg.content.length; idx += 1) { const c = msg.content[idx] if (c.type === 'text') { + // Skip text blocks that are just the Task tool prompt (already shown in tool card) + if (taskPromptText && c.text.trim() === taskPromptText.trim()) continue + if (isCliOutputText(c.text, msg.meta)) { blocks.push(createCliOutputBlock({ id: `${msg.id}:${idx}`,