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:
committed by
GitHub
parent
abee94189a
commit
a3bffc9d0d
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
45
frontend/src/components/logs/ProcessGroup.tsx
Normal file
45
frontend/src/components/logs/ProcessGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -52,6 +52,7 @@ export const useProcessesLogs = (
|
||||
runReason: process.run_reason,
|
||||
startedAt: process.started_at,
|
||||
status: process.status,
|
||||
action: process.executor_action,
|
||||
};
|
||||
|
||||
allEntries.push({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user