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
This commit is contained in:
Lukas May
2026-03-06 13:18:42 +01:00
parent 4ee71d45f4
commit d52317ac5d
6 changed files with 170 additions and 115 deletions

View File

@@ -218,12 +218,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
getAgentOutput: publicProcedure getAgentOutput: publicProcedure
.input(agentIdentifierSchema) .input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<string> => { .query(async ({ ctx, input }) => {
const agent = await resolveAgent(ctx, input); const agent = await resolveAgent(ctx, input);
const logChunkRepo = requireLogChunkRepository(ctx); const logChunkRepo = requireLogChunkRepository(ctx);
const chunks = await logChunkRepo.findByAgentId(agent.id); 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 onAgentOutput: publicProcedure

View File

@@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks"; import { useSubscriptionWithErrorHandling } from "@/hooks";
import { import {
type ParsedMessage, type ParsedMessage,
type TimestampedChunk,
getMessageStyling, getMessageStyling,
parseAgentOutput, parseAgentOutput,
} from "@/lib/parse-agent-output"; } from "@/lib/parse-agent-output";
@@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
const [messages, setMessages] = useState<ParsedMessage[]>([]); const [messages, setMessages] = useState<ParsedMessage[]>([]);
const [follow, setFollow] = useState(true); const [follow, setFollow] = useState(true);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
// Accumulate raw JSONL: initial query data + live subscription chunks // Accumulate timestamped chunks: initial query data + live subscription chunks
const rawBufferRef = useRef<string>(''); const chunksRef = useRef<TimestampedChunk[]>([]);
// Load initial/historical output // Load initial/historical output
const outputQuery = trpc.getAgentOutput.useQuery( const outputQuery = trpc.getAgentOutput.useQuery(
@@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
// TrackedEnvelope shape: { id, data: { agentId, data: string } } // TrackedEnvelope shape: { id, data: { agentId, data: string } }
const raw = event?.data?.data ?? event?.data; const raw = event?.data?.data ?? event?.data;
const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw); const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw);
rawBufferRef.current += chunk; chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }];
setMessages(parseAgentOutput(rawBufferRef.current)); setMessages(parseAgentOutput(chunksRef.current));
}, },
onError: (error) => { onError: (error) => {
console.error('Agent output subscription error:', 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 // Set initial output when query loads
useEffect(() => { useEffect(() => {
if (outputQuery.data) { if (outputQuery.data) {
rawBufferRef.current = outputQuery.data; chunksRef.current = outputQuery.data;
setMessages(parseAgentOutput(outputQuery.data)); setMessages(parseAgentOutput(outputQuery.data));
} }
}, [outputQuery.data]); }, [outputQuery.data]);
// Reset output when agent changes // Reset output when agent changes
useEffect(() => { useEffect(() => {
rawBufferRef.current = ''; chunksRef.current = [];
setMessages([]); setMessages([]);
setFollow(true); setFollow(true);
}, [agentId]); }, [agentId]);
@@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
<div <div
ref={containerRef} ref={containerRef}
onScroll={handleScroll} onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-terminal p-4" className="flex-1 overflow-y-auto overflow-x-hidden bg-terminal p-4"
> >
{isLoading ? ( {isLoading ? (
<div className="text-terminal-muted text-sm">Loading output...</div> <div className="text-terminal-muted text-sm">Loading output...</div>
) : !hasOutput ? ( ) : !hasOutput ? (
<div className="text-terminal-muted text-sm">No output yet...</div> <div className="text-terminal-muted text-sm">No output yet...</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2 min-w-0">
{messages.map((message, index) => ( {messages.map((message, index) => (
<div key={index} className={getMessageStyling(message.type)}> <div key={index} className={getMessageStyling(message.type)}>
{message.type === 'system' && ( {message.type === 'system' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge> <Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
<span className="text-xs text-terminal-muted">{message.content}</span> <span className="text-xs text-terminal-muted">{message.content}</span>
<Timestamp date={message.timestamp} />
</div> </div>
)} )}
{message.type === 'text' && ( {message.type === 'text' && (
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg"> <>
{message.content} <Timestamp date={message.timestamp} />
</div> <div className="font-mono text-sm whitespace-pre-wrap break-words text-terminal-fg">
{message.content}
</div>
</>
)} )}
{message.type === 'tool_call' && ( {message.type === 'tool_call' && (
<div className="border-l-2 border-terminal-tool pl-3 py-1"> <div className="border-l-2 border-terminal-tool pl-3 py-1 min-w-0">
<Badge variant="default" className="mb-1 text-xs"> <div className="flex items-center gap-2 mb-1">
{message.meta?.toolName} <Badge variant="default" className="text-xs">
</Badge> {message.meta?.toolName}
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap"> </Badge>
<Timestamp date={message.timestamp} />
</div>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content} {message.content}
</div> </div>
</div> </div>
)} )}
{message.type === 'tool_result' && ( {message.type === 'tool_result' && (
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02]"> <div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0">
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result"> <Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
Result Result
</Badge> </Badge>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap"> <div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content} {message.content}
</div> </div>
</div> </div>
)} )}
{message.type === 'error' && ( {message.type === 'error' && (
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10"> <div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10 min-w-0">
<Badge variant="destructive" className="mb-1 text-xs"> <Badge variant="destructive" className="mb-1 text-xs">
Error Error
</Badge> </Badge>
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap"> <div className="font-mono text-xs text-terminal-error whitespace-pre-wrap break-words">
{message.content} {message.content}
</div> </div>
</div> </div>
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{message.meta?.duration && ( {message.meta?.duration && (
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span> <span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
)} )}
<Timestamp date={message.timestamp} />
</div> </div>
</div> </div>
)} )}
@@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
</div> </div>
); );
} }
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 (
<span className="shrink-0 text-[10px] text-terminal-muted/60 font-mono tabular-nums">
{formatTime(date)}
</span>
);
}

View File

@@ -184,7 +184,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto"> <div className={cn("flex-1 min-h-0", tab === "details" ? "overflow-y-auto" : "flex flex-col")}>
{tab === "details" ? ( {tab === "details" ? (
<div className="px-5 py-4 space-y-5"> <div className="px-5 py-4 space-y-5">
{/* Metadata grid */} {/* Metadata grid */}
@@ -337,7 +337,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
function AgentLogsTab({ taskId }: { taskId: string }) { function AgentLogsTab({ taskId }: { taskId: string }) {
const { data: agent, isLoading } = trpc.getTaskAgent.useQuery( const { data: agent, isLoading } = trpc.getTaskAgent.useQuery(
{ taskId }, { taskId },
{ refetchOnWindowFocus: false }, { refetchOnWindowFocus: false, refetchInterval: (query) => query.state.data ? false : 5000 },
); );
if (isLoading) { if (isLoading) {
@@ -357,7 +357,7 @@ function AgentLogsTab({ taskId }: { taskId: string }) {
} }
return ( return (
<div className="h-full"> <div className="flex-1 min-h-0">
<AgentOutputViewer <AgentOutputViewer
agentId={agent.id} agentId={agent.id}
agentName={agent.name ?? undefined} agentName={agent.name ?? undefined}

View File

@@ -7,6 +7,7 @@ export interface ParsedMessage {
| "session_end" | "session_end"
| "error"; | "error";
content: string; content: string;
timestamp?: Date;
meta?: { meta?: {
toolName?: string; toolName?: string;
isError?: boolean; isError?: boolean;
@@ -60,108 +61,135 @@ export function getMessageStyling(type: ParsedMessage["type"]): string {
} }
} }
export function parseAgentOutput(raw: string): ParsedMessage[] { /**
const lines = raw.split("\n").filter(Boolean); * A chunk of raw JSONL content with an optional timestamp from the DB.
*/
export interface TimestampedChunk {
content: string;
createdAt: string;
}
/**
* Parse agent output. Accepts either a flat string (legacy) or timestamped chunks.
* When chunks have timestamps, each parsed message inherits the chunk's timestamp.
*/
export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessage[] {
const chunks: { content: string; timestamp?: Date }[] =
typeof raw === "string"
? [{ content: raw }]
: raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
const parsedMessages: ParsedMessage[] = []; const parsedMessages: ParsedMessage[] = [];
for (const line of lines) { for (const chunk of chunks) {
try { const lines = chunk.content.split("\n").filter(Boolean);
const event = JSON.parse(line); for (const line of lines) {
try {
const event = JSON.parse(line);
// System initialization // System initialization
if (event.type === "system" && event.session_id) { if (event.type === "system" && event.session_id) {
parsedMessages.push({ parsedMessages.push({
type: "system", type: "system",
content: `Session started: ${event.session_id}`, content: `Session started: ${event.session_id}`,
}); timestamp: chunk.timestamp,
} });
// 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 },
});
}
} }
}
// User messages with tool results // Assistant messages with text and tool calls
else if ( else if (
event.type === "user" && event.type === "assistant" &&
Array.isArray(event.message?.content) Array.isArray(event.message?.content)
) { ) {
for (const block of event.message.content) { for (const block of event.message.content) {
if (block.type === "tool_result") { if (block.type === "text" && block.text) {
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({ parsedMessages.push({
type: "error", type: "text",
content: stderr, content: block.text,
meta: { isError: true }, timestamp: chunk.timestamp,
}); });
} else if (output) { } else if (block.type === "tool_use") {
const displayOutput =
output.length > 1000
? output.substring(0, 1000) + "\n... (truncated)"
: output;
parsedMessages.push({ parsedMessages.push({
type: "tool_result", type: "tool_call",
content: displayOutput, content: formatToolCall(block),
timestamp: chunk.timestamp,
meta: { toolName: block.name },
}); });
} }
} }
} }
}
// Legacy streaming format // User messages with tool results
else if (event.type === "stream_event" && event.event?.delta?.text) { 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({ parsedMessages.push({
type: "text", type: "error",
content: event.event.delta.text, 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; return parsedMessages;

View File

@@ -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) - DB insert → `agent:output` event emission (single source of truth for UI)
- No FK to agents — survives agent deletion - No FK to agents — survives agent deletion
- Session tracking: spawn=1, resume=previousMax+1 - Session tracking: spawn=1, resume=previousMax+1
- Read path (`getAgentOutput` tRPC): concatenates all DB chunks (no file fallback) - Read path (`getAgentOutput` tRPC): returns timestamped chunks `{ content, createdAt }[]` from DB
- Live path (`onAgentOutput` subscription): listens for `agent:output` events - Live path (`onAgentOutput` subscription): listens for `agent:output` events (client stamps with `Date.now()`)
- Frontend: initial query loads from DB, subscription accumulates raw JSONL, both parsed via `parseAgentOutput()` - 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 ## Inter-Agent Communication

View File

@@ -62,7 +62,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getAgent | query | Single agent by name or ID | | getAgent | query | Single agent by name or ID |
| getAgentResult | query | Execution result | | getAgentResult | query | Execution result |
| getAgentQuestions | query | Pending questions | | 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 | | getActiveRefineAgent | query | Active refine agent for initiative |
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) | | getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
| listWaitingAgents | query | Agents waiting for input | | listWaitingAgents | query | Agents waiting for input |