diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index f931f2dd..4adad158 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -11,9 +11,10 @@ import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; +import { parseTaskNotifications } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import { format } from 'date-fns'; -import { ChevronDown, ChevronUp, User } from 'lucide-react'; +import { CheckCircle, ChevronDown, ChevronUp, Circle, FileText, User, XCircle } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -427,6 +428,29 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. const stripped = useMemo(() => stripAgentBlocks(textContent), [textContent]); const isLongContent = stripped.length > 500; + const taskNotifications = useMemo(() => { + const rawContent = + typeof userGroup.message.content === 'string' + ? userGroup.message.content + : Array.isArray(userGroup.message.content) + ? userGroup.message.content + .filter((block): block is { type: 'text'; text: string } => { + return ( + typeof block === 'object' && + block !== null && + 'type' in block && + 'text' in block && + (block as { type?: unknown }).type === 'text' && + typeof (block as { text?: unknown }).text === 'string' + ); + }) + .map((block) => block.text) + .join('') + : ''; + + return parseTaskNotifications(rawContent); + }, [userGroup.message.content]); + // Extract @path mentions from text const pathMentions = useMemo(() => { if (!textContent) return []; @@ -578,6 +602,59 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React. ) : null} + {taskNotifications.length > 0 && + taskNotifications.map((notification) => { + const isCompleted = notification.status === 'completed'; + const isFailed = notification.status === 'failed' || notification.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)'; + const commandMatch = /"([^"]+)"/.exec(notification.summary); + const commandName = + commandMatch?.[1] ?? notification.summary.trim() ?? 'Background task'; + const exitCodeMatch = /\(exit code (\d+)\)/.exec(notification.summary); + const outputFileName = notification.outputFile + ? (notification.outputFile.split(/[\\/]/).pop() ?? notification.outputFile) + : null; + + return ( +
+ +
+
+ {commandName || 'Background task'} +
+
+ {notification.status || 'unknown'} + {exitCodeMatch?.[1] ? exit {exitCodeMatch[1]} : null} + {outputFileName ? ( + + + {outputFileName} + + ) : null} +
+
+
+ ); + })} + {/* Images indicator */} {hasImages && (
diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts index 06430637..3f71e5c3 100644 --- a/src/shared/utils/contentSanitizer.ts +++ b/src/shared/utils/contentSanitizer.ts @@ -19,8 +19,15 @@ 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 output-file instruction emitted after + * task notifications. + */ +const TASK_OUTPUT_INSTRUCTION_PATTERN = / ?Read the output file to retrieve the result: [^\s]+/g; + export interface CommandOutputInfo { stream: 'stdout' | 'stderr'; output: string; @@ -121,6 +128,9 @@ export function sanitizeDisplayContent(content: string): string { .replace(/[\s\S]*?<\/command-message>/gi, '') .replace(/[\s\S]*?<\/command-args>/gi, ''); + // Remove follow-up instructions that only make sense in raw XML form. + sanitized = sanitized.replace(TASK_OUTPUT_INSTRUCTION_PATTERN, ''); + return sanitized.trim(); } @@ -160,3 +170,38 @@ export function extractSlashInfo(content: string): SlashInfo | null { args: argsMatch?.[1]?.trim() ?? undefined, }; } + +// ============================================================================= +// Task Notification Parsing +// ============================================================================= + +/** + * Parsed background task notification embedded in a user message. + */ +export interface TaskNotification { + taskId: string; + status: string; + summary: string; + outputFile: string; +} + +/** + * Extract task notifications from raw Claude Code XML blocks. + */ +export function parseTaskNotifications(content: string): TaskNotification[] { + const notifications: TaskNotification[] = []; + const pattern = /([\s\S]*?)<\/task-notification>/gi; + let match: RegExpExecArray | null; + + 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; +} diff --git a/test/shared/utils/contentSanitizer.test.ts b/test/shared/utils/contentSanitizer.test.ts new file mode 100644 index 00000000..d6c71087 --- /dev/null +++ b/test/shared/utils/contentSanitizer.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseTaskNotifications, + sanitizeDisplayContent, +} from '@shared/utils/contentSanitizer'; + +describe('contentSanitizer task notifications', () => { + it('removes task-notification blocks and trailing output instructions from display text', () => { + const content = [ + 'Task finished.', + '', + 'task-123', + 'completed', + 'Background command "Run foo" completed (exit code 0)', + '/tmp/task-123.log', + '', + 'Read the output file to retrieve the result: /tmp/task-123.log', + ].join(''); + + expect(sanitizeDisplayContent(content)).toBe('Task finished.'); + }); + + it('extracts task notifications from raw xml blocks', () => { + const content = [ + '', + 'task-123', + 'completed', + 'Background command "Run foo" completed (exit code 0)', + '/tmp/task-123.log', + '', + ].join(''); + + expect(parseTaskNotifications(content)).toEqual([ + { + taskId: 'task-123', + status: 'completed', + summary: 'Background command "Run foo" completed (exit code 0)', + outputFile: '/tmp/task-123.log', + }, + ]); + }); + + it('returns an empty array when no task notifications are present', () => { + expect(parseTaskNotifications('normal user content')).toEqual([]); + }); +});