It should be possible to view logs in the processes tab (vibe-kanban) (#473)

* Cleanup script changes for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Commit changes from coding agent for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Cleanup script changes for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Commit changes from coding agent for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Cleanup script changes for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Commit changes from coding agent for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Cleanup script changes for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Commit changes from coding agent for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Cleanup script changes for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Commit changes from coding agent for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* Cleanup script changes for task attempt 31bb8b01-6bda-4a86-8b51-3797673d052e

* handle bottom scroll better

* lint

* Lint
This commit is contained in:
Louis Knight-Webb
2025-08-19 09:19:12 +01:00
committed by GitHub
parent 41b7bf4eb0
commit d71944d14d
4 changed files with 155 additions and 45 deletions

View File

@@ -22,11 +22,7 @@ function ProcessCard({ process }: ProcessCardProps) {
const isCodingAgent = process.run_reason === 'codingagent';
// Use appropriate hook based on process type
const {
logs,
isConnected: rawConnected,
error: rawError,
} = useLogStream(process.id, showLogs && !isCodingAgent);
const { logs, error: rawError } = useLogStream(process.id);
const {
entries,
isConnected: normalizedConnected,
@@ -34,7 +30,7 @@ function ProcessCard({ process }: ProcessCardProps) {
} = useProcessConversation(process.id, showLogs && isCodingAgent);
const logEndRef = useRef<HTMLDivElement>(null);
const isConnected = isCodingAgent ? normalizedConnected : rawConnected;
const isConnected = isCodingAgent ? normalizedConnected : false;
const error = isCodingAgent ? normalizedError : rawError;
const getStatusIcon = (status: ExecutionProcessStatus) => {
@@ -176,9 +172,17 @@ function ProcessCard({ process }: ProcessCardProps) {
{logs.length === 0 ? (
<div className="text-gray-400">No logs available...</div>
) : (
logs.map((log, index) => (
logs.map((logEntry, index) => (
<div key={index} className="break-all">
{log}
{logEntry.type === 'STDERR' ? (
<span className="text-destructive">
{logEntry.content}
</span>
) : (
<span className="text-foreground">
{logEntry.content}
</span>
)}
</div>
))
)}

View File

@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { AlertCircle } from 'lucide-react';
import { useLogStream } from '@/hooks/useLogStream';
import type { PatchType } from 'shared/types';
type LogEntry = Extract<PatchType, { type: 'STDOUT' } | { type: 'STDERR' }>;
interface ProcessLogsViewerProps {
processId: string;
}
export default function ProcessLogsViewer({
processId,
}: ProcessLogsViewerProps) {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const didInitScroll = useRef(false);
const prevLenRef = useRef(0);
const [atBottom, setAtBottom] = useState(true);
const { logs, error } = useLogStream(processId);
// 1) Initial jump to bottom once data appears.
useEffect(() => {
if (!didInitScroll.current && logs.length > 0) {
didInitScroll.current = true;
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: logs.length - 1,
align: 'end',
});
});
}
}, [logs.length]);
// 2) If there's a large append and we're at bottom, force-stick to the last item.
useEffect(() => {
const prev = prevLenRef.current;
const grewBy = logs.length - prev;
prevLenRef.current = logs.length;
// tweak threshold as you like; this handles "big bursts"
const LARGE_BURST = 10;
if (grewBy >= LARGE_BURST && atBottom && logs.length > 0) {
// defer so Virtuoso can re-measure before jumping
requestAnimationFrame(() => {
virtuosoRef.current?.scrollToIndex({
index: logs.length - 1,
align: 'end',
});
});
}
}, [logs.length, atBottom, logs]);
const formatLogLine = (entry: LogEntry, index: number) => {
let className = 'text-sm font-mono px-4 py-1 whitespace-pre-wrap';
className +=
entry.type === 'STDERR' ? ' text-destructive' : ' text-foreground';
return (
<div key={index} className={className}>
{entry.content}
</div>
);
};
return (
<div className="flex flex-col flex-1 min-h-0 space-y-3">
<div className="flex-shrink-0">
<h3 className="text-sm font-medium">Process Logs</h3>
</div>
<div className="border rounded-lg bg-card flex-1 min-h-0 flex flex-col">
{logs.length === 0 && !error ? (
<div className="p-4 text-center text-muted-foreground text-sm">
No logs available
</div>
) : error ? (
<div className="p-4 text-center text-destructive text-sm">
<AlertCircle className="h-4 w-4 inline mr-2" />
{error}
</div>
) : (
<Virtuoso<LogEntry>
ref={virtuosoRef}
className="flex-1 rounded-lg"
data={logs}
itemContent={(index, entry) =>
formatLogLine(entry as LogEntry, index)
}
// Keep pinned while user is at bottom; release when they scroll up
atBottomStateChange={setAtBottom}
followOutput={atBottom ? 'smooth' : false}
// Optional: a bit more overscan helps during bursts
increaseViewportBy={{ top: 0, bottom: 600 }}
/>
)}
</div>
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
import { TaskAttemptDataContext } from '@/components/context/taskDetailsContext.ts';
import { executionProcessesApi } from '@/lib/api.ts';
import { ProfileVariantBadge } from '@/components/common/ProfileVariantBadge.tsx';
import ProcessLogsViewer from './ProcessLogsViewer';
import type { ExecutionProcessStatus, ExecutionProcess } from 'shared/types';
function ProcessesTab() {
@@ -183,10 +184,10 @@ function ProcessesTab() {
Back to list
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 pb-20">
<div className="flex-1 flex flex-col min-h-0 overflow-hidden p-4 pb-20">
{selectedProcess ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="flex-1 flex flex-col min-h-0 space-y-4">
<div className="grid grid-cols-2 gap-4 flex-shrink-0">
<div>
<h3 className="font-medium text-sm mb-2">Process Info</h3>
<div className="space-y-1 text-sm">
@@ -237,7 +238,7 @@ function ProcessesTab() {
</div>
{/* Command, working directory, stdout, stderr fields not available in new ExecutionProcess type */}
<div>
<div className="flex-shrink-0">
<h3 className="font-medium text-sm mb-2">
Process Information
</h3>
@@ -255,6 +256,8 @@ function ProcessesTab() {
)}
</div>
</div>
<ProcessLogsViewer processId={selectedProcess.id} />
</div>
) : loadingProcessId === selectedProcessId ? (
<div className="text-center text-muted-foreground">

View File

@@ -1,74 +1,76 @@
import { useEffect, useState, useRef } from 'react';
import type { PatchType } from 'shared/types';
type LogEntry = Extract<PatchType, { type: 'STDOUT' } | { type: 'STDERR' }>;
interface UseLogStreamResult {
logs: string[];
isConnected: boolean;
logs: LogEntry[];
error: string | null;
}
export const useLogStream = (
processId: string,
enabled: boolean
): UseLogStreamResult => {
const [logs, setLogs] = useState<string[]>([]);
const [isConnected, setIsConnected] = useState(false);
export const useLogStream = (processId: string): UseLogStreamResult => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
useEffect(() => {
if (!enabled || !processId) {
if (!processId) {
return;
}
// Clear logs when process changes
setLogs([]);
setError(null);
const eventSource = new EventSource(
`/api/execution-processes/${processId}/raw-logs`
);
eventSourceRef.current = eventSource;
eventSource.onopen = () => {
setIsConnected(true);
setError(null);
};
eventSource.onmessage = (event) => {
// Handle default messages
setLogs((prev) => [...prev, event.data]);
const addLogEntry = (entry: LogEntry) => {
setLogs((prev) => [...prev, entry]);
};
eventSource.addEventListener('stdout', (event) => {
setLogs((prev) => [...prev, `stdout: ${event.data}`]);
});
// Handle json_patch events (new format from server)
eventSource.addEventListener('json_patch', (event) => {
try {
const patches = JSON.parse(event.data);
patches.forEach((patch: any) => {
const value = patch?.value;
if (!value || !value.type) return;
eventSource.addEventListener('stderr', (event) => {
setLogs((prev) => [...prev, `stderr: ${event.data}`]);
switch (value.type) {
case 'STDOUT':
case 'STDERR':
addLogEntry({ type: value.type, content: value.content });
break;
// Ignore other patch types (NORMALIZED_ENTRY, DIFF, etc.)
default:
break;
}
});
} catch (e) {
console.error('Failed to parse json_patch:', e);
}
});
eventSource.addEventListener('finished', () => {
setLogs((prev) => [...prev, '--- Stream finished ---']);
eventSource.close();
setIsConnected(false);
});
eventSource.onerror = () => {
setError('Connection failed');
setIsConnected(false);
eventSource.close();
};
return () => {
eventSource.close();
setIsConnected(false);
};
}, [processId, enabled]);
}, [processId]);
// Reset logs when disabled
useEffect(() => {
if (!enabled) {
setLogs([]);
setError(null);
setIsConnected(false);
}
}, [enabled]);
return { logs, isConnected, error };
return { logs, error };
};