Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 41 additions & 9 deletions dashboard/src/lib/components/views/TimelineView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
the most recent (or active) task.

Issue #38: Data Integration — PR3
Issue #85: Added tool_call event rendering
-->
<script lang="ts">
import { tasksStore } from '$lib/stores/tasks.svelte.js';
Expand Down Expand Up @@ -36,7 +37,8 @@
exec: { color: 'var(--color-accent-yellow)', label: 'EXEC' },
fail: { color: 'var(--color-accent-red)', label: 'FAIL' },
retry: { color: 'var(--color-accent-orange)', label: 'RETRY' },
success: { color: 'var(--color-accent-green)', label: 'DONE' }
success: { color: 'var(--color-accent-green)', label: 'DONE' },
tool_call: { color: 'var(--color-accent-blue, #60a5fa)', label: 'TOOL' }
};

function budgetPct(b: TaskBudget): number {
Expand All @@ -48,6 +50,11 @@
if (pct > 75) return 'var(--color-accent-amber)';
return 'var(--color-accent-cyan)';
}

/** Check if an event is a tool_call for compact rendering. */
function isToolCall(event: TimelineEvent): boolean {
return event.type === 'tool_call';
}
</script>

<div class="p-4 pl-6" style="font-family: var(--font-mono);">
Expand Down Expand Up @@ -100,39 +107,64 @@
{@const style = eventStyles[event.type] ?? { color: 'var(--color-text-dim)', label: '?' }}
{@const isFail = event.type === 'fail'}
{@const isSuccess = event.type === 'success'}
{@const isTool = isToolCall(event)}
{@const isLast = i === task.timeline.length - 1}

<div class="relative flex gap-3 pb-0.5">
{#if !isLast}
<div class="absolute bottom-0 left-[13px] top-7 w-px" style="background: var(--color-border);"></div>
{/if}

<!-- Agent avatar — smaller for tool calls -->
<div
class="z-1 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-[10px] font-semibold"
class="z-1 flex shrink-0 items-center justify-center rounded-md font-semibold"
class:h-7={!isTool}
class:w-7={!isTool}
class:text-[10px]={!isTool}
class:h-5={isTool}
class:w-5={isTool}
class:text-[8px]={isTool}
class:mt-0.5={isTool}
class:ml-1={isTool}
style="background: {agent.color}18; border: 1px solid {agent.color}25; color: {agent.color};"
>
{agent.name.charAt(0)}
</div>

<!-- Event card — compact for tool calls -->
<div
class="mb-1.5 flex-1 rounded-md border p-1.5 px-3"
class="mb-1.5 flex-1 rounded-md border"
class:p-1.5={!isTool}
class:px-3={!isTool}
class:py-1={isTool}
class:px-2.5={isTool}
style="
background: {style.color}08;
border-color: {isFail ? style.color + '40' : 'var(--color-border)'};
border-left: {isFail ? '3px solid ' + style.color : isSuccess ? '3px solid ' + style.color : '1px solid var(--color-border)'};
border-left: {isFail ? '3px solid ' + style.color : isSuccess ? '3px solid ' + style.color : isTool ? '2px solid ' + style.color + '40' : '1px solid var(--color-border)'};
{isTool ? 'opacity: 0.85;' : ''}
"
>
<div class="mb-0.5 flex items-center justify-between">
<div class="flex items-center justify-between" class:mb-0.5={!isTool}>
<div class="flex items-center gap-1.5">
<span class="text-[10px]" style="color: {agent.color};">{agent.name}</span>
{#if !isTool}
<span class="text-[10px]" style="color: {agent.color};">{agent.name}</span>
{/if}
<span
class="rounded-sm px-1.5 py-px text-[8px] font-semibold"
class="rounded-sm px-1.5 py-px font-semibold"
class:text-[8px]={!isTool}
class:text-[7px]={isTool}
style="color: {style.color}; background: {style.color}18; letter-spacing: 0.5px;"
>{style.label}</span>
</div>
<span class="text-[9px]" style="color: var(--color-text-faint);">{event.time}</span>
</div>
<div class="text-[11px] leading-relaxed" style="color: {isFail ? '#f8a0a0' : isSuccess ? '#6ee7b7' : 'var(--color-text-muted)'};">
<div
class="leading-relaxed"
class:text-[11px]={!isTool}
class:text-[10px]={isTool}
style="color: {isFail ? '#f8a0a0' : isSuccess ? '#6ee7b7' : isTool ? 'var(--color-text-dim)' : 'var(--color-text-muted)'};"
>
{event.action}
</div>
</div>
Expand All @@ -153,7 +185,7 @@
{ label: 'Tokens Used', value: (task.budget.tokens_used).toLocaleString(), sub: `of ${task.budget.token_budget.toLocaleString()} budget`, icon: 'Tk' },
{ label: 'Cost', value: `$${task.budget.cost_used.toFixed(2)}`, sub: `of $${task.budget.cost_budget} budget`, icon: '$' },
{ label: 'Retries', value: String(task.budget.retries_used), sub: `of ${task.budget.max_retries} max`, icon: 'Rt' },
{ label: 'Events', value: String(task.timeline.length), sub: 'timeline entries', icon: 'Ev' }
{ label: 'Events', value: String(task.timeline.length), sub: `${task.timeline.filter(e => e.type === 'tool_call').length} tool calls`, icon: 'Ev' }
] as metric}
<div class="rounded-md border p-2.5" style="background: var(--color-bg-primary); border-color: var(--color-border);">
<div class="mb-1 flex items-center justify-between">
Expand Down
27 changes: 25 additions & 2 deletions dashboard/src/lib/sse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@
* });
*
* Issue #37
* Issue #85: Added tool_call event handling
*/

import { connection } from '$lib/stores/connection.svelte.js';
import { agentsStore } from '$lib/stores/agents.svelte.js';
import { tasksStore } from '$lib/stores/tasks.svelte.js';
import { memoryStore } from '$lib/stores/memory.svelte.js';
import type { SSEEventType } from '$lib/types/api.js';
import type { SSEEventType, ToolCallEvent } from '$lib/types/api.js';

/** Backoff config */
const INITIAL_DELAY_MS = 1000;
Expand All @@ -38,6 +39,21 @@ function getBackoffDelay(): number {
return Math.min(delay, MAX_DELAY_MS);
}

/**
* Type guard for ToolCallEvent payloads (CodeRabbit fix #1).
* Validates all required fields are present with correct types
* before passing to the store handler.
*/
function isToolCallEvent(payload: Record<string, unknown>): payload is ToolCallEvent {
return (
typeof payload.task_id === 'string' &&
typeof payload.agent === 'string' &&
typeof payload.tool === 'string' &&
typeof payload.success === 'boolean' &&
typeof payload.result_preview === 'string'
);
}

/**
* Route an SSE event to the appropriate store handler.
*/
Expand Down Expand Up @@ -67,6 +83,12 @@ function dispatch(eventType: SSEEventType, payload: Record<string, unknown>) {
);
break;

case 'tool_call':
if (isToolCallEvent(payload)) {
tasksStore.handleToolCall(payload);
}
break;

case 'log_line':
// Log lines are consumed by the BottomPanel directly.
// For now, dispatch a custom DOM event that components can listen to.
Expand Down Expand Up @@ -140,7 +162,8 @@ function connect() {
'task_progress',
'task_complete',
'memory_added',
'log_line'
'log_line',
'tool_call'
];

for (const type of eventTypes) {
Expand Down
86 changes: 82 additions & 4 deletions dashboard/src/lib/stores/tasks.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,59 @@
* Tasks store — reactive task list.
*
* Initialised by fetching GET /api/tasks.
* Updated in real-time from SSE `task_progress` and `task_complete` events.
* Updated in real-time from SSE `task_progress`, `task_complete`,
* and `tool_call` events.
* Supports mutations: create, cancel, retry.
*
* Issue #37
* Issue #85: Added handleToolCall() for TOOL_CALL SSE events
*/

import type { TaskSummary, TaskStatus, CreateTaskResponse } from '$lib/types/api.js';
import type { TaskSummary, TaskStatus, CreateTaskResponse, ToolCallEvent, TimelineEvent } from '$lib/types/api.js';

let tasks = $state<TaskSummary[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);

/**
* Buffer for tool call events that arrive before the task exists in memory
* or while a refresh() is in flight. Keyed by task_id.
* (CodeRabbit fix #2 — prevent reconnect hydration from erasing in-flight tool calls)
*/
const pendingToolCalls = new Map<string, TimelineEvent[]>();

/** Format current time as HH:MM for timeline entries. */
function nowTime(): string {
const d = new Date();
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
}

/** Convert a ToolCallEvent into a TimelineEvent for the task timeline. */
function toolCallToTimeline(data: ToolCallEvent): TimelineEvent {
const statusIcon = data.success ? '\u2713' : '\u2717';
const preview = data.result_preview
? `: ${data.result_preview.slice(0, 80)}${data.result_preview.length > 80 ? '...' : ''}`
: '';
return {
time: nowTime(),
agent: data.agent === 'developer' ? 'dev' : data.agent === 'qa' ? 'qa' : data.agent,
action: `${statusIcon} ${data.tool}${preview}`,
type: 'tool_call',
sandbox: 'locked'
};
}

/**
* Replay any buffered tool call timeline events into a task's timeline.
* Returns the merged timeline, or the original if no buffered events exist.
*/
function replayBuffered(task: TaskSummary): TimelineEvent[] {
const buffered = pendingToolCalls.get(task.id);
if (!buffered || buffered.length === 0) return task.timeline;
pendingToolCalls.delete(task.id);
return [...task.timeline, ...buffered];
}

export const tasksStore = {
get list() {
return tasks;
Expand All @@ -29,15 +70,24 @@ export const tasksStore = {
return tasks.filter((t) => active.includes(t.status));
},

/** Fetch tasks from the proxy route. */
/**
* Fetch tasks from the proxy route.
* After fetching, replays any buffered tool call events that arrived
* while the fetch was in flight (CodeRabbit fix #2).
*/
async refresh() {
loading = true;
error = null;
try {
const res = await fetch('/api/tasks');
const body = await res.json();
if (res.ok && body.data) {
tasks = body.data;
const fetched = body.data as TaskSummary[];
// Merge buffered tool call events into fetched tasks
tasks = fetched.map((t) => ({
...t,
timeline: replayBuffered(t)
}));
} else {
error = body.errors?.[0] ?? 'Failed to fetch tasks';
}
Expand Down Expand Up @@ -142,9 +192,37 @@ export const tasksStore = {
);
},

/**
* Apply an SSE tool_call event (Issue #85).
*
* Appends tool calls as timeline events on the matching task.
* If the task isn't in memory yet (e.g. during reconnect refresh),
* buffers the event for replay once refresh() completes (CodeRabbit fix #2).
*/
handleToolCall(data: ToolCallEvent) {
const timelineEvent = toolCallToTimeline(data);

const idx = tasks.findIndex((t) => t.id === data.task_id);
if (idx < 0) {
// Task not in memory — buffer for replay after refresh()
const existing = pendingToolCalls.get(data.task_id) ?? [];
existing.push(timelineEvent);
pendingToolCalls.set(data.task_id, existing);
return;
}

const task = tasks[idx];
const newTimeline = [...task.timeline, timelineEvent];

tasks = tasks.map((t, i) =>
i === idx ? { ...t, timeline: newTimeline } : t
);
},

/** Reset to empty state. */
reset() {
tasks = [];
error = null;
pendingToolCalls.clear();
}
};
12 changes: 11 additions & 1 deletion dashboard/src/lib/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
* Issue #37: SvelteKit API Routes & Providers
* Issue #19: Added confidence, sandbox, related_files to MemoryEntry; AuditLogEntry
* Issue #85: Added tool_call SSE event type and ToolCallEvent interface
*/

// -- Envelope --
Expand Down Expand Up @@ -174,7 +175,16 @@ export type SSEEventType =
| 'task_progress'
| 'task_complete'
| 'memory_added'
| 'log_line';
| 'log_line'
| 'tool_call';

export interface ToolCallEvent {
task_id: string;
agent: string;
tool: string;
success: boolean;
result_preview: string;
}

export interface SSEEventData {
type: SSEEventType;
Expand Down
Loading