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 { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
|
import { parseTaskNotifications } from '@shared/utils/contentSanitizer';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { format } from 'date-fns';
|
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 remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
|
@ -427,6 +428,29 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
const stripped = useMemo(() => stripAgentBlocks(textContent), [textContent]);
|
const stripped = useMemo(() => stripAgentBlocks(textContent), [textContent]);
|
||||||
const isLongContent = stripped.length > 500;
|
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
|
// Extract @path mentions from text
|
||||||
const pathMentions = useMemo(() => {
|
const pathMentions = useMemo(() => {
|
||||||
if (!textContent) return [];
|
if (!textContent) return [];
|
||||||
|
|
@ -578,6 +602,59 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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 */}
|
{/* Images indicator */}
|
||||||
{hasImages && (
|
{hasImages && (
|
||||||
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,15 @@
|
||||||
const NOISE_TAG_PATTERNS = [
|
const NOISE_TAG_PATTERNS = [
|
||||||
/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
|
/<local-command-caveat>[\s\S]*?<\/local-command-caveat>/gi,
|
||||||
/<system-reminder>[\s\S]*?<\/system-reminder>/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 {
|
export interface CommandOutputInfo {
|
||||||
stream: 'stdout' | 'stderr';
|
stream: 'stdout' | 'stderr';
|
||||||
output: string;
|
output: string;
|
||||||
|
|
@ -121,6 +128,9 @@ export function sanitizeDisplayContent(content: string): string {
|
||||||
.replace(/<command-message>[\s\S]*?<\/command-message>/gi, '')
|
.replace(/<command-message>[\s\S]*?<\/command-message>/gi, '')
|
||||||
.replace(/<command-args>[\s\S]*?<\/command-args>/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();
|
return sanitized.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,3 +170,38 @@ export function extractSlashInfo(content: string): SlashInfo | null {
|
||||||
args: argsMatch?.[1]?.trim() ?? undefined,
|
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