From d52317ac5de860b82879195751977c1277cccc36 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:18:42 +0100 Subject: [PATCH] feat: Add timestamps to agent logs and fix horizontal scroll - getAgentOutput now returns timestamped chunks ({ content, createdAt }[]) instead of a flat string, preserving DB chunk timestamps - parseAgentOutput accepts TimestampedChunk[] and propagates timestamps to each ParsedMessage - AgentOutputViewer displays HH:MM:SS timestamps on text, tool_call, system, and session_end messages - Live subscription chunks get client-side Date.now() timestamps - Fix horizontal scroll: overflow-x-hidden + break-words on content areas - AgentLogsTab polls getTaskAgent every 5s until an agent is found, then stops polling for live subscription to take over - Fix slide-over layout: details tab scrolls independently, logs tab fills remaining flex space for proper AgentOutputViewer sizing --- apps/server/trpc/routers/agent.ts | 7 +- apps/web/src/components/AgentOutputViewer.tsx | 62 ++++-- .../components/execution/TaskSlideOver.tsx | 6 +- apps/web/src/lib/parse-agent-output.ts | 200 ++++++++++-------- docs/agent.md | 7 +- docs/server-api.md | 3 +- 6 files changed, 170 insertions(+), 115 deletions(-) diff --git a/apps/server/trpc/routers/agent.ts b/apps/server/trpc/routers/agent.ts index b547b80..d2f1461 100644 --- a/apps/server/trpc/routers/agent.ts +++ b/apps/server/trpc/routers/agent.ts @@ -218,12 +218,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) { getAgentOutput: publicProcedure .input(agentIdentifierSchema) - .query(async ({ ctx, input }): Promise => { + .query(async ({ ctx, input }) => { const agent = await resolveAgent(ctx, input); const logChunkRepo = requireLogChunkRepository(ctx); const chunks = await logChunkRepo.findByAgentId(agent.id); - return chunks.map(c => c.content).join(''); + return chunks.map(c => ({ + content: c.content, + createdAt: c.createdAt.toISOString(), + })); }), onAgentOutput: publicProcedure diff --git a/apps/web/src/components/AgentOutputViewer.tsx b/apps/web/src/components/AgentOutputViewer.tsx index 3eaaeb3..48663ed 100644 --- a/apps/web/src/components/AgentOutputViewer.tsx +++ b/apps/web/src/components/AgentOutputViewer.tsx @@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; import { type ParsedMessage, + type TimestampedChunk, getMessageStyling, parseAgentOutput, } from "@/lib/parse-agent-output"; @@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO const [messages, setMessages] = useState([]); const [follow, setFollow] = useState(true); const containerRef = useRef(null); - // Accumulate raw JSONL: initial query data + live subscription chunks - const rawBufferRef = useRef(''); + // Accumulate timestamped chunks: initial query data + live subscription chunks + const chunksRef = useRef([]); // Load initial/historical output const outputQuery = trpc.getAgentOutput.useQuery( @@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // TrackedEnvelope shape: { id, data: { agentId, data: string } } const raw = event?.data?.data ?? event?.data; const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw); - rawBufferRef.current += chunk; - setMessages(parseAgentOutput(rawBufferRef.current)); + chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }]; + setMessages(parseAgentOutput(chunksRef.current)); }, onError: (error) => { console.error('Agent output subscription error:', error); @@ -54,14 +55,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO // Set initial output when query loads useEffect(() => { if (outputQuery.data) { - rawBufferRef.current = outputQuery.data; + chunksRef.current = outputQuery.data; setMessages(parseAgentOutput(outputQuery.data)); } }, [outputQuery.data]); // Reset output when agent changes useEffect(() => { - rawBufferRef.current = ''; + chunksRef.current = []; setMessages([]); setFollow(true); }, [agentId]); @@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{isLoading ? (
Loading output...
) : !hasOutput ? (
No output yet...
) : ( -
+
{messages.map((message, index) => (
{message.type === 'system' && (
System {message.content} +
)} {message.type === 'text' && ( -
- {message.content} -
+ <> + +
+ {message.content} +
+ )} {message.type === 'tool_call' && ( -
- - {message.meta?.toolName} - -
+
+
+ + {message.meta?.toolName} + + +
+
{message.content}
)} {message.type === 'tool_result' && ( -
+
Result -
+
{message.content}
)} {message.type === 'error' && ( -
+
Error -
+
{message.content}
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO {message.meta?.duration && ( {(message.meta.duration / 1000).toFixed(1)}s )} +
)} @@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
); } + +function formatTime(date: Date): string { + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); +} + +function Timestamp({ date }: { date?: Date }) { + if (!date) return null; + return ( + + {formatTime(date)} + + ); +} diff --git a/apps/web/src/components/execution/TaskSlideOver.tsx b/apps/web/src/components/execution/TaskSlideOver.tsx index 0885782..1df8f13 100644 --- a/apps/web/src/components/execution/TaskSlideOver.tsx +++ b/apps/web/src/components/execution/TaskSlideOver.tsx @@ -184,7 +184,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
{/* Content */} -
+
{tab === "details" ? (
{/* Metadata grid */} @@ -337,7 +337,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) { function AgentLogsTab({ taskId }: { taskId: string }) { const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( { taskId }, - { refetchOnWindowFocus: false }, + { refetchOnWindowFocus: false, refetchInterval: (query) => query.state.data ? false : 5000 }, ); if (isLoading) { @@ -357,7 +357,7 @@ function AgentLogsTab({ taskId }: { taskId: string }) { } return ( -
+
({ content: c.content, timestamp: new Date(c.createdAt) })); + const parsedMessages: ParsedMessage[] = []; - for (const line of lines) { - try { - const event = JSON.parse(line); + for (const chunk of chunks) { + const lines = chunk.content.split("\n").filter(Boolean); + for (const line of lines) { + try { + const event = JSON.parse(line); - // System initialization - if (event.type === "system" && event.session_id) { - parsedMessages.push({ - type: "system", - content: `Session started: ${event.session_id}`, - }); - } - - // Assistant messages with text and tool calls - else if ( - event.type === "assistant" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "text" && block.text) { - parsedMessages.push({ - type: "text", - content: block.text, - }); - } else if (block.type === "tool_use") { - parsedMessages.push({ - type: "tool_call", - content: formatToolCall(block), - meta: { toolName: block.name }, - }); - } + // System initialization + if (event.type === "system" && event.session_id) { + parsedMessages.push({ + type: "system", + content: `Session started: ${event.session_id}`, + timestamp: chunk.timestamp, + }); } - } - // User messages with tool results - else if ( - event.type === "user" && - Array.isArray(event.message?.content) - ) { - for (const block of event.message.content) { - if (block.type === "tool_result") { - const rawContent = block.content; - const output = - typeof rawContent === "string" - ? rawContent - : Array.isArray(rawContent) - ? rawContent - .map((c: any) => c.text ?? JSON.stringify(c)) - .join("\n") - : (event.tool_use_result?.stdout || ""); - const stderr = event.tool_use_result?.stderr; - - if (stderr) { + // Assistant messages with text and tool calls + else if ( + event.type === "assistant" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "text" && block.text) { parsedMessages.push({ - type: "error", - content: stderr, - meta: { isError: true }, + type: "text", + content: block.text, + timestamp: chunk.timestamp, }); - } else if (output) { - const displayOutput = - output.length > 1000 - ? output.substring(0, 1000) + "\n... (truncated)" - : output; + } else if (block.type === "tool_use") { parsedMessages.push({ - type: "tool_result", - content: displayOutput, + type: "tool_call", + content: formatToolCall(block), + timestamp: chunk.timestamp, + meta: { toolName: block.name }, }); } } } - } - // Legacy streaming format - else if (event.type === "stream_event" && event.event?.delta?.text) { + // User messages with tool results + else if ( + event.type === "user" && + Array.isArray(event.message?.content) + ) { + for (const block of event.message.content) { + if (block.type === "tool_result") { + const rawContent = block.content; + const output = + typeof rawContent === "string" + ? rawContent + : Array.isArray(rawContent) + ? rawContent + .map((c: any) => c.text ?? JSON.stringify(c)) + .join("\n") + : (event.tool_use_result?.stdout || ""); + const stderr = event.tool_use_result?.stderr; + + if (stderr) { + parsedMessages.push({ + type: "error", + content: stderr, + timestamp: chunk.timestamp, + meta: { isError: true }, + }); + } else if (output) { + const displayOutput = + output.length > 1000 + ? output.substring(0, 1000) + "\n... (truncated)" + : output; + parsedMessages.push({ + type: "tool_result", + content: displayOutput, + timestamp: chunk.timestamp, + }); + } + } + } + } + + // Legacy streaming format + else if (event.type === "stream_event" && event.event?.delta?.text) { + parsedMessages.push({ + type: "text", + content: event.event.delta.text, + timestamp: chunk.timestamp, + }); + } + + // Session completion + else if (event.type === "result") { + parsedMessages.push({ + type: "session_end", + content: event.is_error ? "Session failed" : "Session completed", + timestamp: chunk.timestamp, + meta: { + isError: event.is_error, + cost: event.total_cost_usd, + duration: event.duration_ms, + }, + }); + } + } catch { + // Not JSON, display as-is parsedMessages.push({ - type: "text", - content: event.event.delta.text, + type: "error", + content: line, + timestamp: chunk.timestamp, + meta: { isError: true }, }); } - - // Session completion - else if (event.type === "result") { - parsedMessages.push({ - type: "session_end", - content: event.is_error ? "Session failed" : "Session completed", - meta: { - isError: event.is_error, - cost: event.total_cost_usd, - duration: event.duration_ms, - }, - }); - } - } catch { - // Not JSON, display as-is - parsedMessages.push({ - type: "error", - content: line, - meta: { isError: true }, - }); } } return parsedMessages; diff --git a/docs/agent.md b/docs/agent.md index 7083585..bd692b9 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -153,9 +153,10 @@ Agent output is persisted to `agent_log_chunks` table and drives all live stream - DB insert → `agent:output` event emission (single source of truth for UI) - No FK to agents — survives agent deletion - Session tracking: spawn=1, resume=previousMax+1 -- Read path (`getAgentOutput` tRPC): concatenates all DB chunks (no file fallback) -- Live path (`onAgentOutput` subscription): listens for `agent:output` events -- Frontend: initial query loads from DB, subscription accumulates raw JSONL, both parsed via `parseAgentOutput()` +- Read path (`getAgentOutput` tRPC): returns timestamped chunks `{ content, createdAt }[]` from DB +- Live path (`onAgentOutput` subscription): listens for `agent:output` events (client stamps with `Date.now()`) +- Frontend: initial query loads timestamped chunks, subscription accumulates live chunks, both parsed via `parseAgentOutput()` which accepts `TimestampedChunk[]` +- Timestamps displayed inline (HH:MM:SS) on text, tool_call, system, and session_end messages ## Inter-Agent Communication diff --git a/docs/server-api.md b/docs/server-api.md index ec11000..5921317 100644 --- a/docs/server-api.md +++ b/docs/server-api.md @@ -62,7 +62,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE | getAgent | query | Single agent by name or ID | | getAgentResult | query | Execution result | | getAgentQuestions | query | Pending questions | -| getAgentOutput | query | Full output from DB log chunks | +| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) | +| getTaskAgent | query | Most recent agent assigned to a task (by taskId) | | getActiveRefineAgent | query | Active refine agent for initiative | | getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | listWaitingAgents | query | Agents waiting for input |