feat(chat): render task notifications as cards
This commit is contained in:
parent
f97d9c6e2c
commit
f1a8a343d4
3 changed files with 170 additions and 1 deletions
|
|
@ -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)' }}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
47
test/shared/utils/contentSanitizer.test.ts
Normal file
47
test/shared/utils/contentSanitizer.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue