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:
@@ -218,12 +218,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
||||
|
||||
getAgentOutput: publicProcedure
|
||||
.input(agentIdentifierSchema)
|
||||
.query(async ({ ctx, input }): Promise<string> => {
|
||||
.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
|
||||
|
||||
@@ -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<ParsedMessage[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Accumulate raw JSONL: initial query data + live subscription chunks
|
||||
const rawBufferRef = useRef<string>('');
|
||||
// Accumulate timestamped chunks: initial query data + live subscription chunks
|
||||
const chunksRef = useRef<TimestampedChunk[]>([]);
|
||||
|
||||
// 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
|
||||
<div
|
||||
ref={containerRef}
|
||||
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 ? (
|
||||
<div className="text-terminal-muted text-sm">Loading output...</div>
|
||||
) : !hasOutput ? (
|
||||
<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) => (
|
||||
<div key={index} className={getMessageStyling(message.type)}>
|
||||
{message.type === 'system' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
|
||||
<span className="text-xs text-terminal-muted">{message.content}</span>
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.type === 'text' && (
|
||||
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg">
|
||||
{message.content}
|
||||
</div>
|
||||
<>
|
||||
<Timestamp date={message.timestamp} />
|
||||
<div className="font-mono text-sm whitespace-pre-wrap break-words text-terminal-fg">
|
||||
{message.content}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{message.type === 'tool_call' && (
|
||||
<div className="border-l-2 border-terminal-tool pl-3 py-1">
|
||||
<Badge variant="default" className="mb-1 text-xs">
|
||||
{message.meta?.toolName}
|
||||
</Badge>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
|
||||
<div className="border-l-2 border-terminal-tool pl-3 py-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="default" className="text-xs">
|
||||
{message.meta?.toolName}
|
||||
</Badge>
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Result
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Error
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
{message.meta?.duration && (
|
||||
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
|
||||
)}
|
||||
<Timestamp date={message.timestamp} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||
</div>
|
||||
|
||||
{/* 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" ? (
|
||||
<div className="px-5 py-4 space-y-5">
|
||||
{/* 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 (
|
||||
<div className="h-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<AgentOutputViewer
|
||||
agentId={agent.id}
|
||||
agentName={agent.name ?? undefined}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface ParsedMessage {
|
||||
| "session_end"
|
||||
| "error";
|
||||
content: string;
|
||||
timestamp?: Date;
|
||||
meta?: {
|
||||
toolName?: string;
|
||||
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[] = [];
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user