Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion src/renderer/components/chat/UserChatGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -345,6 +346,20 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): 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)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The type guard here is a bit verbose. If userGroup.message.content is an array of a discriminated union based on the type property, the check b.type === 'text' should be sufficient for TypeScript to infer that b has a text property. The additional 'text' in b check is likely redundant and can be removed for cleaner code.

Suggested change
.filter((b): b is { type: 'text'; text: string } => b.type === 'text' && 'text' in b)
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')

.map((b) => b.text)
.join('')
: '';
return parseTaskNotifications(raw);
}, [userGroup.message.content]);

// Extract @path mentions from text
const pathMentions = useMemo(() => {
if (!textContent) return [];
Expand Down Expand Up @@ -462,6 +477,60 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
</div>
)}

{/* 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];
Comment on lines +493 to +497

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For performance, these regular expressions should be defined as constants outside of the component render function. This prevents them from being re-created on every render and for every item in the taskNotifications array.

For example, you can define them at the top of the file:

const CMD_NAME_REGEX = /"([^"]+)"/;
const EXIT_CODE_REGEX = /\(exit code (\d+)\)/;

Then use them inside the map callback:

const cmdMatch = CMD_NAME_REGEX.exec(notif.summary);
// ...
const exitMatch = EXIT_CODE_REGEX.exec(notif.summary);


return (
<div
key={notif.taskId}
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)' }}
>
{cmdName}
</div>
<div className="flex items-center gap-2 text-[10px]" style={{ color: 'var(--color-text-muted)' }}>
<span className="capitalize">{notif.status}</span>
{exitCode != null && <span>exit {exitCode}</span>}
{notif.outputFile && (
<span className="flex items-center gap-0.5 truncate">
<FileText className="size-2.5" />
<span className="truncate">{notif.outputFile.split('/').pop()}</span>
</span>
)}
</div>
</div>
</div>
);
})}

{/* Images indicator */}
{hasImages && (
<div className="text-right text-xs" style={{ color: 'var(--color-text-muted)' }}>
Expand Down
47 changes: 47 additions & 0 deletions src/shared/utils/contentSanitizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,16 @@
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 "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 <local-command-stdout> tags.
* Returns the command output without the wrapper tags.
Expand Down Expand Up @@ -110,6 +118,9 @@ export function sanitizeDisplayContent(content: string): string {
.replace(/<command-message>[\s\S]*?<\/command-message>/gi, '')
.replace(/<command-args>[\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();
}

Expand Down Expand Up @@ -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 = /<task-notification>([\s\S]*?)<\/task-notification>/gi;
let match;

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;
}
Comment on lines +182 to +198

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved performance and code clarity, the regular expressions used for parsing should be defined once before the while loop. Re-creating them on each iteration is inefficient, especially if a message contains multiple task notifications.

export function parseTaskNotifications(content: string): TaskNotification[] {
  const notifications: TaskNotification[] = [];
  const pattern = /<task-notification>([\s\S]*?)<\/task-notification>/gi;
  const taskIdRegex = /<task-id>([^<]*)<\/task-id>/;
  const statusRegex = /<status>([^<]*)<\/status>/;
  const summaryRegex = /<summary>([\s\S]*?)<\/summary>/;
  const outputFileRegex = /<output-file>([^<]*)<\/output-file>/;
  let match;

  while ((match = pattern.exec(content)) !== null) {
    const block = match[1];
    notifications.push({
      taskId: taskIdRegex.exec(block)?.[1] ?? '',
      status: statusRegex.exec(block)?.[1] ?? '',
      summary: summaryRegex.exec(block)?.[1]?.trim() ?? '',
      outputFile: outputFileRegex.exec(block)?.[1] ?? '',
    });
  }

  return notifications;
}

Loading