diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 0d02fb2..5625f71 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -4,9 +4,10 @@ import ReactMarkdown, { type Components } from 'react-markdown'; import { api } from '@renderer/api'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; +import { parseTaskNotifications } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import { format } from 'date-fns'; -import { User } from 'lucide-react'; +import { CheckCircle, Circle, FileText, User, XCircle } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -345,6 +346,20 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. const textContent = content.rawText ?? content.text ?? ''; const isLongContent = textContent.length > 500; + // Parse task notifications from the original message content (before sanitization) + const taskNotifications = useMemo(() => { + const raw = + typeof userGroup.message.content === 'string' + ? userGroup.message.content + : Array.isArray(userGroup.message.content) + ? userGroup.message.content + .filter((b): b is { type: 'text'; text: string } => b.type === 'text' && 'text' in b) + .map((b) => b.text) + .join('') + : ''; + return parseTaskNotifications(raw); + }, [userGroup.message.content]); + // Extract @path mentions from text const pathMentions = useMemo(() => { if (!textContent) return []; @@ -462,6 +477,60 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. )} + {/* Task notification cards */} + {taskNotifications.length > 0 && + taskNotifications.map((notif) => { + const isCompleted = notif.status === 'completed'; + const isFailed = notif.status === 'failed' || notif.status === 'error'; + const StatusIcon = isFailed ? XCircle : isCompleted ? CheckCircle : Circle; + const statusColor = isFailed + ? 'var(--error-highlight-text, #ef4444)' + : isCompleted + ? 'var(--badge-success-text, #22c55e)' + : 'var(--color-text-muted)'; + + // Extract quoted command name from summary (e.g., 'Background command "Run foo" completed') + const cmdMatch = /"([^"]+)"/.exec(notif.summary); + const cmdName = cmdMatch?.[1] ?? notif.summary; + // Extract exit code + const exitMatch = /\(exit code (\d+)\)/.exec(notif.summary); + const exitCode = exitMatch?.[1]; + + return ( +
+ +
+
+ {cmdName} +
+
+ {notif.status} + {exitCode != null && exit {exitCode}} + {notif.outputFile && ( + + + {notif.outputFile.split('/').pop()} + + )} +
+
+
+ ); + })} + {/* Images indicator */} {hasImages && (
diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts index e85c145..defc851 100644 --- a/src/shared/utils/contentSanitizer.ts +++ b/src/shared/utils/contentSanitizer.ts @@ -19,8 +19,16 @@ const NOISE_TAG_PATTERNS = [ /[\s\S]*?<\/local-command-caveat>/gi, /[\s\S]*?<\/system-reminder>/gi, + /[\s\S]*?<\/task-notification>/gi, ]; +/** + * Pattern to match the trailing "Read the output file to retrieve the result: /path" + * instruction that follows task notifications. + */ +const TASK_OUTPUT_INSTRUCTION_PATTERN = + / ?Read the output file to retrieve the result: [^\s]+/g; + /** * Extract content from tags. * Returns the command output without the wrapper tags. @@ -110,6 +118,9 @@ export function sanitizeDisplayContent(content: string): string { .replace(/[\s\S]*?<\/command-message>/gi, '') .replace(/[\s\S]*?<\/command-args>/gi, ''); + // Remove trailing "Read the output file..." instructions from task notifications + sanitized = sanitized.replace(TASK_OUTPUT_INSTRUCTION_PATTERN, ''); + return sanitized.trim(); } @@ -149,3 +160,39 @@ export function extractSlashInfo(content: string): SlashInfo | null { args: argsMatch?.[1]?.trim() ?? undefined, }; } + +// ============================================================================= +// Task Notification Parsing +// ============================================================================= + +/** + * Parsed task notification from Claude Code's background task system. + */ +export interface TaskNotification { + taskId: string; + status: string; + summary: string; + outputFile: string; +} + +/** + * Extract task notifications from raw message content. + * These are XML blocks injected by Claude Code when background tasks complete. + */ +export function parseTaskNotifications(content: string): TaskNotification[] { + const notifications: TaskNotification[] = []; + const pattern = /([\s\S]*?)<\/task-notification>/gi; + let match; + + while ((match = pattern.exec(content)) !== null) { + const block = match[1]; + notifications.push({ + taskId: /([^<]*)<\/task-id>/.exec(block)?.[1] ?? '', + status: /([^<]*)<\/status>/.exec(block)?.[1] ?? '', + summary: /([\s\S]*?)<\/summary>/.exec(block)?.[1]?.trim() ?? '', + outputFile: /([^<]*)<\/output-file>/.exec(block)?.[1] ?? '', + }); + } + + return notifications; +}