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:
committed by
GitHub
parent
41b7bf4eb0
commit
d71944d14d
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
101
frontend/src/components/tasks/TaskDetails/ProcessLogsViewer.tsx
Normal file
101
frontend/src/components/tasks/TaskDetails/ProcessLogsViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user