diff --git a/.gitignore b/.gitignore index 2b4ad1f6..bf4acb90 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ sessions/ html/ .superset/ .github/hooks/ +.superpowers/ diff --git a/frontend/e2e/activity-minimap.spec.ts b/frontend/e2e/activity-minimap.spec.ts new file mode 100644 index 00000000..503d38bb --- /dev/null +++ b/frontend/e2e/activity-minimap.spec.ts @@ -0,0 +1,126 @@ +import { test, expect } from "@playwright/test"; +import { SessionsPage } from "./pages/sessions-page"; + +test.describe("Activity Minimap", () => { + let sp: SessionsPage; + + test.beforeEach(async ({ page }) => { + sp = new SessionsPage(page); + await sp.goto(); + await sp.selectSession(0); + }); + + test("minimap is hidden by default", async ({ + page, + }) => { + await expect( + page.locator(".activity-minimap"), + ).not.toBeVisible(); + }); + + test("toggle button shows and hides minimap", async ({ + page, + }) => { + await page.locator(".minimap-btn").click(); + await expect( + page.locator(".activity-minimap"), + ).toBeVisible(); + + await page.locator(".minimap-btn").click(); + await expect( + page.locator(".activity-minimap"), + ).not.toBeVisible(); + }); + + test("minimap shows bars for sessions with timestamps", async ({ + page, + }) => { + await page.locator(".minimap-btn").click(); + + await expect( + page.locator(".minimap-status"), + ).not.toBeVisible({ timeout: 3000 }); + + const bars = page.locator(".minimap-bar"); + const count = await bars.count(); + expect(count).toBeGreaterThan(0); + }); + + test("clicking a bar scrolls the message list", async ({ + page, + }) => { + await page.locator(".minimap-btn").click(); + + const clickableBars = page.locator( + "g.minimap-bar[role='button']", + ); + await clickableBars.first().waitFor({ timeout: 3000 }); + + const barCount = await clickableBars.count(); + expect(barCount).toBeGreaterThan(0); + + const scrollBefore = await sp.scroller.evaluate( + (el) => el.scrollTop, + ); + + const lastBar = clickableBars.nth(barCount - 1); + await lastBar.click(); + + if (barCount > 1) { + await expect + .poll( + () => + sp.scroller.evaluate((el) => el.scrollTop), + { timeout: 3000 }, + ) + .not.toBe(scrollBefore); + } + }); + + test("active indicator moves after reopen without scroll", async ({ + page, + }) => { + // Open minimap, wait for multiple bars. + await page.locator(".minimap-btn").click(); + const bars = page.locator("g.minimap-bar[role='button']"); + await bars.first().waitFor({ timeout: 3000 }); + + const barCount = await bars.count(); + if (barCount < 2) { + // Can't test indicator movement with < 2 bars. + return; + } + + // Record the x position of the active indicator. + const indicator = page.locator(".bar-indicator"); + await expect(indicator).toBeVisible(); + const xBefore = await indicator.evaluate( + (el) => el.getAttribute("x"), + ); + + // Close minimap, scroll to the bottom. + await page.locator(".minimap-btn").click(); + await expect( + page.locator(".activity-minimap"), + ).not.toBeVisible(); + + await sp.scroller.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + + // Reopen — indicator should appear at a different + // position reflecting the new scroll location. + await page.locator(".minimap-btn").click(); + await expect(indicator).toBeVisible({ timeout: 3000 }); + + await expect + .poll( + () => + indicator.evaluate( + (el) => el.getAttribute("x"), + ), + { timeout: 3000 }, + ) + .not.toBe(xBefore); + }); +}); diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 1f2aa660..9c0f208e 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -6,6 +6,8 @@ import StatusBar from "./lib/components/layout/StatusBar.svelte"; import SessionList from "./lib/components/sidebar/SessionList.svelte"; import MessageList from "./lib/components/content/MessageList.svelte"; + import ActivityMinimap from "./lib/components/content/ActivityMinimap.svelte"; + import { sessionActivity } from "./lib/stores/sessionActivity.svelte.js"; import CommandPalette from "./lib/components/command-palette/CommandPalette.svelte"; import AboutModal from "./lib/components/modals/AboutModal.svelte"; import ShortcutsModal from "./lib/components/modals/ShortcutsModal.svelte"; @@ -84,9 +86,15 @@ messages.reload(); sessions.refreshActiveSession(); sessions.loadChildSessions(id); + if (ui.activityMinimapOpen) { + sessionActivity.reload(id); + } else { + sessionActivity.invalidate(); + } }); pins.loadForSession(id); } else { + sessionActivity.clear(); messages.clear(); sessions.childSessions = new Map(); sync.unwatchSession(); @@ -380,6 +388,11 @@ session={session} onBack={() => sessions.deselectSession()} /> + {#if ui.activityMinimapOpen && sessions.activeSessionId} + + {/if} {:else} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index a58b11c6..bdb01c50 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -2,7 +2,6 @@ import type { SessionPage, Session, MessagesResponse, - MinimapResponse, SearchResponse, ProjectsResponse, MachinesResponse, @@ -34,6 +33,7 @@ import type { PinsResponse, TrashResponse, } from "./types.js"; +import type { SessionActivityResponse } from "./types/session-activity.js"; const SERVER_URL_KEY = "agentsview-server-url"; const AUTH_TOKEN_KEY = "agentsview-auth-token"; @@ -171,6 +171,14 @@ export function getChildSessions( return fetchJSON(`/sessions/${id}/children`, init); } +export function getSessionActivity( + sessionId: string, +): Promise { + return fetchJSON( + `/sessions/${sessionId}/activity`, + ); +} + /* Messages */ export interface GetMessagesParams { @@ -190,20 +198,6 @@ export function getMessages( ); } -export interface GetMinimapParams { - from?: number; - max?: number; -} - -export function getMinimap( - sessionId: string, - params: GetMinimapParams = {}, -): Promise { - return fetchJSON( - `/sessions/${sessionId}/minimap${buildQuery({ ...params })}`, - ); -} - /* Search */ export function search( diff --git a/frontend/src/lib/api/types/core.ts b/frontend/src/lib/api/types/core.ts index 2cb1cc13..5018db56 100644 --- a/frontend/src/lib/api/types/core.ts +++ b/frontend/src/lib/api/types/core.ts @@ -72,12 +72,6 @@ export interface Message { is_system: boolean; } -/** Matches Go MinimapEntry struct */ -export type MinimapEntry = Pick< - Message, - "ordinal" | "role" | "content_length" | "has_thinking" | "has_tool_use" ->; - /** Matches Go SearchResult struct in internal/db/search.go */ export interface SearchResult { session_id: string; @@ -104,11 +98,6 @@ export interface MessagesResponse { count: number; } -export interface MinimapResponse { - entries: MinimapEntry[]; - count: number; -} - export interface SearchResponse { query: string; results: SearchResult[]; diff --git a/frontend/src/lib/api/types/index.ts b/frontend/src/lib/api/types/index.ts index e32c84dd..5e4f1f46 100644 --- a/frontend/src/lib/api/types/index.ts +++ b/frontend/src/lib/api/types/index.ts @@ -3,3 +3,4 @@ export type * from "./sync.js"; export type * from "./analytics.js"; export type * from "./github.js"; export type * from "./insights.js"; +export type * from "./session-activity.js"; diff --git a/frontend/src/lib/api/types/session-activity.ts b/frontend/src/lib/api/types/session-activity.ts new file mode 100644 index 00000000..4632dde2 --- /dev/null +++ b/frontend/src/lib/api/types/session-activity.ts @@ -0,0 +1,13 @@ +export interface SessionActivityBucket { + start_time: string; + end_time: string; + user_count: number; + assistant_count: number; + first_ordinal: number | null; +} + +export interface SessionActivityResponse { + buckets: SessionActivityBucket[]; + interval_seconds: number; + total_messages: number; +} diff --git a/frontend/src/lib/components/content/ActivityMinimap.svelte b/frontend/src/lib/components/content/ActivityMinimap.svelte new file mode 100644 index 00000000..5e2c12e9 --- /dev/null +++ b/frontend/src/lib/components/content/ActivityMinimap.svelte @@ -0,0 +1,347 @@ + + +
+ {#if sessionActivity.loading || !sessionActivity.loaded || !sessionActivity.isForSession(sessionId)} +
Loading activity...
+ {:else if sessionActivity.error} +
+ {sessionActivity.error} + +
+ {:else if sessionActivity.buckets.length === 0} +
+ No timestamp data available +
+ {:else if chart} +
+ + {#each chart.bars as bar} + {#if bar.populated} + handleBarClick(bar.bucket)} + onkeydown={(e) => + handleBarKeydown(e, bar.bucket)} + onmouseenter={(e) => handleBarHover(e, bar)} + onmouseleave={handleBarLeave} + > + + + {:else} + + {/if} + {#if activeIndex === bar.index} + + {/if} + {/each} + +
+ + {#if tooltip} +
+ {tooltip.text} +
+ {/if} + +
+ {startTime} + {endTime} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/content/MessageList.svelte b/frontend/src/lib/components/content/MessageList.svelte index ce8f820f..dc0c16c7 100644 --- a/frontend/src/lib/components/content/MessageList.svelte +++ b/frontend/src/lib/components/content/MessageList.svelte @@ -18,6 +18,7 @@ } from "../../utils/content-parser.js"; import { isSystemMessage } from "../../utils/messages.js"; import { inSessionSearch } from "../../stores/inSessionSearch.svelte.js"; + import { sessionActivity } from "../../stores/sessionActivity.svelte.js"; import SessionFindBar from "./SessionFindBar.svelte"; let containerRef: HTMLDivElement | undefined = $state(undefined); @@ -126,25 +127,71 @@ }; } + function publishVisibleTimestamp() { + const v = virtualizer.instance; + if (!v) return; + const items = v.getVirtualItems(); + // Skip overscanned items above the viewport. + const scrollTop = v.scrollOffset ?? 0; + for (const vi of items) { + if (vi.end <= scrollTop) continue; + const item = + displayItemsAsc[ + ui.sortNewestFirst + ? displayItemsAsc.length - 1 - vi.index + : vi.index + ]; + if (!item) continue; + const ts = + item.kind === "message" + ? item.message.timestamp + : item.timestamp; + if (ts) { + sessionActivity.firstVisibleTimestamp = ts; + return; + } + } + sessionActivity.firstVisibleTimestamp = null; + } + + // Recompute visible timestamp when minimap opens or + // message content changes (e.g. SSE reload). + $effect(() => { + if (ui.activityMinimapOpen) { + // Track message array so the effect re-runs after + // content changes while the minimap is open. + void messages.messages.length; + publishVisibleTimestamp(); + } + }); + function handleScroll() { if (!containerRef) return; if (scrollRaf !== null) return; scrollRaf = requestAnimationFrame(() => { scrollRaf = null; if (!containerRef) return; - const items = virtualizer.instance?.getVirtualItems() ?? []; + const items = + virtualizer.instance?.getVirtualItems() ?? []; if (items.length > 0 && messages.hasOlder) { const firstVisible = items[0]!.index; - const lastVisible = items[items.length - 1]!.index; + const lastVisible = + items[items.length - 1]!.index; const threshold = 30; if ( (ui.sortNewestFirst && - lastVisible >= displayItemsAsc.length - threshold) || - (!ui.sortNewestFirst && firstVisible <= threshold) + lastVisible >= + displayItemsAsc.length - threshold) || + (!ui.sortNewestFirst && + firstVisible <= threshold) ) { messages.loadOlder(); } } + + if (ui.activityMinimapOpen) { + publishVisibleTimestamp(); + } }); } diff --git a/frontend/src/lib/components/layout/SessionBreadcrumb.svelte b/frontend/src/lib/components/layout/SessionBreadcrumb.svelte index c58a280b..456c76dd 100644 --- a/frontend/src/lib/components/layout/SessionBreadcrumb.svelte +++ b/frontend/src/lib/components/layout/SessionBreadcrumb.svelte @@ -20,6 +20,7 @@ import { inSessionSearch } from "../../stores/inSessionSearch.svelte.js"; import { messages as messagesStore } from "../../stores/messages.svelte.js"; + import { ui } from "../../stores/ui.svelte.js"; interface Props { session: Session | undefined; @@ -46,15 +47,27 @@ .catch(() => {}); }); + let resolvedSessionDirId: string | null = null; $effect(() => { - sessionDir = null; - if (!session) return; + if (!session) { + sessionDir = null; + resolvedSessionDirId = null; + return; + } const id = session.id; + if (id === resolvedSessionDirId) return; + sessionDir = null; getSessionDirectory(id) .then(({ path }) => { - if (session?.id === id) sessionDir = path || null; + if (session?.id === id) { + sessionDir = path || null; + resolvedSessionDirId = id; + } }) - .catch(() => {}); + .catch(() => { + // Don't cache the ID on failure so the next + // session refresh retries the lookup. + }); }); let sessionContextTokens = $derived(session?.peak_context_tokens ?? 0); @@ -494,6 +507,17 @@ {/if} +