feat: rework log view (#625)

* rework process start card

* do not auto-insert user message

* error and system message cards

* nest tool cards

* improve tool card rendering

* fix tsc errors

* spacing

* scroll bar

* tweaks

* put back icon

* use run reason constants

* fix restore icon display

* round diff card

* add special plan card rendering

* fmt

* opacity for thinking text

* Louis/logs tweaks (#641)

* remove divs

* text

* status indicator

* expandable tool boxes

* diffs and raw logs

* Tweaks

* new files

* message

* lint

---------

Co-authored-by: Louis Knight-Webb <louis@bloop.ai>
This commit is contained in:
Gabriel Gordon-Hall
2025-09-06 14:50:30 +01:00
committed by GitHub
parent abee94189a
commit a3bffc9d0d
23 changed files with 909 additions and 1046 deletions

View File

@@ -1,7 +1,8 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
@@ -9,14 +10,13 @@
<link rel="manifest" href="/site.webmanifest" />
<link rel="icon" type="image/png" sizes="192x192" href="/android-chrome-192x192.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/android-chrome-512x512.png" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chivo+Mono:ital,wght@0,100..900;1,100..900&family=VT323&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>vibe-kanban</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>

View File

@@ -1,11 +1,20 @@
import MarkdownRenderer from '@/components/ui/markdown-renderer.tsx';
import {
ActionType,
NormalizedEntry,
type NormalizedEntryType,
} from 'shared/types.ts';
import type { ProcessStartPayload } from '@/types/logs';
import FileChangeRenderer from './FileChangeRenderer';
import { renderJson } from './ToolDetails';
import { useExpandable } from '@/stores/useExpandableStore';
import {
AlertCircle,
Bot,
Brain,
CheckSquare,
ChevronRight,
ChevronUp,
ChevronDown,
Hammer,
Edit,
Eye,
Globe,
@@ -15,36 +24,32 @@ import {
Terminal,
User,
} from 'lucide-react';
import {
NormalizedEntry,
type NormalizedEntryType,
type ActionType,
} from 'shared/types.ts';
import FileChangeRenderer from './FileChangeRenderer';
import ToolDetails from './ToolDetails';
import { Braces, FileText } from 'lucide-react';
import RawLogText from '../common/RawLogText';
type Props = {
entry: NormalizedEntry;
entry: NormalizedEntry | ProcessStartPayload;
expansionKey: string;
diffDeletable?: boolean;
};
type FileEditAction = Extract<ActionType, { action: 'file_edit' }>;
const getEntryIcon = (entryType: NormalizedEntryType) => {
const iconSize = 'h-3 w-3';
if (entryType.type === 'user_message') {
return <User className="h-4 w-4 text-blue-600" />;
return <User className={iconSize} />;
}
if (entryType.type === 'assistant_message') {
return <Bot className="h-4 w-4 text-success" />;
return <Bot className={iconSize} />;
}
if (entryType.type === 'system_message') {
return <Settings className="h-4 w-4 text-gray-600" />;
return <Settings className={iconSize} />;
}
if (entryType.type === 'thinking') {
return <Brain className="h-4 w-4 text-purple-600" />;
return <Brain className={iconSize} />;
}
if (entryType.type === 'error_message') {
return <AlertCircle className="h-4 w-4 text-destructive" />;
return <AlertCircle className={iconSize} />;
}
if (entryType.type === 'tool_use') {
const { action_type, tool_name } = entryType;
@@ -59,363 +64,483 @@ const getEntryIcon = (entryType: NormalizedEntryType) => {
tool_name.toLowerCase() === 'todo_read' ||
tool_name.toLowerCase() === 'todo'))
) {
return <CheckSquare className="h-4 w-4 text-purple-600" />;
return <CheckSquare className={iconSize} />;
}
if (action_type.action === 'file_read') {
return <Eye className="h-4 w-4 text-orange-600" />;
return <Eye className={iconSize} />;
} else if (action_type.action === 'file_edit') {
return <Edit className="h-4 w-4 text-destructive" />;
return <Edit className={iconSize} />;
} else if (action_type.action === 'command_run') {
return <Terminal className="h-4 w-4 text-yellow-600" />;
return <Terminal className={iconSize} />;
} else if (action_type.action === 'search') {
return <Search className="h-4 w-4 text-indigo-600" />;
return <Search className={iconSize} />;
} else if (action_type.action === 'web_fetch') {
return <Globe className="h-4 w-4 text-cyan-600" />;
return <Globe className={iconSize} />;
} else if (action_type.action === 'task_create') {
return <Plus className="h-4 w-4 text-teal-600" />;
return <Plus className={iconSize} />;
} else if (action_type.action === 'plan_presentation') {
return <CheckSquare className="h-4 w-4 text-blue-600" />;
return <CheckSquare className={iconSize} />;
} else if (action_type.action === 'tool') {
return <Hammer className={iconSize} />;
}
return <Settings className="h-4 w-4 text-gray-600" />;
return <Settings className={iconSize} />;
}
return <Settings className="h-4 w-4 text-gray-400" />;
return <Settings className={iconSize} />;
};
const getContentClassName = (entryType: NormalizedEntryType) => {
const baseClasses = 'text-sm whitespace-pre-wrap break-words';
const getStatusIndicator = (entryType: NormalizedEntryType) => {
const result =
entryType.type === 'tool_use' &&
entryType.action_type.action === 'command_run'
? entryType.action_type.result?.exit_status
: null;
const status =
result?.type === 'success'
? result.success
? 'success'
: 'error'
: result?.type === 'exit_code'
? result.code === 0
? 'success'
: 'error'
: 'unknown';
if (status === 'unknown') return null;
const colorMap: Record<typeof status, string> = {
success: 'bg-green-300',
error: 'bg-red-300',
};
return (
<div className="relative">
<div
className={`${colorMap[status]} h-1.5 w-1.5 rounded-full absolute -left-1 -bottom-4`}
/>
</div>
);
};
/**********************
* Helper definitions *
**********************/
const shouldRenderMarkdown = (entryType: NormalizedEntryType) =>
entryType.type === 'assistant_message' ||
entryType.type === 'system_message' ||
entryType.type === 'thinking' ||
entryType.type === 'tool_use';
const getContentClassName = (entryType: NormalizedEntryType) => {
const base = ' whitespace-pre-wrap break-words';
if (
entryType.type === 'tool_use' &&
entryType.action_type.action === 'command_run'
) {
return `${baseClasses} font-mono`;
}
)
return `${base} font-mono`;
if (entryType.type === 'error_message') {
return `${baseClasses} text-destructive font-mono bg-red-50 dark:bg-red-950/20 px-2 py-1 rounded`;
}
// Keep content-only styling — no bg/padding/rounded here.
if (entryType.type === 'error_message')
return `${base} font-mono text-destructive`;
if (entryType.type === 'thinking') return `${base} opacity-60`;
// Special styling for TODO lists
if (
entryType.type === 'tool_use' &&
(entryType.action_type.action === 'todo_management' ||
(entryType.tool_name &&
(entryType.tool_name.toLowerCase() === 'todowrite' ||
entryType.tool_name.toLowerCase() === 'todoread' ||
entryType.tool_name.toLowerCase() === 'todo_write' ||
entryType.tool_name.toLowerCase() === 'todo_read' ||
entryType.tool_name.toLowerCase() === 'todo')))
) {
return `${baseClasses} font-mono text-zinc-800 dark:text-zinc-200 bg-zinc-50 dark:bg-zinc-900/40 px-2 py-1 rounded`;
}
['todowrite', 'todoread', 'todo_write', 'todo_read', 'todo'].includes(
entryType.tool_name.toLowerCase()
)))
)
return `${base} font-mono text-zinc-800 dark:text-zinc-200`;
// Special styling for plan presentations
if (
entryType.type === 'tool_use' &&
entryType.action_type.action === 'plan_presentation'
) {
return `${baseClasses} text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/20 px-3 py-2 rounded-md border-l-4 border-blue-400`;
}
)
return `${base} text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-950/20 px-3 py-2 border-l-4 border-blue-400`;
return baseClasses;
return base;
};
// Helper function to determine if content should be rendered as markdown
const shouldRenderMarkdown = (entryType: NormalizedEntryType) => {
// Render markdown for assistant messages, plan presentations, and tool outputs that contain backticks
return (
entryType.type === 'assistant_message' ||
entryType.type === 'system_message' ||
entryType.type === 'thinking' ||
entryType.type === 'tool_use'
);
};
/*********************
* Unified card *
*********************/
import { useExpandable } from '@/stores/useExpandableStore';
type CardVariant = 'system' | 'error';
function DisplayConversationEntry({ entry, expansionKey }: Props) {
const isErrorMessage = entry.entry_type.type === 'error_message';
const hasMultipleLines = isErrorMessage && entry.content.includes('\n');
const [isExpanded, setIsExpanded] = useExpandable(
`err:${expansionKey}`,
false
);
const fileEdit =
entry.entry_type.type === 'tool_use' &&
entry.entry_type.action_type.action === 'file_edit'
? (entry.entry_type.action_type as Extract<
ActionType,
{ action: 'file_edit' }
>)
: null;
// One-line collapsed UX for tool entries
const isToolUse = entry.entry_type.type === 'tool_use';
const toolAction: any = isToolUse
? (entry.entry_type as any).action_type
: null;
const hasArgs = toolAction?.action === 'tool' && !!toolAction?.arguments;
const hasResult = toolAction?.action === 'tool' && !!toolAction?.result;
const isCommand = toolAction?.action === 'command_run';
const commandOutput: string | null = isCommand
? (toolAction?.result?.output ?? null)
: null;
// Derive success from either { type: 'success', success: boolean } or { type: 'exit_code', code: number }
let commandSuccess: boolean | undefined = undefined;
let commandExitCode: number | undefined = undefined;
if (isCommand) {
const st: any = toolAction?.result?.exit_status;
if (st && typeof st === 'object') {
if (st.type === 'success' && typeof st.success === 'boolean') {
commandSuccess = st.success;
} else if (st.type === 'exit_code' && typeof st.code === 'number') {
commandExitCode = st.code;
commandSuccess = st.code === 0;
}
}
}
const outputMeta = (() => {
if (!commandOutput) return null;
const lineCount =
commandOutput === '' ? 0 : commandOutput.split('\n').length;
const bytes = new Blob([commandOutput]).size;
const kb = bytes / 1024;
const sizeStr = kb >= 1 ? `${kb.toFixed(1)} kB` : `${bytes} B`;
return { lineCount, sizeStr };
})();
const canExpand =
(isCommand && !!commandOutput) ||
(toolAction?.action === 'tool' && (hasArgs || hasResult));
const [toolExpanded, toggleToolExpanded] = useExpandable(
`tool-entry:${expansionKey}`,
false
);
const MessageCard: React.FC<{
children: React.ReactNode;
variant: CardVariant;
expanded?: boolean;
onToggle?: () => void;
}> = ({ children, variant, expanded, onToggle }) => {
const frameBase =
'border px-3 py-2 w-full cursor-pointer bg-[hsl(var(--card))] border-[hsl(var(--border))]';
const systemTheme = 'border-400/40 text-zinc-500';
const errorTheme =
'border-red-400/40 bg-red-50 dark:bg-[hsl(var(--card))] text-[hsl(var(--foreground))]';
return (
<div className="px-4 py-1">
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-1">
{isErrorMessage && hasMultipleLines ? (
<button
onClick={() => setIsExpanded()}
className="transition-colors hover:opacity-70"
>
{getEntryIcon(entry.entry_type)}
</button>
) : (
getEntryIcon(entry.entry_type)
)}
</div>
<div className="flex-1 min-w-0">
{isErrorMessage && hasMultipleLines ? (
<div className={isExpanded ? 'space-y-2' : ''}>
<div className={getContentClassName(entry.entry_type)}>
{isExpanded ? (
shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
) : (
entry.content
)
) : (
<>
{entry.content.split('\n')[0]}
<button
onClick={() => setIsExpanded()}
className="ml-2 inline-flex items-center gap-1 text-xs text-destructive hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronRight className="h-3 w-3" />
Show more
</button>
</>
)}
</div>
{isExpanded && (
<button
onClick={() => setIsExpanded()}
className="flex items-center gap-1 text-xs text-destructive hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 transition-colors"
>
<ChevronUp className="h-3 w-3" />
Show less
</button>
)}
</div>
) : (
<div>
{isToolUse ? (
canExpand ? (
<button
onClick={() => toggleToolExpanded()}
className="flex items-center gap-2 w-full text-left"
title={toolExpanded ? 'Hide details' : 'Show details'}
>
<span className="flex items-center gap-1 min-w-0">
<span className="text-sm break-words">
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="inline"
/>
) : (
entry.content
)}
</span>
{/* Icons immediately after tool name */}
{isCommand ? (
<>
{typeof commandSuccess === 'boolean' && (
<span
className={
'px-1.5 py-0.5 rounded text-[10px] border whitespace-nowrap ' +
(commandSuccess
? 'bg-green-50 text-green-700 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-900/40'
: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900/40')
}
title={
typeof commandExitCode === 'number'
? `exit code: ${commandExitCode}`
: commandSuccess
? 'success'
: 'failed'
}
>
{typeof commandExitCode === 'number'
? `exit ${commandExitCode}`
: commandSuccess
? 'ok'
: 'fail'}
</span>
)}
{commandOutput && (
<span
title={
outputMeta
? `output: ${outputMeta.lineCount} lines · ${outputMeta.sizeStr}`
: 'output'
}
></span>
)}
</>
) : (
<>
{hasArgs && (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
)}
{hasResult &&
(toolAction?.result?.type === 'json' ? (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
) : (
<FileText className="h-3.5 w-3.5 text-zinc-500" />
))}
</>
)}
</span>
</button>
) : (
<div className="flex items-center gap-2">
<div className={'text-sm break-words'}>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="inline"
/>
) : (
entry.content
)}
</div>
{isCommand ? (
<>
{commandOutput && (
<span
title={
outputMeta
? `output: ${outputMeta.lineCount} lines · ${outputMeta.sizeStr}`
: 'output'
}
>
<FileText className="h-3.5 w-3.5 text-zinc-500" />
</span>
)}
</>
) : (
<>
{hasArgs && (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
)}
{hasResult &&
(toolAction?.result?.type === 'json' ? (
<Braces className="h-3.5 w-3.5 text-zinc-500" />
) : (
<FileText className="h-3.5 w-3.5 text-zinc-500" />
))}
</>
)}
</div>
)
) : (
<div className={getContentClassName(entry.entry_type)}>
{shouldRenderMarkdown(entry.entry_type) ? (
<MarkdownRenderer
content={entry.content}
className="whitespace-pre-wrap break-words"
/>
) : (
entry.content
)}
</div>
)}
</div>
)}
{fileEdit &&
Array.isArray(fileEdit.changes) &&
fileEdit.changes.map((change, idx) => {
return (
<FileChangeRenderer
key={idx}
path={fileEdit.path}
change={change}
expansionKey={`edit:${expansionKey}:${idx}`}
/>
);
})}
{entry.entry_type.type === 'tool_use' &&
toolExpanded &&
(() => {
const at: any = entry.entry_type.action_type as any;
if (at?.action === 'tool') {
return (
<ToolDetails
arguments={at.arguments ?? null}
result={
at.result
? { type: at.result.type, value: at.result.value }
: null
}
/>
);
}
if (at?.action === 'command_run') {
const output = at?.result?.output as string | undefined;
const exit = (at?.result?.exit_status as any) ?? null;
return (
<ToolDetails
commandOutput={output ?? null}
commandExit={exit}
/>
);
}
return null;
})()}
</div>
<div
className={`${frameBase} ${
variant === 'system' ? systemTheme : errorTheme
}`}
onClick={onToggle}
>
<div className="flex items-center gap-1.5">
<div className="min-w-0 flex-1">{children}</div>
{onToggle && (
<ExpandChevron
expanded={!!expanded}
onClick={onToggle}
variant={variant}
/>
)}
</div>
</div>
);
};
/************************
* Collapsible container *
************************/
type CollapsibleVariant = 'system' | 'error';
const ExpandChevron: React.FC<{
expanded: boolean;
onClick: () => void;
variant: CollapsibleVariant;
}> = ({ expanded, onClick, variant }) => {
const color =
variant === 'system'
? 'text-700 dark:text-300'
: 'text-red-700 dark:text-red-300';
return (
<ChevronDown
onClick={onClick}
className={`h-4 w-4 cursor-pointer transition-transform ${color} ${
expanded ? '' : '-rotate-90'
}`}
/>
);
};
const CollapsibleEntry: React.FC<{
content: string;
markdown: boolean;
expansionKey: string;
variant: CollapsibleVariant;
contentClassName: string;
}> = ({ content, markdown, expansionKey, variant, contentClassName }) => {
const multiline = content.includes('\n');
const [expanded, toggle] = useExpandable(`entry:${expansionKey}`, false);
const Inner = (
<div className={contentClassName}>
{markdown ? (
<MarkdownRenderer
content={content}
className="whitespace-pre-wrap break-words"
/>
) : (
content
)}
</div>
);
const firstLine = content.split('\n')[0];
const PreviewInner = (
<div className={contentClassName}>
{markdown ? (
<MarkdownRenderer
content={firstLine}
className="whitespace-pre-wrap break-words"
/>
) : (
firstLine
)}
</div>
);
if (!multiline) {
return <MessageCard variant={variant}>{Inner}</MessageCard>;
}
return expanded ? (
<MessageCard variant={variant} expanded={expanded} onToggle={toggle}>
{Inner}
</MessageCard>
) : (
<MessageCard variant={variant} expanded={expanded} onToggle={toggle}>
{PreviewInner}
</MessageCard>
);
};
const PlanPresentationCard: React.FC<{
plan: string;
expansionKey: string;
}> = ({ plan, expansionKey }) => {
const [expanded, toggle] = useExpandable(`plan-entry:${expansionKey}`, true);
return (
<div className="inline-block w-full">
<div className="border w-full overflow-hidden border-blue-400/40">
<button
onClick={(e: React.MouseEvent) => {
e.preventDefault();
toggle();
}}
title={expanded ? 'Hide plan' : 'Show plan'}
className="w-full px-2 py-1.5 flex items-center gap-1.5 text-left bg-blue-50 dark:bg-blue-950/20 text-blue-700 dark:text-blue-300 border-b border-blue-400/40"
>
<span className=" min-w-0 truncate">
<span className="font-semibold">Plan</span>
</span>
<div className="ml-auto flex items-center gap-2">
<ExpandChevron
expanded={expanded}
onClick={toggle}
variant="system"
/>
</div>
</button>
{expanded && (
<div className="px-3 py-2 max-h-[65vh] overflow-y-auto overscroll-contain bg-blue-50 dark:bg-blue-950/20">
<div className=" text-blue-700 dark:text-blue-300">
<MarkdownRenderer
content={plan}
className="whitespace-pre-wrap break-words"
/>
</div>
</div>
)}
</div>
</div>
);
};
const ToolCallCard: React.FC<{
entryType?: Extract<NormalizedEntryType, { type: 'tool_use' }>;
action?: any;
expansionKey: string;
content?: string;
entryContent?: string;
}> = ({ entryType, action, expansionKey, content, entryContent }) => {
const at: any = entryType?.action_type || action;
const [expanded, toggle] = useExpandable(`tool-entry:${expansionKey}`, false);
const label =
at?.action === 'command_run'
? 'Ran'
: entryType?.tool_name || at?.tool_name || 'Tool';
const isCommand = at?.action === 'command_run';
const inlineText = (entryContent || content || '').trim();
const isSingleLine = inlineText !== '' && !/\r?\n/.test(inlineText);
const showInlineSummary = isSingleLine;
const hasArgs = at?.action === 'tool' && !!at?.arguments;
const hasResult = at?.action === 'tool' && !!at?.result;
const output: string | null = isCommand ? (at?.result?.output ?? null) : null;
let argsText: string | null = null;
if (isCommand) {
const fromArgs =
typeof at?.arguments === 'string'
? at.arguments
: at?.arguments != null
? JSON.stringify(at.arguments, null, 2)
: '';
const fallback = (entryContent || content || '').trim();
argsText = (fromArgs || fallback).trim();
}
const hasExpandableDetails = isCommand
? Boolean(argsText) || Boolean(output)
: hasArgs || hasResult;
const HeaderWrapper: React.ElementType = hasExpandableDetails
? 'button'
: 'div';
const headerProps: any = hasExpandableDetails
? {
onClick: (e: React.MouseEvent) => {
e.preventDefault();
toggle();
},
title: expanded ? 'Hide details' : 'Show details',
}
: {};
return (
<div className="inline-block w-full flex flex-col gap-4">
<HeaderWrapper
{...headerProps}
className="w-full flex items-center gap-1.5 text-left text-secondary-foreground"
>
<span className=" min-w-0 flex items-center gap-1.5">
{entryType ? (
<span>
{getStatusIndicator(entryType)}
{getEntryIcon(entryType)}
</span>
) : (
<span className="font-normal flex">{label}</span>
)}
{showInlineSummary && (
<span className="font-light">{inlineText}</span>
)}
</span>
</HeaderWrapper>
{expanded && (
<div className="max-h-[200px] overflow-y-auto border">
{isCommand ? (
<>
{argsText && (
<>
<div className="font-normal uppercase bg-background border-b border-dashed px-2 py-1">
Args
</div>
<div className="px-2 py-1">{argsText}</div>
</>
)}
{output && (
<>
<div className="font-normal uppercase bg-background border-y border-dashed px-2 py-1">
Output
</div>
<div className="px-2 py-1">
<RawLogText content={output} />
</div>
</>
)}
</>
) : (
<>
{entryType?.action_type.action === 'tool' && (
<>
<div className="font-normal uppercase bg-background border-b border-dashed px-2 py-1">
Args
</div>
<div className="px-2 py-1">
{renderJson(entryType.action_type.arguments)}
</div>
<div className="font-normal uppercase bg-background border-y border-dashed px-2 py-1">
Result
</div>
<div className="px-2 py-1">
{entryType.action_type.result?.type.type === 'markdown' &&
entryType.action_type.result.value && (
<MarkdownRenderer
content={entryType.action_type.result.value?.toString()}
/>
)}
{entryType.action_type.result?.type.type === 'json' &&
renderJson(entryType.action_type.result.value)}
</div>
</>
)}
</>
)}
</div>
)}
</div>
);
};
/*******************
* Main component *
*******************/
function DisplayConversationEntry({ entry, expansionKey }: Props) {
const isNormalizedEntry = (
entry: NormalizedEntry | ProcessStartPayload
): entry is NormalizedEntry => 'entry_type' in entry;
const isProcessStart = (
entry: NormalizedEntry | ProcessStartPayload
): entry is ProcessStartPayload => 'processId' in entry;
if (isProcessStart(entry)) {
const toolAction: any = entry.action ?? null;
return (
<ToolCallCard
action={toolAction}
expansionKey={expansionKey}
content={toolAction?.message ?? toolAction?.summary ?? undefined}
/>
);
}
// Handle NormalizedEntry
const entryType = entry.entry_type;
const isSystem = entryType.type === 'system_message';
const isError = entryType.type === 'error_message';
const isToolUse = entryType.type === 'tool_use';
const isFileEdit = (a: ActionType): a is FileEditAction =>
a.action === 'file_edit';
return (
<>
{isSystem || isError ? (
<CollapsibleEntry
content={isNormalizedEntry(entry) ? entry.content : ''}
markdown={shouldRenderMarkdown(entryType)}
expansionKey={expansionKey}
variant={isSystem ? 'system' : 'error'}
contentClassName={getContentClassName(entryType)}
/>
) : isToolUse && isFileEdit(entryType.action_type) ? (
// Only FileChangeRenderer for file_edit
(() => {
const fileEditAction = entryType.action_type as FileEditAction;
return fileEditAction.changes.map((change, idx) => (
<FileChangeRenderer
key={idx}
path={fileEditAction.path}
change={change}
expansionKey={`edit:${expansionKey}:${idx}`}
/>
));
})()
) : isToolUse && entryType.action_type.action === 'plan_presentation' ? (
<PlanPresentationCard
plan={entryType.action_type.plan}
expansionKey={expansionKey}
/>
) : isToolUse ? (
<ToolCallCard
entryType={entryType}
expansionKey={expansionKey}
entryContent={isNormalizedEntry(entry) ? entry.content : ''}
/>
) : (
<div className={getContentClassName(entryType)}>
{shouldRenderMarkdown(entryType) ? (
<MarkdownRenderer
content={isNormalizedEntry(entry) ? entry.content : ''}
className="whitespace-pre-wrap break-words flex flex-col gap-1 font-light"
/>
) : isNormalizedEntry(entry) ? (
entry.content
) : (
''
)}
</div>
)}
</>
);
}
export default DisplayConversationEntry;

View File

@@ -5,8 +5,7 @@ import {
DiffLineType,
parseInstance,
} from '@git-diff-view/react';
import { ChevronRight, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { SquarePen } from 'lucide-react';
import { useConfig } from '@/components/config-provider';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import { getActualTheme } from '@/utils/theme';
@@ -86,25 +85,12 @@ function EditDiffRenderer({
}, [hunks, path]);
return (
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded()}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
<div>
<div className="flex items-center text-secondary-foreground gap-1.5">
<SquarePen className="h-3 w-3" />
<p
className="text-xs font-mono overflow-x-auto flex-1"
style={{ color: 'hsl(var(--muted-foreground) / 0.7)' }}
onClick={() => setExpanded()}
className="text-xs font-mono overflow-x-auto flex-1 cursor-pointer"
>
{path}{' '}
<span style={{ color: 'hsl(var(--console-success))' }}>
@@ -117,7 +103,7 @@ function EditDiffRenderer({
</div>
{expanded && (
<div className={'mt-2' + hideLineNumbersClass}>
<div className={'mt-2 border ' + hideLineNumbersClass}>
{isValidDiff ? (
<DiffView
data={diffData}

View File

@@ -1,13 +1,6 @@
import { type FileChange } from 'shared/types';
import { useConfig } from '@/components/config-provider';
import { Button } from '@/components/ui/button';
import {
ChevronRight,
ChevronUp,
Trash2,
ArrowLeftRight,
ArrowRight,
} from 'lucide-react';
import { Trash2, FilePlus2, ArrowRight } from 'lucide-react';
import { getHighLightLanguageFromPath } from '@/utils/extToLanguage';
import { getActualTheme } from '@/utils/theme';
import EditDiffRenderer from './EditDiffRenderer';
@@ -61,20 +54,11 @@ const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
}
// Title row content and whether the row is expandable
const { titleNode, expandable } = (() => {
const commonTitleClass = 'text-xs font-mono overflow-x-auto flex-1';
const commonTitleStyle = {
color: 'hsl(var(--muted-foreground) / 0.7)',
};
const { titleNode, icon, expandable } = (() => {
if (isDelete(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
<Trash2 className="h-3 w-3 inline mr-1.5" aria-hidden />
Delete <span className="ml-1">{path}</span>
</p>
),
titleNode: path,
icon: <Trash2 className="h-3 w-3" />,
expandable: false,
};
}
@@ -82,24 +66,19 @@ const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
if (isRename(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
<ArrowLeftRight className="h-3 w-3 inline mr-1.5" aria-hidden />
Rename <span className="ml-1">{path}</span>{' '}
<ArrowRight className="h-3 w-3 inline mx-1" aria-hidden />{' '}
<span>{change.new_path}</span>
</p>
<>
Rename {path} to {change.new_path}
</>
),
icon: <ArrowRight className="h-3 w-3" />,
expandable: false,
};
}
if (isWrite(change)) {
return {
titleNode: (
<p className={commonTitleClass} style={commonTitleStyle}>
Write to <span className="ml-1">{path}</span>
</p>
),
titleNode: path,
icon: <FilePlus2 className="h-3 w-3" />,
expandable: true,
};
}
@@ -107,6 +86,7 @@ const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
// No fallback: render nothing for unknown change types
return {
titleNode: null,
icon: null,
expandable: false,
};
})();
@@ -117,26 +97,15 @@ const FileChangeRenderer = ({ path, change, expansionKey }: Props) => {
}
return (
<div className="my-4 border">
<div className="flex items-center px-4 py-2">
{expandable && (
<Button
variant="ghost"
size="sm"
onClick={() => setExpanded()}
className="h-6 w-6 p-0 mr-2"
title={expanded ? 'Collapse' : 'Expand'}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
</Button>
)}
{titleNode}
<div>
<div className="flex items-center text-secondary-foreground gap-1.5">
{icon}
<p
onClick={() => expandable && setExpanded()}
className="text-xs font-mono overflow-x-auto flex-1 cursor-pointer"
>
{titleNode}
</p>
</div>
{/* Body */}

View File

@@ -8,13 +8,12 @@ type Props = {
content: string;
lang: string | null;
theme?: 'light' | 'dark';
className?: string;
};
/**
* View syntax highlighted file content.
*/
function FileContentView({ content, lang, theme, className }: Props) {
function FileContentView({ content, lang, theme }: Props) {
// Uses the syntax highlighter from @git-diff-view/react without any diff-related features.
// This allows uniform styling with EditDiffRenderer.
const diffFile = useMemo(() => {
@@ -34,29 +33,21 @@ function FileContentView({ content, lang, theme, className }: Props) {
}
}, [content, lang]);
return (
<div
className={['plain-file-content edit-diff-hide-nums', className]
.filter(Boolean)
.join(' ')}
>
<div className="px-4 py-2">
{diffFile ? (
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
) : (
<pre className="text-xs font-mono overflow-x-auto whitespace-pre">
{content}
</pre>
)}
</div>
return diffFile ? (
<div className="border mt-2">
<DiffView
diffFile={diffFile}
diffViewWrap={false}
diffViewTheme={theme}
diffViewHighlight
diffViewMode={DiffModeEnum.Unified}
diffViewFontSize={12}
/>
</div>
) : (
<pre className="text-xs font-mono overflow-x-auto whitespace-pre">
{content}
</pre>
);
}

View File

@@ -12,37 +12,51 @@ type ToolResult = {
type Props = {
arguments?: JsonValue | null;
result?: ToolResult | null;
commandOutput?: string | null;
commandOutput?: string | null; // presence => command mode
commandExit?:
| { type: 'success'; success: boolean }
| { type: 'exit_code'; code: number }
| null;
};
export const renderJson = (v: JsonValue) => (
<pre>{JSON.stringify(v, null, 2)}</pre>
);
export default function ToolDetails({
arguments: args,
result,
commandOutput,
commandExit,
}: Props) {
const renderJson = (v: JsonValue) => (
<pre className="mt-1 max-h-80 overflow-auto rounded bg-muted p-2 text-xs">
{JSON.stringify(v, null, 2)}
</pre>
);
const isCommandMode = commandOutput !== undefined;
return (
<div className="mt-2 space-y-3">
<div className="space-y-3">
{args && (
<section>
<div className="flex items-center gap-2 text-xs text-zinc-500">
<Braces className="h-3 w-3" />
<span>Arguments</span>
</div>
{renderJson(args)}
{!isCommandMode ? (
<>
<div className="flex items-center gap-2 text-xs text-zinc-500">
<Braces className="h-3 w-3" />
<span>Arguments</span>
</div>
{renderJson(args)}
</>
) : (
<>
<RawLogText
content={
typeof args === 'string'
? args
: JSON.stringify(args, null, 2)
}
/>
</>
)}
</section>
)}
{result && (
{result && !isCommandMode && (
<section>
<div className="flex items-center gap-2 text-xs text-zinc-500">
{result.type === 'json' ? (
@@ -61,31 +75,12 @@ export default function ToolDetails({
</div>
</section>
)}
{(commandOutput || commandExit) && (
{isCommandMode && (
<section>
<div className="flex items-center gap-2 text-xs text-zinc-500">
<FileText className="h-3 w-3" />
<span>
Output
{commandExit && (
<>
{' '}
<span className="ml-1 px-1.5 py-0.5 rounded bg-zinc-100 dark:bg-zinc-800 text-[10px] text-zinc-600 dark:text-zinc-300 border border-zinc-200/80 dark:border-zinc-700/80 whitespace-nowrap">
{commandExit.type === 'exit_code'
? `exit ${commandExit.code}`
: commandExit.success
? 'ok'
: 'fail'}
</span>
</>
)}
</span>
<div className="mt-1">
<RawLogText content={commandOutput ?? ''} />
</div>
{commandOutput && (
<div className="mt-1">
<RawLogText content={commandOutput} />
</div>
)}
</section>
)}
</div>

View File

@@ -1,16 +1,13 @@
import { memo, useEffect, useRef } from 'react';
import type { UnifiedLogEntry, ProcessStartPayload } from '@/types/logs';
import { memo } from 'react';
import type { UnifiedLogEntry } from '@/types/logs';
import type { NormalizedEntry } from 'shared/types';
import StdoutEntry from './StdoutEntry';
import StderrEntry from './StderrEntry';
import ProcessStartCard from './ProcessStartCard';
import DisplayConversationEntry from '@/components/NormalizedConversation/DisplayConversationEntry';
interface LogEntryRowProps {
entry: UnifiedLogEntry;
index: number;
style?: React.CSSProperties;
setRowHeight?: (index: number, height: number) => void;
isCollapsed?: boolean;
onToggleCollapse?: (processId: string) => void;
onRestore?: (processId: string) => void;
@@ -19,66 +16,29 @@ interface LogEntryRowProps {
restoreDisabledReason?: string;
}
function LogEntryRow({
entry,
index,
style,
setRowHeight,
isCollapsed,
onToggleCollapse,
onRestore,
restoreProcessId,
restoreDisabled,
restoreDisabledReason,
}: LogEntryRowProps) {
const rowRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (rowRef.current && setRowHeight) {
setRowHeight(index, rowRef.current.clientHeight);
}
}, [rowRef, setRowHeight, index]);
const content = (
<div className="" ref={rowRef}>
{(() => {
switch (entry.channel) {
case 'stdout':
return <StdoutEntry content={entry.payload as string} />;
case 'stderr':
return <StderrEntry content={entry.payload as string} />;
case 'normalized':
return (
<DisplayConversationEntry
entry={entry.payload as NormalizedEntry}
expansionKey={`${entry.processId}:${index}`}
diffDeletable={false}
/>
);
case 'process_start':
return (
<ProcessStartCard
payload={entry.payload as ProcessStartPayload}
isCollapsed={isCollapsed || false}
onToggle={onToggleCollapse || (() => {})}
onRestore={onRestore}
restoreProcessId={restoreProcessId}
restoreDisabled={restoreDisabled}
restoreDisabledReason={restoreDisabledReason}
/>
);
default:
return (
<div className="text-destructive text-xs">
Unknown log type: {entry.channel}
</div>
);
}
})()}
</div>
);
return style ? <div style={style}>{content}</div> : content;
function LogEntryRow({ entry, index }: LogEntryRowProps) {
switch (entry.channel) {
case 'stdout':
return <StdoutEntry content={entry.payload as string} />;
case 'stderr':
return <StderrEntry content={entry.payload as string} />;
case 'normalized':
return (
<div className="my-4">
<DisplayConversationEntry
entry={entry.payload as NormalizedEntry}
expansionKey={`${entry.processId}:${index}`}
diffDeletable={false}
/>
</div>
);
default:
return (
<div className="text-destructive text-xs">
Unknown log type: {entry.channel}
</div>
);
}
}
// Memoize to optimize react-window performance

View File

@@ -0,0 +1,45 @@
import type { UnifiedLogEntry, ProcessStartPayload } from '@/types/logs';
import ProcessStartCard from '@/components/logs/ProcessStartCard';
import LogEntryRow from '@/components/logs/LogEntryRow';
type Props = {
header: ProcessStartPayload;
entries: UnifiedLogEntry[];
isCollapsed: boolean;
onToggle: (processId: string) => void;
restore?: {
onRestore: (processId: string) => void;
restoreProcessId?: string;
restoreDisabled?: boolean;
restoreDisabledReason?: string;
};
};
export default function ProcessGroup({
header,
entries,
isCollapsed,
onToggle,
restore,
}: Props) {
return (
<div className="px-4 mt-4">
<ProcessStartCard
payload={header}
isCollapsed={isCollapsed}
onToggle={onToggle}
onRestore={restore?.onRestore}
restoreProcessId={restore?.restoreProcessId}
restoreDisabled={restore?.restoreDisabled}
restoreDisabledReason={restore?.restoreDisabledReason}
/>
<div className="text-sm">
{!isCollapsed &&
entries.length > 0 &&
entries.map((entry, i) => (
<LogEntryRow key={entry.id} entry={entry} index={i} />
))}
</div>
</div>
);
}

View File

@@ -1,14 +1,14 @@
import {
Clock,
Cog,
Play,
Terminal,
Code,
ChevronDown,
History,
} from 'lucide-react';
import { ChevronDown, History } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { ProcessStartPayload } from '@/types/logs';
import type { ExecutorAction } from 'shared/types';
import { PROCESS_RUN_REASONS } from '@/constants/processes';
interface ProcessStartCardProps {
payload: ProcessStartPayload;
@@ -20,6 +20,15 @@ interface ProcessStartCardProps {
restoreDisabledReason?: string;
}
const extractPromptFromAction = (
action?: ExecutorAction | null
): string | null => {
if (!action) return null;
const t = action.typ as any;
if (t && typeof t.prompt === 'string' && t.prompt.trim()) return t.prompt;
return null;
};
function ProcessStartCard({
payload,
isCollapsed,
@@ -29,40 +38,23 @@ function ProcessStartCard({
restoreDisabled,
restoreDisabledReason,
}: ProcessStartCardProps) {
const getProcessIcon = (runReason: string) => {
switch (runReason) {
case 'setupscript':
return <Cog className="h-4 w-4" />;
case 'cleanupscript':
return <Terminal className="h-4 w-4" />;
case 'codingagent':
return <Code className="h-4 w-4" />;
case 'devserver':
return <Play className="h-4 w-4" />;
default:
return <Cog className="h-4 w-4" />;
const getProcessLabel = (p: ProcessStartPayload) => {
if (p.runReason === PROCESS_RUN_REASONS.CODING_AGENT) {
const prompt = extractPromptFromAction(p.action);
return prompt || 'Coding Agent';
}
};
const getProcessLabel = (runReason: string) => {
switch (runReason) {
case 'setupscript':
switch (p.runReason) {
case PROCESS_RUN_REASONS.SETUP_SCRIPT:
return 'Setup Script';
case 'cleanupscript':
case PROCESS_RUN_REASONS.CLEANUP_SCRIPT:
return 'Cleanup Script';
case 'codingagent':
return 'Coding Agent';
case 'devserver':
case PROCESS_RUN_REASONS.DEV_SERVER:
return 'Dev Server';
default:
return runReason;
return p.runReason;
}
};
const formatTime = (dateString: string) => {
return new Date(dateString).toLocaleTimeString();
};
const handleClick = () => {
onToggle(payload.processId);
};
@@ -74,73 +66,83 @@ function ProcessStartCard({
}
};
const label = getProcessLabel(payload);
const shouldTruncate =
isCollapsed && payload.runReason === PROCESS_RUN_REASONS.CODING_AGENT;
return (
<div className="px-4 pt-4 pb-2">
<div
className="p-2 cursor-pointer select-none hover:bg-muted/70 transition-colors"
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 text-foreground">
{getProcessIcon(payload.runReason)}
<span className="font-medium">
{getProcessLabel(payload.runReason)}
</span>
</div>
<div className="flex items-center gap-1 text-muted-foreground">
<Clock className="h-3 w-3" />
<span>{formatTime(payload.startedAt)}</span>
</div>
{onRestore && payload.runReason === 'codingagent' && (
<button
className={cn(
'ml-2 group w-20 flex items-center gap-1 px-1.5 py-1 rounded transition-colors',
restoreDisabled
? 'cursor-not-allowed text-muted-foreground/60 bg-muted/40'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
)}
onClick={(e) => {
e.stopPropagation();
if (restoreDisabled) return;
onRestore(restoreProcessId || payload.processId);
}}
title={
restoreDisabled
? restoreDisabledReason || 'Restore is currently unavailable.'
: 'Restore to this checkpoint (deletes later history)'
}
aria-label="Restore to this checkpoint"
disabled={!!restoreDisabled}
>
<History className="h-4 w-4" />
<span className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">
Restore
</span>
</button>
)}
<div
className={`ml-auto text-xs px-2 py-1 rounded-full ${
payload.status === 'running'
? 'bg-blue-100 text-blue-700'
: payload.status === 'completed'
? 'bg-green-100 text-green-700'
: payload.status === 'failed'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{payload.status}
</div>
<ChevronDown
<div
className="p-2 border cursor-pointer select-none transition-colors w-full bg-background"
role="button"
tabIndex={0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
<div className="flex items-center gap-2 text-sm font-light">
<div className="flex items-center gap-2 text-foreground min-w-0 flex-1">
<span
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isCollapsed && '-rotate-90'
shouldTruncate ? 'truncate' : 'whitespace-normal break-words'
)}
/>
title={shouldTruncate ? label : undefined}
>
{label}
</span>
</div>
{onRestore &&
payload.runReason === PROCESS_RUN_REASONS.CODING_AGENT && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
'ml-2 p-1 rounded transition-colors',
restoreDisabled
? 'cursor-not-allowed text-muted-foreground/60'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/60'
)}
onClick={(e) => {
e.stopPropagation();
if (restoreDisabled) return;
onRestore(restoreProcessId || payload.processId);
}}
aria-label="Restore to this checkpoint"
disabled={!!restoreDisabled}
>
<History className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent>
{restoreDisabled
? restoreDisabledReason ||
'Restore is currently unavailable.'
: 'Restore'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<div
className={cn(
'ml-auto text-xs px-2 py-1 rounded-full',
payload.status === 'running'
? 'bg-blue-100 text-blue-700'
: payload.status === 'completed'
? 'bg-green-100 text-green-700'
: payload.status === 'failed'
? 'bg-red-100 text-red-700'
: 'bg-gray-100 text-gray-700'
)}
>
{payload.status}
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isCollapsed && '-rotate-90'
)}
/>
</div>
</div>
);

View File

@@ -5,11 +5,7 @@ interface StderrEntryProps {
}
function StderrEntry({ content }: StderrEntryProps) {
return (
<div className="flex gap-2 px-4">
<RawLogText content={content} channel="stderr" as="span" />
</div>
);
return <RawLogText content={content} channel="stderr" as="span" />;
}
export default StderrEntry;

View File

@@ -5,11 +5,7 @@ interface StdoutEntryProps {
}
function StdoutEntry({ content }: StdoutEntryProps) {
return (
<div className="flex gap-2 px-4">
<RawLogText content={content} channel="stdout" as="span" />
</div>
);
return <RawLogText content={content} channel="stdout" as="span" />;
}
export default StdoutEntry;

View File

@@ -6,12 +6,12 @@ import {
useReducer,
useState,
} from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Cog, AlertTriangle, CheckCircle, GitCommit } from 'lucide-react';
import { useAttemptExecution } from '@/hooks/useAttemptExecution';
import { useBranchStatus } from '@/hooks/useBranchStatus';
import { useProcessesLogs } from '@/hooks/useProcessesLogs';
import LogEntryRow from '@/components/logs/LogEntryRow';
import ProcessGroup from '@/components/logs/ProcessGroup';
import {
shouldShowInLogs,
isAutoCollapsibleProcess,
@@ -21,11 +21,6 @@ import {
PROCESS_STATUSES,
PROCESS_RUN_REASONS,
} from '@/constants/processes';
import type {
ExecutionProcessStatus,
TaskAttempt,
BaseAgentCapability,
} from 'shared/types';
import { useUserSystem } from '@/components/config-provider';
import {
Dialog,
@@ -36,8 +31,13 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import type {
ExecutionProcessStatus,
BaseAgentCapability,
TaskAttempt,
} from 'shared/types';
import type { UnifiedLogEntry, ProcessStartPayload } from '@/types/logs';
// Helper functions
function addAll<T>(set: Set<T>, items: T[]): Set<T> {
items.forEach((i: T) => set.add(i));
return set;
@@ -141,7 +141,7 @@ function LogsTab({ selectedAttempt }: Props) {
const { data: branchStatus, refetch: refetchBranch } = useBranchStatus(
selectedAttempt?.id
);
const virtuosoRef = useRef<any>(null);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const [state, dispatch] = useReducer(reducer, initialState);
@@ -281,143 +281,163 @@ function LogsTab({ selectedAttempt }: Props) {
state.autoCollapsed,
]);
// Filter entries to hide logs from collapsed processes
const visibleEntries = useMemo(() => {
return entries.filter((entry) =>
entry.channel === 'process_start'
? true
: !allCollapsedProcesses.has(entry.processId)
);
}, [entries, allCollapsedProcesses]);
const groups = useMemo(() => {
const map = new Map<
string,
{ header?: ProcessStartPayload; entries: UnifiedLogEntry[] }
>();
filteredProcesses.forEach((p) => {
map.set(p.id, { header: undefined, entries: [] });
});
entries.forEach((e: UnifiedLogEntry) => {
const bucket = map.get(e.processId);
if (!bucket) return;
if (e.channel === 'process_start') {
bucket.header = e.payload as ProcessStartPayload;
return;
}
// Always store entries; whether they show is decided by group collapse
bucket.entries.push(e);
});
return filteredProcesses
.map((p) => ({
processId: p.id,
...(map.get(p.id) || { entries: [] }),
}))
.filter((g) => g.header) as Array<{
processId: string;
header: ProcessStartPayload;
entries: UnifiedLogEntry[];
}>;
}, [filteredProcesses, entries]);
// Memoized item content to prevent flickering
const itemContent = useCallback(
(index: number, entry: any) => (
<LogEntryRow
entry={entry}
index={index}
isCollapsed={
entry.channel === 'process_start'
? allCollapsedProcesses.has(entry.payload.processId)
: undefined
}
onToggleCollapse={
entry.channel === 'process_start' ? toggleProcessCollapse : undefined
}
// Pass restore handler via entry.meta for process_start
// The LogEntryRow/ProcessStartCard will ignore if not provided
{...(entry.channel === 'process_start' && restoreSupported
? (() => {
const proc = (attemptData.processes || []).find(
(p) => p.id === entry.payload.processId
(
_index: number,
group: {
processId: string;
header: ProcessStartPayload;
entries: UnifiedLogEntry[];
}
) =>
(() => {
// Compute restore props for the process header (if supported)
let restore:
| {
onRestore: (pid: string) => void;
restoreProcessId?: string;
restoreDisabled?: boolean;
restoreDisabledReason?: string;
}
| undefined;
if (restoreSupported) {
const proc = (attemptData.processes || []).find(
(p) => p.id === group.processId
);
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const finished = procs.filter((p) => p.status !== 'running');
const latestFinished =
finished.length > 0 ? finished[finished.length - 1] : undefined;
const isLatest = latestFinished?.id === proc?.id;
const isRunningProc = proc?.status === 'running';
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset = !!(
proc?.after_head_commit &&
(proc.after_head_commit !== head || isDirty)
);
// visibility decision
let baseShouldShow = false;
if (!isRunningProc) {
baseShouldShow = !isLatest || needGitReset;
if (baseShouldShow && !isLatest && !needGitReset) {
const idx = procs.findIndex((p) => p.id === proc?.id);
const later = idx >= 0 ? procs.slice(idx + 1) : [];
const laterHasCoding = later.some((p) =>
isCodingAgent(p.run_reason)
);
// Consider only non-dropped processes that appear in logs for latest determination
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const finished = procs.filter((p) => p.status !== 'running');
const latestFinished =
finished.length > 0 ? finished[finished.length - 1] : undefined;
const isLatest = latestFinished?.id === proc?.id;
const isRunningProc = proc?.status === 'running';
const headKnown = !!branchStatus?.head_oid;
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset =
headKnown &&
!!(
proc?.after_head_commit &&
(proc.after_head_commit !== head || isDirty)
baseShouldShow = laterHasCoding;
}
}
const shouldShow =
baseShouldShow || (anyRunning && !isRunningProc && isLatest);
if (shouldShow) {
let disabled = anyRunning || restoreBusy || confirmOpen;
let disabledReason: string | undefined;
if (anyRunning)
disabledReason = 'Cannot restore while a process is running.';
else if (restoreBusy) disabledReason = 'Restore in progress.';
else if (confirmOpen)
disabledReason = 'Confirm the current restore first.';
if (!proc?.after_head_commit) {
disabled = true;
disabledReason = 'No recorded commit for this process.';
}
restore = {
restoreProcessId: group.processId,
restoreDisabled: disabled,
restoreDisabledReason: disabledReason,
onRestore: async (pid: string) => {
setRestorePid(pid);
const p2 = (attemptData.processes || []).find(
(p) => p.id === pid
);
// Base visibility rules:
// - Never show for the currently running process
// - For earlier finished processes, show only if either:
// a) later history includes a coding agent run, or
// b) restoring would change the worktree (needGitReset)
// - For the latest finished process, only show if diverged (needGitReset)
let baseShouldShow = false;
if (!isRunningProc) {
baseShouldShow = !isLatest || needGitReset;
// If this is an earlier finished process and restoring would not
// change the worktree, hide when only non-coding processes would be deleted.
if (baseShouldShow && !isLatest && !needGitReset) {
const procs = (attemptData.processes || []).filter(
(p) => !p.dropped && shouldShowInLogs(p.run_reason)
);
const idx = procs.findIndex((p) => p.id === proc?.id);
const later = idx >= 0 ? procs.slice(idx + 1) : [];
const laterHasCoding = later.some((p) =>
isCodingAgent(p.run_reason)
);
baseShouldShow = laterHasCoding;
}
}
// If any process is running, also surface the latest finished button disabled
// so users see it immediately with a clear disabled reason.
const shouldShow =
baseShouldShow || (anyRunning && !isRunningProc && isLatest);
if (!shouldShow) return {};
let disabledReason: string | undefined;
let disabled = anyRunning || restoreBusy || confirmOpen;
if (anyRunning)
disabledReason = 'Cannot restore while a process is running.';
else if (restoreBusy) disabledReason = 'Restore in progress.';
else if (confirmOpen)
disabledReason = 'Confirm the current restore first.';
if (!proc?.after_head_commit) {
disabled = true;
disabledReason = 'No recorded commit for this process.';
}
return {
restoreProcessId: entry.payload.processId,
onRestore: async (pid: string) => {
setRestorePid(pid);
const p2 = (attemptData.processes || []).find(
(p) => p.id === pid
);
const after = p2?.after_head_commit || null;
setTargetSha(after);
setTargetSubject(null);
if (after && selectedAttempt?.id) {
try {
const { commitsApi } = await import('@/lib/api');
const info = await commitsApi.getInfo(
selectedAttempt.id,
after
);
setTargetSubject(info.subject);
const cmp = await commitsApi.compareToHead(
selectedAttempt.id,
after
);
setCommitsToReset(
cmp.is_linear ? cmp.ahead_from_head : null
);
setIsLinear(cmp.is_linear);
} catch {
/* empty */
}
const after = p2?.after_head_commit || null;
setTargetSha(after);
setTargetSubject(null);
if (after && selectedAttempt?.id) {
try {
const { commitsApi } = await import('@/lib/api');
const info = await commitsApi.getInfo(
selectedAttempt.id,
after
);
setTargetSubject(info.subject);
const cmp = await commitsApi.compareToHead(
selectedAttempt.id,
after
);
setCommitsToReset(
cmp.is_linear ? cmp.ahead_from_head : null
);
setIsLinear(cmp.is_linear);
} catch {
/* ignore */
}
// Initialize reset to disabled (white) when dirty, enabled otherwise
const head = branchStatus?.head_oid || null;
const isDirty = !!branchStatus?.has_uncommitted_changes;
const needGitReset = !!(after && (after !== head || isDirty));
const canGitReset = needGitReset && !isDirty;
setWorktreeResetOn(!!canGitReset);
setForceReset(false);
setConfirmOpen(true);
},
restoreDisabled: disabled,
restoreDisabledReason: disabledReason,
};
})()
: {})}
/>
),
}
const head = branchStatus?.head_oid || null;
const dirty = !!branchStatus?.has_uncommitted_changes;
const needReset = !!(after && (after !== head || dirty));
const canGitReset = needReset && !dirty;
setWorktreeResetOn(!!canGitReset);
setForceReset(false);
setConfirmOpen(true);
},
};
}
}
return (
<ProcessGroup
header={group.header}
entries={group.entries}
isCollapsed={allCollapsedProcesses.has(group.processId)}
onToggle={toggleProcessCollapse}
restore={restore}
/>
);
})(),
[
allCollapsedProcesses,
toggleProcessCollapse,
@@ -946,18 +966,16 @@ function LogsTab({ selectedAttempt }: Props) {
<Virtuoso
ref={virtuosoRef}
style={{ height: '100%' }}
data={visibleEntries}
data={groups}
itemContent={itemContent}
followOutput={true}
followOutput
increaseViewportBy={200}
overscan={5}
components={{
Footer: () => <div className="pb-4" />,
}}
components={{ Footer: () => <div className="pb-4" /> }}
/>
</div>
</div>
);
}
export default LogsTab;
export default LogsTab; // Filter entries to hide logs from collapsed processes

View File

@@ -1,197 +0,0 @@
import { useState, useRef } from 'react';
import {
Play,
Square,
AlertCircle,
CheckCircle,
Clock,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import type { ExecutionProcessStatus, ExecutionProcess } from 'shared/types';
import { useLogStream } from '@/hooks/useLogStream';
import { useProcessConversation } from '@/hooks/useProcessConversation';
import DisplayConversationEntry from '@/components/NormalizedConversation/DisplayConversationEntry';
import RawLogText from '@/components/common/RawLogText';
interface ProcessCardProps {
process: ExecutionProcess;
}
function ProcessCard({ process }: ProcessCardProps) {
const [showLogs, setShowLogs] = useState(false);
const isCodingAgent = process.run_reason === 'codingagent';
// Use appropriate hook based on process type
const { logs, error: rawError } = useLogStream(process.id);
const {
entries,
isConnected: normalizedConnected,
error: normalizedError,
} = useProcessConversation(process.id, showLogs && isCodingAgent);
const logEndRef = useRef<HTMLDivElement>(null);
const isConnected = isCodingAgent ? normalizedConnected : false;
const error = isCodingAgent ? normalizedError : rawError;
const getStatusIcon = (status: ExecutionProcessStatus) => {
switch (status) {
case 'running':
return <Play className="h-4 w-4 text-blue-500" />;
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed':
return <AlertCircle className="h-4 w-4 text-destructive" />;
case 'killed':
return <Square className="h-4 w-4 text-gray-500" />;
default:
return <Clock className="h-4 w-4 text-gray-400" />;
}
};
const getStatusColor = (status: ExecutionProcessStatus) => {
switch (status) {
case 'running':
return 'bg-blue-50 border-blue-200 text-blue-800';
case 'completed':
return 'bg-green-50 border-green-200 text-green-800';
case 'failed':
return 'bg-red-50 border-red-200 text-red-800';
case 'killed':
return 'bg-gray-50 border-gray-200 text-gray-800';
default:
return 'bg-gray-50 border-gray-200 text-gray-800';
}
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString();
};
const getDuration = () => {
const startTime = new Date(process.started_at).getTime();
const endTime = process.completed_at
? new Date(process.completed_at).getTime()
: Date.now();
const durationMs = endTime - startTime;
const durationSeconds = Math.floor(durationMs / 1000);
if (durationSeconds < 60) {
return `${durationSeconds}s`;
}
const minutes = Math.floor(durationSeconds / 60);
const seconds = durationSeconds % 60;
return `${minutes}m ${seconds}s`;
};
return (
<div className="border rounded-lg p-4 bg-card">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
{getStatusIcon(process.status)}
<div>
<h3 className="font-medium text-sm">{process.run_reason}</h3>
<p className="text-sm text-muted-foreground mt-1">
Duration: {getDuration()}
</p>
</div>
</div>
<div className="text-right">
<span
className={`inline-block px-2 py-1 text-xs font-medium border rounded-full ${getStatusColor(
process.status
)}`}
>
{process.status}
</span>
{process.exit_code !== null && (
<p className="text-xs text-muted-foreground mt-1">
Exit: {process.exit_code.toString()}
</p>
)}
</div>
</div>
<div className="mt-3 text-xs text-muted-foreground space-y-1">
<div>
<span className="font-medium">Started:</span>{' '}
{formatDate(process.started_at)}
</div>
{process.completed_at && (
<div>
<span className="font-medium">Completed:</span>{' '}
{formatDate(process.completed_at)}
</div>
)}
<div>
<span className="font-medium">Process ID:</span> {process.id}
</div>
</div>
{/* Log section */}
<div className="mt-3 border-t pt-3">
<button
onClick={() => setShowLogs(!showLogs)}
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showLogs ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
View Logs
{isConnected && <span className="text-green-500"></span>}
</button>
{showLogs && (
<div className="mt-3">
{error && (
<div className="text-destructive text-sm mb-2">{error}</div>
)}
{isCodingAgent ? (
// Normalized conversation display for coding agents
<div className="space-y-2 max-h-64 overflow-y-auto">
{entries.length === 0 ? (
<div className="text-gray-400 text-sm">
No conversation entries available...
</div>
) : (
entries.map((entry, index) => (
<DisplayConversationEntry
key={entry.timestamp ?? index}
entry={entry}
expansionKey={`${process.id}:${index}`}
diffDeletable={false}
/>
))
)}
<div ref={logEndRef} />
</div>
) : (
// Raw logs display for other processes
<div className="bg-black text-white text-xs font-mono p-3 rounded-md max-h-64 overflow-y-auto">
{logs.length === 0 ? (
<div className="text-gray-400">No logs available...</div>
) : (
logs.map((logEntry, index) => (
<RawLogText
key={index}
content={logEntry.content}
channel={logEntry.type === 'STDERR' ? 'stderr' : 'stdout'}
as="div"
/>
))
)}
<div ref={logEndRef} />
</div>
)}
</div>
)}
</div>
</div>
);
}
export default ProcessCard;

View File

@@ -255,7 +255,7 @@ export function TaskFollowUpSection({
<ImageIcon
className={cn(
'h-4 w-4',
images.length > 0 && 'text-primary'
(images.length > 0 || showImageUpload) && 'text-primary'
)}
/>
</Button>

View File

@@ -12,15 +12,15 @@ function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
code: ({ children, ...props }) => (
<code
{...props}
className="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded text-sm font-mono"
className="bg-background px-1 py-0.5 text-sm font-mono"
>
{children}
</code>
),
strong: ({ children, ...props }) => (
<strong {...props} className="font-bold">
<span {...props} className="">
{children}
</strong>
</span>
),
em: ({ children, ...props }) => (
<em {...props} className="italic">
@@ -33,27 +33,33 @@ function MarkdownRenderer({ content, className = '' }: MarkdownRendererProps) {
</p>
),
h1: ({ children, ...props }) => (
<h1 {...props} className="text-lg font-bold leading-tight">
<h1 {...props} className="text-lg leading-tight font-medium">
{children}
</h1>
),
h2: ({ children, ...props }) => (
<h2 {...props} className="text-base font-bold leading-tight">
<h2 {...props} className="text-baseleading-tight font-medium">
{children}
</h2>
),
h3: ({ children, ...props }) => (
<h3 {...props} className="text-sm font-bold leading-tight">
<h3 {...props} className="text-sm leading-tight">
{children}
</h3>
),
ul: ({ children, ...props }) => (
<ul {...props} className="list-disc ml-2">
<ul
{...props}
className="list-disc list-inside flex flex-col gap-1 pl-4"
>
{children}
</ul>
),
ol: ({ children, ...props }) => (
<ol {...props} className="list-decimal ml-2">
<ol
{...props}
className="list-decimal list-inside flex flex-col gap-1 pl-4"
>
{children}
</ol>
),

View File

@@ -52,6 +52,7 @@ export const useProcessesLogs = (
runReason: process.run_reason,
startedAt: process.started_at,
status: process.status,
action: process.executor_action,
};
allEntries.push({

View File

@@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Chivo+Mono:ital,wght@0,100..900;1,100..900&family=Noto+Emoji:wght@300..700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -11,7 +13,7 @@
--_primary: var(--_muted);
--_primary-foreground: var(--_muted-foreground);
--_secondary: var(--_muted);
--_secondary-foreground: 215.4 16.3% 46.9%;
--_secondary-foreground: 215.4 16.3% 70.9%;
--_muted: 0 0% 100%;
--_muted-foreground: var(--_foreground);
--_accent: var(--_background);

View File

@@ -1,4 +1,4 @@
import type { NormalizedEntry } from 'shared/types';
import type { NormalizedEntry, ExecutorAction } from 'shared/types';
export interface UnifiedLogEntry {
id: string;
@@ -14,4 +14,5 @@ export interface ProcessStartPayload {
runReason: string;
startedAt: string;
status: string;
action?: ExecutorAction;
}

View File

@@ -123,7 +123,7 @@ module.exports = {
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
'chivo-mono': ['Chivo Mono', 'monospace'],
'chivo-mono': ['Chivo Mono', 'Noto Emoji', 'monospace'],
},
keyframes: {
"accordion-down": {