Skip to content

feat: add session activity minimap with click-to-navigate#248

Merged
wesm merged 16 commits intomainfrom
feat/session-minimap
Mar 28, 2026
Merged

feat: add session activity minimap with click-to-navigate#248
wesm merged 16 commits intomainfrom
feat/session-minimap

Conversation

@wesm
Copy link
Copy Markdown
Owner

@wesm wesm commented Mar 28, 2026

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
image

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

wesm and others added 4 commits March 28, 2026 04:23
Add a togglable horizontal bar chart to the session detail view
showing message activity intensity over time. Clicking a bar scrolls
the message list to that point in the session.

Backend:
- New GET /api/v1/sessions/{id}/activity endpoint with adaptive
  time-bucketed message counts (SQLite and PostgreSQL)
- Sub-second timestamp precision via julianday()/floor(EXTRACT)
- System and injected message filtering via SystemPrefixSQL
- Remove unused /minimap endpoint and all supporting code

Frontend:
- ActivityMinimap.svelte custom SVG bar chart component
- Session activity store with load versioning, stale response
  rejection, and loaded lifecycle tracking
- Toggle button in SessionBreadcrumb with localStorage persistence
- Scroll-synced active bucket indicator via firstVisibleTimestamp
- Keyboard/ARIA accessibility, error/retry, no-data states
- Fix breadcrumb flicker on SSE refresh (sessionDir effect)
- Fix mainModel badge flicker during reload (stable model caching)

Tests:
- Backend: interval snapping, bucketing, edge cases, fractional
  timestamps
- Frontend: bucket index derivation, store lifecycle, stale response
  rejection, no-retry on error
- E2E: toggle, bar rendering, click-to-scroll, indicator movement
  on reopen
Add julianday() IS NOT NULL filter to SQLite timestamp filter so
malformed timestamps that produce NULL from julianday() are excluded
instead of causing a scan error. Clear firstVisibleTimestamp to null
when no visible item has a timestamp so the minimap indicator doesn't
stick to an old bucket.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add Docker-based PG tests mirroring the SQLite activity test suite:
basic bucketing with system message exclusion, empty gaps, no
messages, null timestamps, single message. Refactor fractional
timestamp test to use shared seedActivitySession helper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…aring

- SQLite: malformed timestamp string alongside valid rows is excluded
  from buckets without causing a scan error
- PostgreSQL: prefix-detected injected user message (is_system=false
  but content starts with system prefix) excluded from buckets
- Frontend: active indicator clears when firstVisibleTimestamp is
  set to null

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (c391088)

Verdict: Changes are not ready to merge; 2 High and 3 Medium issues need to be addressed.

High

  • messages.svelte.ts
    mainModel mutates _stableMainModel from inside $derived.by(...). In Svelte runes, derived computations must stay side-effect free; mutating $state during derived evaluation can trigger state_unsafe_mutation or otherwise unstable reactive behavior when mainModel is read.
    Fix: Keep mainModel pure and move the “last stable model” update into an $effect or an explicit state update outside the derived.

  • activity.go and activity.go
    SnapInterval hard-caps the bucket interval at 7200 seconds. For sessions with extreme timestamp gaps, that can still produce hundreds of thousands of empty buckets, leading to huge JSON payloads and frontend rendering of an excessive number of DOM nodes.
    Fix: Remove the hard cap and scale the interval dynamically so total bucket count stays bounded.

Medium

  • activity.go and activity.go
    The activity queries snap the session anchor down to minTimestamp’s whole second before computing duration and bucket indexes. That changes the true max-min span and can mis-bucket messages near fractional-second boundaries.
    Fix: Use the exact minimum timestamp for duration and bucket math; only round for display if needed.

  • ActivityMinimap.svelte
    sessionActivity.activeBucketIndex is evaluated inside the {#each} loop. Since findActiveBucketIndex is linear and does repeated date parsing, this turns rendering into an O(N^2) bottleneck for large charts.
    Fix: Compute the active index once before the loop and compare against that value inside the iteration; also avoid repeated Date construction in the lookup.

  • ActivityMinimap.svelte and ActivityMinimap.svelte
    The minimap collapses user_count and assistant_count into a single bar, so sessions with very different user/assistant mixes render identically. That drops the per-role visualization exposed by the new API.
    Fix: Render stacked segments for user_count and assistant_count with distinct fills, while keeping scaling based on the combined total.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

wesm and others added 2 commits March 28, 2026 09:42
- Move _stableMainModel out of $derived.by to avoid side-effect
  mutation; update it explicitly in loadSession/fullReload finally
  blocks
- Scale SnapInterval dynamically for extreme durations so bucket
  count stays bounded (maxBuckets=50)
- Compute duration from exact float timestamps instead of truncated
  anchor to preserve sub-second precision
- Cache activeBucketIndex in a $derived before the render loop to
  avoid O(N^2) repeated computation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Assert bucket count in PG prefix-injected test to catch range
leakage (excluded message extending min/max). Add comment to
indicator clearing test explaining the store/E2E test split.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (1f9633e)

Verdict: Changes are mostly sound, but there are 2 medium-severity functional regressions that should be addressed before merge.

Medium

  1. Stale activity data after reopening minimap
    Location: frontend/src/App.svelte, frontend/src/lib/stores/sessionActivity.svelte.ts
    Problem: Activity data is only refreshed from SSE while the minimap is open, and the store’s load() logic short-circuits once data for the session is marked loaded. If new messages arrive while the minimap is hidden, reopening it reuses stale cached buckets instead of fetching fresh activity, so the minimap can remain behind the message list indefinitely.
    Fix: Invalidate the activity cache when session updates arrive while hidden, or force a reload when the minimap is reopened.

  2. Minimap no longer shows user vs. assistant breakdown
    Location: frontend/src/lib/components/content/ActivityMinimap.svelte, frontend/src/lib/components/content/ActivityMinimap.svelte
    Problem: The minimap renders a single combined bar for total message count per bucket, collapsing user_count and assistant_count into one value. This drops the stacked breakdown the API now provides, so the UI does not actually visualize the intended split.
    Fix: Render separate stacked segments for user_count and assistant_count with distinct styling and independently computed heights.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

When the minimap is closed and an SSE session update arrives, mark
the activity cache as stale via invalidate(). The next time the
minimap opens, load() sees the cache is invalid and refetches
instead of showing stale buckets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (e74e0c1)

Verdict: 2 medium issues found; no high or critical findings.

Medium

  • Stale mainModel can leak across session switches
    Location: frontend/src/lib/stores/messages.svelte.ts
    mainModel now falls back to _stableMainModel on every load, not just same-session reloads. Because _stableMainModel is only cleared in clear(), switching to a different session can briefly display the previous session’s model until the new messages finish loading.

  • Minimap no longer visualizes user vs assistant breakdown
    Location: frontend/src/lib/components/content/ActivityMinimap.svelte
    The minimap combines user_count and assistant_count into a single total bar. That drops the role-level breakdown exposed by the new API, so the chart no longer communicates user-versus-assistant activity even though the tooltip still does.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

SnapInterval bucket count is floor(duration/interval)+1, so use
maxBuckets-1 as the divisor when scaling. Also increment loadVersion
in invalidate() so pending responses are discarded when SSE fires
while the minimap is hidden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (9f76dd3)

Summary verdict: Changes look mostly sound, but there is one medium-severity correctness issue in the new minimap rendering.

Medium

  • frontend/src/lib/components/content/ActivityMinimap.svelte:33
    The minimap collapses user_count and assistant_count into a single total-height blue bar, so buckets with very different user/assistant mixes render identically. That makes the visualization misleading and drops the main distinction returned by the new API.
    Suggested fix: Render separate stacked segments for user_count and assistant_count with distinct colors, and keep the tooltip/legend aligned with those segments.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

Start a load, call invalidate(), resolve the stale response, then
verify load() issues a fresh request instead of short-circuiting
on the discarded cache.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (ae6af4f)

Verdict: Changes are generally solid, but there are 3 medium-severity UX regressions that should be addressed before merge.

Medium

  1. Minimap loses the current-position indicator after live refresh
    Location: frontend/src/App.svelte:89, frontend/src/lib/stores/sessionActivity.svelte.ts:57
    Problem: Live SSE refreshes call sessionActivity.reload(id), and load() clears firstVisibleTimestamp. The minimap’s active bucket depends on that field, but it is only republished on scroll or when reopening the minimap. After a refresh without user scroll, the “you are here” indicator disappears and stays gone until the user scrolls again.
    Fix: Preserve firstVisibleTimestamp on same-session reloads, or republish the current visible timestamp after the reload completes.

  2. Minimap visibly flickers on every same-session SSE update
    Location: frontend/src/lib/stores/sessionActivity.svelte.ts:92, frontend/src/lib/components/content/ActivityMinimap.svelte:161
    Problem: reload() resets cached state and routes back through load(), which clears buckets and sets loading = true. That makes the minimap disappear and flash its loading state on each SSE update, causing obvious visual flicker.
    Fix: For same-session reloads, refresh in the background without clearing existing buckets or toggling the loading state.

  3. Tooltip is clipped by the scroll container
    Location: frontend/src/lib/components/content/ActivityMinimap.svelte:231, frontend/src/lib/components/content/ActivityMinimap.svelte:318
    Problem: The tooltip is rendered inside .minimap-chart, which has horizontal overflow enabled. Because the tooltip is positioned above the bars with a negative top, it can be clipped by the scroll container and become partially or fully hidden.
    Fix: Render the tooltip outside the scrolling chart container, make the outer minimap container the positioning context, and compute tooltip coordinates relative to that container.

Security review did not identify any security-impacting issues in this diff.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

wesm and others added 2 commits March 28, 2026 10:41
getVirtualItems() includes overscan rows above the viewport. Filter
by scrollOffset so the minimap indicator tracks the first actually
visible item, not an overscanned one above the fold.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use vi.end <= scrollTop to skip only rows fully above the viewport.
The previous vi.start < scrollTop check also excluded the partially
visible top row, shifting the indicator one row too far down.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (375f9a5)

Overall looks solid, but there are 2 medium-severity issues to address before merge.

Medium

  • frontend/src/lib/components/content/MessageList.svelte#L131: publishVisibleTimestamp() publishes the first visible item's timestamp without excluding system/prefix-injected messages, while backend activity buckets exclude those messages. If one of those rows is at the top of the viewport, the minimap indicator can point to an empty bucket or disappear even though visible content is on screen.

    • Fix: skip timestamps for messages excluded by isSystemMessage(...) so frontend visibility matches GetSessionActivity().
  • frontend/src/lib/components/content/ActivityMinimap.svelte#L18: the mount effect calls sessionActivity.load(sessionId) directly inside $effect. Because load() synchronously reads reactive store fields like cachedSessionId, buckets, and loaded, those reads can become effect dependencies. A later reload() or invalidate() may retrigger the effect and start a second fetch while the first reload is still in flight.

    • Fix: make the effect depend only on sessionId by wrapping the call in untrack(...), or add deduping for concurrent loads inside the store.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (f6d4b61)

Verdict: Changes are mostly sound, but there are 2 medium-severity UI state regressions that should be addressed before merge.

Medium

  • Stale model breadcrumb during session switches

    • Location: frontend/src/lib/stores/messages.svelte.ts:22
    • Problem: mainModel now reuses _stableMainModel for any in-flight load, not just reloads of the same session. If a user switches to a different session, the breadcrumb can briefly display the previous session’s model until the new messages finish loading.
    • Fix: Reset _stableMainModel when loading a different sessionId, or associate the stable value with the session that produced it and only reuse it for reloads of that same session.
  • Minimap active indicator can go stale or disappear

    • Location: frontend/src/lib/components/content/MessageList.svelte:130
    • Problem: The minimap’s active bucket is only recomputed on scroll and when activityMinimapOpen toggles. If the minimap remains open while the active session changes or the list rerenders without a scroll event, sessionActivity.load() clears firstVisibleTimestamp and the indicator remains stale or missing until the user scrolls.
    • Fix: Recompute publishVisibleTimestamp() whenever the visible virtual items or session content changes while the minimap is open, not only on scroll or toggle.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

…content change

Explicitly clear _stableMainModel at the start of loadSession so
it cannot carry over from the previous session. Track
messages.messages.length in the minimap $effect so the active
indicator recomputes after SSE content changes, not only on scroll
or minimap toggle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (0f881f5)

Verdict: Changes are close, but there are 3 medium-severity issues that should be addressed before merge.

Medium

  • Breadcrumb state can get stuck after fast session switches

    • Location: frontend/src/lib/components/layout/SessionBreadcrumb.svelte:49
    • Problem: resolvedSessionDirId is only cleared when session becomes null. A switch sequence like A -> B -> A before the B lookup completes can cause the effect to short-circuit for A while sessionDir is still null, leaving the reopened session’s breadcrumb path missing until refresh.
    • Fix: Reset resolvedSessionDirId whenever the active session ID changes, or use a per-session cache keyed by session ID.
  • Session switch triggers duplicate activity loads and can clear the active indicator

    • Location: frontend/src/App.svelte:89, frontend/src/lib/components/content/ActivityMinimap.svelte:17, frontend/src/lib/stores/sessionActivity.svelte.ts:53
    • Problem: With the minimap open, selecting a session triggers one load from ActivityMinimap’s $effect and another forced sessionActivity.reload(id) after messages.loadSession(id) resolves. The second reload resets firstVisibleTimestamp, which can clear the active-bar indicator until the user scrolls, and it also adds an unnecessary API request on each session switch.
    • Fix: Make one place own the initial fetch. Removing sessionActivity.reload(id) from App.svelte is the simplest fix; if a post-load refresh is required, avoid clearing firstVisibleTimestamp during that refresh.
  • Tooltip is likely clipped by the minimap scroll container

    • Location: frontend/src/lib/components/content/ActivityMinimap.svelte:87, frontend/src/lib/components/content/ActivityMinimap.svelte:243-251, frontend/src/lib/components/content/ActivityMinimap.svelte:296
    • Problem: The tooltip is rendered inside .minimap-chart, which uses overflow-x: auto; this effectively makes overflow-y non-visible as well. Since the tooltip is positioned above the bars, it can be clipped by the scroll container.
    • Fix: Render the tooltip outside .minimap-chart and position it relative to the parent .activity-minimap container instead.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

Render tooltip in .activity-minimap instead of .minimap-chart to
prevent clipping by overflow-x:auto. Position relative to the
parent container. Add regression test verifying mainModel doesn't
carry over when switching between sessions with different models.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (52754e7)

Verdict: 2 medium-severity regressions should be addressed before merge; no high or critical issues were identified.

Medium

  • frontend/src/lib/components/content/ActivityMinimap.svelte:17, frontend/src/lib/stores/sessionActivity.svelte.ts:40, frontend/src/lib/stores/sessionActivity.svelte.ts:46
    The minimap fetch path can issue duplicate /sessions/:id/activity requests for the same refresh cycle. ActivityMinimap triggers sessionActivity.load(sessionId) from a reactive $effect, and load() reads reactive store state synchronously. That makes the effect depend on store fields that are also mutated by reload(id), so SSE-driven reloads or session switches can retrigger the effect and launch a second identical request.
    Suggested fix: Make the effect depend only on sessionId (for example via untrack or by moving fetch initiation out of the component), and/or add in-flight request deduplication in SessionActivityStore.

  • internal/postgres/activity.go:56
    maxTS.Sub(*minTS) uses time.Duration, which can clamp around 292 years for malformed extreme timestamps. That can understate durationSec, cause SnapInterval to choose an interval that is too small for the true range, and generate excessive buckets despite the intended maxBuckets cap.
    Suggested fix: Compute the range directly in seconds from Unix timestamps instead of relying on time.Duration for the full span.


Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

wesm and others added 2 commits March 28, 2026 12:04
Add position:relative to .activity-minimap so it becomes the
containing block for the absolutely positioned tooltip. Drop
scrollLeft from tooltip x calculation since the tooltip is no
longer inside the scrolling element. Remove unused chartRef.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Switch tooltip to position:fixed with viewport coordinates from
getBoundingClientRect(). This avoids all containing-block and
padding-box offset issues. Remove position:relative from the
container since it's no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@roborev-ci
Copy link
Copy Markdown

roborev-ci bot commented Mar 28, 2026

roborev: Combined Review (4685520)

Verdict: Changes are close, but there is 1 High and 1 Medium issue that should be addressed before merge.

High

  • internal/server/messages.go
    • The diff removes the last use of dbpkg.SampleMinimap but leaves the dbpkg import in place. That produces an unused import and breaks the Go build.
    • Fix: remove the stale dbpkg import.

Medium

  • frontend/src/lib/components/content/MessageList.svelte
    • Near the $effect that calls publishVisibleTimestamp(), the effect only depends on messages.messages.length. If SSE reloads the visible messages or changes their timestamps without changing the count, firstVisibleTimestamp is not recomputed, so the minimap highlight can stay stale until the next scroll event.
    • Fix: depend on the message array identity or an explicit reload/version signal instead of only length, and add a regression test for same-length updates.

Synthesized from 3 reviews (agents: codex, gemini | types: default, security)

@wesm
Copy link
Copy Markdown
Owner Author

wesm commented Mar 28, 2026

This is good to go

@wesm wesm merged commit 30063de into main Mar 28, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant