Skip to content

Commit 30063de

Browse files
wesmclaude
andauthored
feat: add session activity minimap with click-to-navigate (#248)
## Summary - Add a togglable horizontal bar chart to the session detail view showing message activity intensity over time intervals - Clicking a bar scrolls the message list to that point in the session - New `GET /api/v1/sessions/{id}/activity` endpoint with adaptive time-bucketed message counts (SQLite and PostgreSQL) - Remove unused `/minimap` endpoint and all supporting code - Fix pre-existing breadcrumb flicker on SSE session refresh - Fix pre-existing model badge flicker during session reload <img width="1099" height="106" alt="image" src="https://github.com/user-attachments/assets/1f66c6a9-357c-4b13-b3da-31cfebe842ca" /> ## Details **Backend:** - Adaptive interval sizing targets ~30 buckets per session (1m intervals for short sessions, scales dynamically for long ones, capped at 50 buckets) - Sub-second timestamp precision via `julianday()` (SQLite) and `floor(EXTRACT(EPOCH ...))` (PostgreSQL) - System and prefix-detected injected messages excluded via `SystemPrefixSQL` - Malformed timestamps silently excluded from bucketing **Frontend:** - `ActivityMinimap.svelte` custom SVG bar chart with tooltips, keyboard/ARIA accessibility, error/retry states - Session activity store with load versioning and cache invalidation to prevent stale cross-session data - Toggle button in session breadcrumb with localStorage persistence - Scroll-synced active bucket indicator via `firstVisibleTimestamp` published from `MessageList` (excludes overscanned rows) - Single-color bars showing total message count per interval; tooltip shows user/assistant breakdown 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 496f9a8 commit 30063de

30 files changed

+2149
-442
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,4 @@ sessions/
4747
html/
4848
.superset/
4949
.github/hooks/
50+
.superpowers/
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { test, expect } from "@playwright/test";
2+
import { SessionsPage } from "./pages/sessions-page";
3+
4+
test.describe("Activity Minimap", () => {
5+
let sp: SessionsPage;
6+
7+
test.beforeEach(async ({ page }) => {
8+
sp = new SessionsPage(page);
9+
await sp.goto();
10+
await sp.selectSession(0);
11+
});
12+
13+
test("minimap is hidden by default", async ({
14+
page,
15+
}) => {
16+
await expect(
17+
page.locator(".activity-minimap"),
18+
).not.toBeVisible();
19+
});
20+
21+
test("toggle button shows and hides minimap", async ({
22+
page,
23+
}) => {
24+
await page.locator(".minimap-btn").click();
25+
await expect(
26+
page.locator(".activity-minimap"),
27+
).toBeVisible();
28+
29+
await page.locator(".minimap-btn").click();
30+
await expect(
31+
page.locator(".activity-minimap"),
32+
).not.toBeVisible();
33+
});
34+
35+
test("minimap shows bars for sessions with timestamps", async ({
36+
page,
37+
}) => {
38+
await page.locator(".minimap-btn").click();
39+
40+
await expect(
41+
page.locator(".minimap-status"),
42+
).not.toBeVisible({ timeout: 3000 });
43+
44+
const bars = page.locator(".minimap-bar");
45+
const count = await bars.count();
46+
expect(count).toBeGreaterThan(0);
47+
});
48+
49+
test("clicking a bar scrolls the message list", async ({
50+
page,
51+
}) => {
52+
await page.locator(".minimap-btn").click();
53+
54+
const clickableBars = page.locator(
55+
"g.minimap-bar[role='button']",
56+
);
57+
await clickableBars.first().waitFor({ timeout: 3000 });
58+
59+
const barCount = await clickableBars.count();
60+
expect(barCount).toBeGreaterThan(0);
61+
62+
const scrollBefore = await sp.scroller.evaluate(
63+
(el) => el.scrollTop,
64+
);
65+
66+
const lastBar = clickableBars.nth(barCount - 1);
67+
await lastBar.click();
68+
69+
if (barCount > 1) {
70+
await expect
71+
.poll(
72+
() =>
73+
sp.scroller.evaluate((el) => el.scrollTop),
74+
{ timeout: 3000 },
75+
)
76+
.not.toBe(scrollBefore);
77+
}
78+
});
79+
80+
test("active indicator moves after reopen without scroll", async ({
81+
page,
82+
}) => {
83+
// Open minimap, wait for multiple bars.
84+
await page.locator(".minimap-btn").click();
85+
const bars = page.locator("g.minimap-bar[role='button']");
86+
await bars.first().waitFor({ timeout: 3000 });
87+
88+
const barCount = await bars.count();
89+
if (barCount < 2) {
90+
// Can't test indicator movement with < 2 bars.
91+
return;
92+
}
93+
94+
// Record the x position of the active indicator.
95+
const indicator = page.locator(".bar-indicator");
96+
await expect(indicator).toBeVisible();
97+
const xBefore = await indicator.evaluate(
98+
(el) => el.getAttribute("x"),
99+
);
100+
101+
// Close minimap, scroll to the bottom.
102+
await page.locator(".minimap-btn").click();
103+
await expect(
104+
page.locator(".activity-minimap"),
105+
).not.toBeVisible();
106+
107+
await sp.scroller.evaluate((el) => {
108+
el.scrollTop = el.scrollHeight;
109+
});
110+
111+
// Reopen — indicator should appear at a different
112+
// position reflecting the new scroll location.
113+
await page.locator(".minimap-btn").click();
114+
await expect(indicator).toBeVisible({ timeout: 3000 });
115+
116+
await expect
117+
.poll(
118+
() =>
119+
indicator.evaluate(
120+
(el) => el.getAttribute("x"),
121+
),
122+
{ timeout: 3000 },
123+
)
124+
.not.toBe(xBefore);
125+
});
126+
});

frontend/src/App.svelte

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import StatusBar from "./lib/components/layout/StatusBar.svelte";
77
import SessionList from "./lib/components/sidebar/SessionList.svelte";
88
import MessageList from "./lib/components/content/MessageList.svelte";
9+
import ActivityMinimap from "./lib/components/content/ActivityMinimap.svelte";
10+
import { sessionActivity } from "./lib/stores/sessionActivity.svelte.js";
911
import CommandPalette from "./lib/components/command-palette/CommandPalette.svelte";
1012
import AboutModal from "./lib/components/modals/AboutModal.svelte";
1113
import ShortcutsModal from "./lib/components/modals/ShortcutsModal.svelte";
@@ -84,9 +86,15 @@
8486
messages.reload();
8587
sessions.refreshActiveSession();
8688
sessions.loadChildSessions(id);
89+
if (ui.activityMinimapOpen) {
90+
sessionActivity.reload(id);
91+
} else {
92+
sessionActivity.invalidate();
93+
}
8794
});
8895
pins.loadForSession(id);
8996
} else {
97+
sessionActivity.clear();
9098
messages.clear();
9199
sessions.childSessions = new Map();
92100
sync.unwatchSession();
@@ -380,6 +388,11 @@
380388
session={session}
381389
onBack={() => sessions.deselectSession()}
382390
/>
391+
{#if ui.activityMinimapOpen && sessions.activeSessionId}
392+
<ActivityMinimap
393+
sessionId={sessions.activeSessionId}
394+
/>
395+
{/if}
383396
<MessageList bind:this={messageListRef} />
384397
{:else}
385398
<AnalyticsPage />

frontend/src/lib/api/client.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import type {
22
SessionPage,
33
Session,
44
MessagesResponse,
5-
MinimapResponse,
65
SearchResponse,
76
ProjectsResponse,
87
MachinesResponse,
@@ -34,6 +33,7 @@ import type {
3433
PinsResponse,
3534
TrashResponse,
3635
} from "./types.js";
36+
import type { SessionActivityResponse } from "./types/session-activity.js";
3737

3838
const SERVER_URL_KEY = "agentsview-server-url";
3939
const AUTH_TOKEN_KEY = "agentsview-auth-token";
@@ -171,6 +171,14 @@ export function getChildSessions(
171171
return fetchJSON(`/sessions/${id}/children`, init);
172172
}
173173

174+
export function getSessionActivity(
175+
sessionId: string,
176+
): Promise<SessionActivityResponse> {
177+
return fetchJSON(
178+
`/sessions/${sessionId}/activity`,
179+
);
180+
}
181+
174182
/* Messages */
175183

176184
export interface GetMessagesParams {
@@ -190,20 +198,6 @@ export function getMessages(
190198
);
191199
}
192200

193-
export interface GetMinimapParams {
194-
from?: number;
195-
max?: number;
196-
}
197-
198-
export function getMinimap(
199-
sessionId: string,
200-
params: GetMinimapParams = {},
201-
): Promise<MinimapResponse> {
202-
return fetchJSON(
203-
`/sessions/${sessionId}/minimap${buildQuery({ ...params })}`,
204-
);
205-
}
206-
207201
/* Search */
208202

209203
export function search(

frontend/src/lib/api/types/core.ts

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,6 @@ export interface Message {
7272
is_system: boolean;
7373
}
7474

75-
/** Matches Go MinimapEntry struct */
76-
export type MinimapEntry = Pick<
77-
Message,
78-
"ordinal" | "role" | "content_length" | "has_thinking" | "has_tool_use"
79-
>;
80-
8175
/** Matches Go SearchResult struct in internal/db/search.go */
8276
export interface SearchResult {
8377
session_id: string;
@@ -104,11 +98,6 @@ export interface MessagesResponse {
10498
count: number;
10599
}
106100

107-
export interface MinimapResponse {
108-
entries: MinimapEntry[];
109-
count: number;
110-
}
111-
112101
export interface SearchResponse {
113102
query: string;
114103
results: SearchResult[];

frontend/src/lib/api/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export type * from "./sync.js";
33
export type * from "./analytics.js";
44
export type * from "./github.js";
55
export type * from "./insights.js";
6+
export type * from "./session-activity.js";
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export interface SessionActivityBucket {
2+
start_time: string;
3+
end_time: string;
4+
user_count: number;
5+
assistant_count: number;
6+
first_ordinal: number | null;
7+
}
8+
9+
export interface SessionActivityResponse {
10+
buckets: SessionActivityBucket[];
11+
interval_seconds: number;
12+
total_messages: number;
13+
}

0 commit comments

Comments
 (0)