feat(chat): render task notifications as cards

This commit is contained in:
777genius 2026-04-11 10:05:20 +03:00
parent f97d9c6e2c
commit f1a8a343d4
3 changed files with 170 additions and 1 deletions

View file

@ -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<UserChatGroupProps>): 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<UserChatGroupProps>): React.
</div>
) : 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 (
<div
key={notification.taskId || `${groupId}-${notification.summary}`}
className="flex items-start gap-2.5 rounded-lg px-3 py-2"
style={{
backgroundColor: 'var(--card-bg)',
border: '1px solid var(--card-border)',
}}
>
<StatusIcon className="mt-0.5 size-3.5 shrink-0" style={{ color: statusColor }} />
<div className="min-w-0 flex-1 space-y-0.5">
<div
className="text-xs font-medium leading-snug"
style={{ color: 'var(--color-text-secondary)' }}
>
{commandName || 'Background task'}
</div>
<div
className="flex items-center gap-2 text-[10px]"
style={{ color: 'var(--color-text-muted)' }}
>
<span className="capitalize">{notification.status || 'unknown'}</span>
{exitCodeMatch?.[1] ? <span>exit {exitCodeMatch[1]}</span> : null}
{outputFileName ? (
<span className="flex min-w-0 items-center gap-0.5 truncate">
<FileText className="size-2.5 shrink-0" />
<span className="truncate">{outputFileName}</span>
</span>
) : null}
</div>
</div>
</div>
);
})}
{/* Images indicator */}
{hasImages && (
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>

View file

@ -19,8 +19,15 @@
const NOISE_TAG_PATTERNS = [
/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
/<system-reminder>[\s\S]*?<\/system-reminder>/gi,
/<task-notification>[\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(/<command-message>[\s\S]*?<\/command-message>/gi, '')
.replace(/<command-args>[\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 = /<task-notification>([\s\S]*?)<\/task-notification>/gi;
let match: RegExpExecArray | null;
while ((match = pattern.exec(content)) !== null) {
const block = match[1];
notifications.push({
taskId: /<task-id>([^<]*)<\/task-id>/.exec(block)?.[1] ?? '',
status: /<status>([^<]*)<\/status>/.exec(block)?.[1] ?? '',
summary: /<summary>([\s\S]*?)<\/summary>/.exec(block)?.[1]?.trim() ?? '',
outputFile: /<output-file>([^<]*)<\/output-file>/.exec(block)?.[1] ?? '',
});
}
return notifications;
}

View file

@ -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-notification>',
'<task-id>task-123</task-id>',
'<status>completed</status>',
'<summary>Background command "Run foo" completed (exit code 0)</summary>',
'<output-file>/tmp/task-123.log</output-file>',
'</task-notification>',
'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-notification>',
'<task-id>task-123</task-id>',
'<status>completed</status>',
'<summary>Background command "Run foo" completed (exit code 0)</summary>',
'<output-file>/tmp/task-123.log</output-file>',
'</task-notification>',
].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([]);
});
});