diff --git a/web/src/stores/agents.svelte.ts b/web/src/stores/agents.svelte.ts index a29d380..bf5ad99 100644 --- a/web/src/stores/agents.svelte.ts +++ b/web/src/stores/agents.svelte.ts @@ -4,21 +4,23 @@ */ import { apiClient } from '../api'; -import type { ActiveAgent, WsEvent } from '../types'; +import type { ActiveAgent, FeedEvent, WsEvent } from '../types'; /** * Agents state object. * Managed as Svelte 5 $state for reactivity. - * Event feed is a ring buffer (max 200 items, newest at index 0). + * Event feed is a ring buffer (max 200 items, oldest removed when cap exceeded). */ export const agentsState = $state<{ activeAgents: Array; - eventFeed: Array; + eventFeed: Array; loading: boolean; + feedIdCounter: number; }>({ activeAgents: [], eventFeed: [], loading: false, + feedIdCounter: 0, }); const MAX_FEED_SIZE = 200; @@ -38,12 +40,18 @@ export async function loadAgents(goalId: string): Promise { /** * Add an event to the feed. * Maintains a ring buffer by trimming to MAX_FEED_SIZE. - * Newest events appear at index 0. + * Newest events appear at the end (terminal-like behavior). + * Wraps WsEvent in FeedEvent with stable feedId for Svelte diffing. */ export function addEvent(event: WsEvent): void { - agentsState.eventFeed.unshift(event); + const feedEvent: FeedEvent = { + feedId: agentsState.feedIdCounter++, + event, + arrivedAt: new Date().toISOString(), + }; + agentsState.eventFeed.push(feedEvent); if (agentsState.eventFeed.length > MAX_FEED_SIZE) { - agentsState.eventFeed.pop(); + agentsState.eventFeed.shift(); } } diff --git a/web/src/types.ts b/web/src/types.ts index 0fa7af7..bdde204 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -261,6 +261,16 @@ export type ImportRequest = { strategy?: 'merge' | 'theirs' | 'ours'; }; +/** + * Wrapper for WebSocket events with stable ID and arrival timestamp. + * Used to provide stable keys for Svelte's #each block diffing. + */ +export type FeedEvent = { + feedId: number; + event: WsEvent; + arrivedAt: string; +}; + /** * WebSocket event discriminated union. * Rust: #[serde(tag = "type", rename_all = "snake_case")] diff --git a/web/src/views/AgentMonitor.svelte b/web/src/views/AgentMonitor.svelte index 645b1c4..97f1313 100644 --- a/web/src/views/AgentMonitor.svelte +++ b/web/src/views/AgentMonitor.svelte @@ -10,7 +10,7 @@ import { formatRelativeTime } from '../lib/date-formatting'; import AgentStatusBadge from '../components/AgentStatusBadge.svelte'; import LoadingSpinner from '../components/LoadingSpinner.svelte'; - import type { WsEvent } from '../types'; + import type { FeedEvent, WsEvent } from '../types'; let currentRoute = $derived(getCurrentRoute()); let goalId = $derived(currentRoute.params.goalId); @@ -61,7 +61,8 @@ /** * Format event for display based on type. */ - function formatEventMessage(event: WsEvent): string { + function formatEventMessage(feedEvent: FeedEvent): string { + const event = feedEvent.event; switch (event.type) { case 'agent_spawned': return `Agent ${event.agent_id} spawned with profile ${event.profile} for goal ${event.goal_id}`; @@ -79,7 +80,8 @@ /** * Get event background color based on type. */ - function getEventBgColor(event: WsEvent): string { + function getEventBgColor(feedEvent: FeedEvent): string { + const event = feedEvent.event; switch (event.type) { case 'agent_spawned': return 'rgba(59, 130, 246, 0.1)'; // blue @@ -95,7 +97,8 @@ /** * Get event text color based on type. */ - function getEventTextColor(event: WsEvent): string { + function getEventTextColor(feedEvent: FeedEvent): string { + const event = feedEvent.event; switch (event.type) { case 'agent_spawned': return '#93c5fd'; // light blue @@ -109,11 +112,11 @@ } /** - * Get event timestamp with relative time update trigger. + * Get event timestamp with relative time. + * Accepts relativeTimeCounter parameter to create reactive dependency for periodic updates. */ - function getEventTime(event: WsEvent): string { - const timestamp = 'created_at' in event ? event.created_at : new Date().toISOString(); - return formatRelativeTime(timestamp); + function getEventTime(feedEvent: FeedEvent, _tick: number): string { + return formatRelativeTime(feedEvent.arrivedAt); } @@ -158,13 +161,13 @@

No agent activity yet. Events will appear here as agents run.

{/if} - {#each agentsState.eventFeed as event (event.type + Math.random())} + {#each agentsState.eventFeed as feedEvent (feedEvent.feedId)}
- {getEventTime(event)} - {formatEventMessage(event)} + {getEventTime(feedEvent, relativeTimeCounter)} + {formatEventMessage(feedEvent)}
{/each} diff --git a/web/src/views/GraphSearch.svelte b/web/src/views/GraphSearch.svelte index 69b0d8a..0490522 100644 --- a/web/src/views/GraphSearch.svelte +++ b/web/src/views/GraphSearch.svelte @@ -5,7 +5,7 @@ * Uses FTS5 search endpoint to find nodes by title/description. */ - import { searchState, executeSearch, setQuery, setNodeTypeFilter, clearSearch } from '../stores/search.svelte'; + import { searchState, executeSearch, setNodeTypeFilter } from '../stores/search.svelte'; import { projectsState, getSelectedProject } from '../stores/projects.svelte'; import SearchResult from '../components/SearchResult.svelte'; import LoadingSpinner from '../components/LoadingSpinner.svelte'; @@ -71,14 +71,6 @@ return searchState.nodeTypeFilter === type; } - /** - * Clear search on mount. - */ - $effect(() => { - return () => { - // Cleanup on unmount - }; - });