-
Notifications
You must be signed in to change notification settings - Fork 164
feat: render task notifications as styled cards #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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'; | ||
|
|
||
|
|
@@ -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) | ||
| .map((b) => b.text) | ||
| .join('') | ||
| : ''; | ||
| return parseTaskNotifications(raw); | ||
| }, [userGroup.message.content]); | ||
|
|
||
| // Extract @path mentions from text | ||
| const pathMentions = useMemo(() => { | ||
| if (!textContent) return []; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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 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)' }}> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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(); | ||
| } | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For improved performance and code clarity, the regular expressions used for parsing should be defined once before the 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;
} |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The type guard here is a bit verbose. If
userGroup.message.contentis an array of a discriminated union based on thetypeproperty, the checkb.type === 'text'should be sufficient for TypeScript to infer thatbhas atextproperty. The additional'text' in bcheck is likely redundant and can be removed for cleaner code.