Skip to content

feat(home): unified session feed with message preview#409

Open
dimakis wants to merge 2 commits into
mainfrom
session/2026-06-25-22a9ab867321
Open

feat(home): unified session feed with message preview#409
dimakis wants to merge 2 commits into
mainfrom
session/2026-06-25-22a9ab867321

Conversation

@dimakis

@dimakis dimakis commented Jun 27, 2026

Copy link
Copy Markdown
Owner

Summary

  • Replaces separate Active Sessions + What's Next sections with a single unified SessionFeed component
  • Adds chip-based filtering: All / Needs me (default) / In progress / Done
  • Captures last assistant message preview (300 chars) at turn-end, stored in EventStore, served via SSE + REST
  • Sorted oldest-first within a 48h working batch so longest-waiting sessions surface first
  • 5-item scrollable viewport with 2-line preview clamp per card

Changes

Backend:

  • event-store.ts: last_assistant_preview column + migration + prepared statement
  • query-loop.ts: extract preview from snapshot blocks at turn-end
  • session-overview.ts: preview cache (avoids per-broadcast DB lookups), expose in activity output
  • app.ts: include preview in REST /api/sessions response

Frontend:

  • useSessionFeed.ts (new): working-batch filter, chip filters, oldest-first sort, localStorage persistence
  • SessionFeed.tsx (new): filter chips + feed cards with status icon, repo tag, title, preview, meta
  • SessionList.tsx: swap old components for unified <SessionFeed />
  • global.css: remove old overview/attention CSS (~210 lines), add feed styles

Removed:

  • SessionOverview.tsx, AttentionFeed.tsx, useAttentionFeed.ts + their tests

Test plan

  • 11 new tests for useSessionFeed hook (working batch, filters, sort, persistence)
  • Visual verification on phone: chips render, cards show preview, scroll works
  • Verify preview appears after assistant sends a message
  • Verify "Needs me" default shows awaiting-reply + waiting + uncommitted sessions
  • Verify done sessions age out after 48h

🤖 Generated with Claude Code

Replace separate Active Sessions + What's Next sections with a single
SessionFeed component. Adds chip-based filtering (All / Needs me /
In progress / Done), message preview capture from last assistant turn,
and oldest-first sort within the working batch (48h window).

Backend: preview column in EventStore, capture at turn-end in query-loop,
in-memory cache in SessionOverviewEmitter, REST + SSE exposure.

Frontend: useSessionFeed hook with working-batch logic, SessionFeed
component with filter chips and 5-item scrollable viewport.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 6 issue(s) (3 warning).

server/query-loop.ts

Solid refactor that simplifies the home feed, but the preview cache isn't wired up for real-time updates (query-loop doesn't call emitter.updatePreview), and the removal of Telos/ATB attention items from the home page is a notable behavioral regression that should be intentional.

  • 🟡 bugs (L569): Preview is persisted to the EventStore but SessionOverviewEmitter.updatePreview() is never called, so the in-memory preview cache stays stale. Live SSE broadcasts via getCachedPreview() will miss the latest preview until the cache is opportunistically populated by getCachedSpeaker() on the next tick. The updatePreview() method exists on the emitter but is unreachable from query-loop (no reference passed in). Preview will eventually appear after a page refresh or cache miss, but real-time updates will lag. [fixable]
  • 🔵 bugs (L563): previewText.slice(-300) takes the last 300 characters. For a user-facing preview, taking the first 300 characters (.slice(0, 300)) would be more useful — the beginning of a response typically has the most context (greeting, summary, key decision). The trailing 300 chars might be mid-sentence code or boilerplate. [fixable]

frontend/src/pages/SessionList.tsx

Solid refactor that simplifies the home feed, but the preview cache isn't wired up for real-time updates (query-loop doesn't call emitter.updatePreview), and the removal of Telos/ATB attention items from the home page is a notable behavioral regression that should be intentional.

  • 🟡 regressions (L312): Telos (todo) and ATB (task board) attention items are no longer surfaced on the home page. The deleted useAttentionFeed aggregated items from three sources (telos, ATB, sessions); the replacement useSessionFeed only shows sessions. Starred/urgent todos and pending_review/blocked/failed tasks previously appeared as tier-1 attention items on the home screen and now only appear in the CommandCenter sidebar (visible only inside a chat session). This may be intentional but is a significant behavioral change worth calling out.

frontend/src/components/SessionFeed.tsx

Solid refactor that simplifies the home feed, but the preview cache isn't wired up for real-time updates (query-loop doesn't call emitter.updatePreview), and the removal of Telos/ATB attention items from the home page is a notable behavioral regression that should be intentional.

  • 🟡 missing_tests: No component test for SessionFeed.tsx. The deleted AttentionFeed.tsx and SessionOverview.tsx both had comprehensive component tests (rendering, navigation, state transitions). The replacement has hook-level tests but no component-level tests for rendering, filter chip interaction, card tap navigation, empty state display, or the counts.all === 0 early return. [fixable]

packages/protocol/src/event-store.ts

Solid refactor that simplifies the home feed, but the preview cache isn't wired up for real-time updates (query-loop doesn't call emitter.updatePreview), and the removal of Telos/ATB attention items from the home page is a notable behavioral regression that should be intentional.

  • 🔵 missing_tests (L589): No test coverage for the new updateLastAssistantPreview() method, the migratePreviewColumn() migration, or the lastAssistantPreview field in rowToSession(). The existing EventStore test file has no assertions for preview persistence or retrieval. [fixable]

frontend/src/hooks/useSessionFeed.ts

Solid refactor that simplifies the home feed, but the preview cache isn't wired up for real-time updates (query-loop doesn't call emitter.updatePreview), and the removal of Telos/ATB attention items from the home page is a notable behavioral regression that should be intentional.

  • 🔵 unsafe_assumptions (L96): workingBatch uses Date.now() inside useMemo but only recomputes when activities changes. If no new SSE events arrive, expired done sessions (past the 48h window) won't be evicted from the batch until the next activity update. This is minor — SSE events are frequent — but could show stale sessions on a quiet day.

Comment thread server/query-loop.ts Outdated
if (store && resolvedSessionId) {
store.recordUsage(resolvedSessionId, usageData);
if (lastPreview) {
store.updateLastAssistantPreview(resolvedSessionId, lastPreview);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bugs: Preview is persisted to the EventStore but SessionOverviewEmitter.updatePreview() is never called, so the in-memory preview cache stays stale. Live SSE broadcasts via getCachedPreview() will miss the latest preview until the cache is opportunistically populated by getCachedSpeaker() on the next tick. The updatePreview() method exists on the emitter but is unreachable from query-loop (no reference passed in). Preview will eventually appear after a page refresh or cache miss, but real-time updates will lag. [fixable]

Comment thread server/query-loop.ts Outdated
.map((b) => b.content)
.join('\n')
.trim();
const lastPreview = previewText.length > 300 ? previewText.slice(-300) : previewText;

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 bugs: previewText.slice(-300) takes the last 300 characters. For a user-facing preview, taking the first 300 characters (.slice(0, 300)) would be more useful — the beginning of a response typically has the most context (greeting, summary, key decision). The trailing 300 chars might be mid-sentence code or boilerplate. [fixable]

@@ -311,9 +310,7 @@ export function SessionList() {

<BriefingCard />

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 regressions: Telos (todo) and ATB (task board) attention items are no longer surfaced on the home page. The deleted useAttentionFeed aggregated items from three sources (telos, ATB, sessions); the replacement useSessionFeed only shows sessions. Starred/urgent todos and pending_review/blocked/failed tasks previously appeared as tier-1 attention items on the home screen and now only appear in the CommandCenter sidebar (visible only inside a chat session). This may be intentional but is a significant behavioral change worth calling out.

this.stmts.updateLastSpeaker.run(speaker, sessionId);
}

updateLastAssistantPreview(sessionId: string, preview: string): void {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 missing_tests: No test coverage for the new updateLastAssistantPreview() method, the migratePreviewColumn() migration, or the lastAssistantPreview field in rowToSession(). The existing EventStore test file has no assertions for preview persistence or retrieval. [fixable]

}, []);

// Working batch: all sessions in the user's current active set
const workingBatch = useMemo(() => {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: workingBatch uses Date.now() inside useMemo but only recomputes when activities changes. If no new SSE events arrive, expired done sessions (past the 48h window) won't be evicted from the batch until the next activity update. This is minor — SSE events are frequent — but could show stale sessions on a quiet day.

- Move preview DB write before forceFlush so turn_end handler can
  read it and populate the in-memory cache for real-time SSE
- Wire overviewEmitter.updatePreview() in the turn_end handler
- Add EventStore preview round-trip tests (3 tests)
- Add SessionFeed component tests (8 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@dimakis

dimakis commented Jun 27, 2026

Copy link
Copy Markdown
Owner Author

Review response

Fixed in 7264748:

  1. Preview cache wiring (query-loop.ts) — moved updateLastAssistantPreview() before forceFlushPendingMessage() so the preview is in the DB when turn_end fires. Added overviewEmitter.updatePreview() call in the turn_end handler in index.ts. Real-time SSE broadcasts now include the preview immediately.

  2. SessionFeed component test — added 8 tests covering: early return when empty, chip rendering with counts, filter interaction, card tap navigation, preview rendering, graceful degradation without preview, empty state text, repo tag.

  3. EventStore preview tests — added 3 tests: store/retrieve round-trip, overwrite on update, null when unset.

Design decisions (intentional, no change):

  1. slice(-300) vs slice(0, 300) — tail is intentional. The last 300 chars of an assistant message contain the actionable part (conclusion, question, next steps). The first 300 chars are typically "I'll help you with..." preamble. For a "what does this session need from me?" feed, the tail is more useful.

  2. Telos/ATB exclusion — explicit design decision discussed before implementation. These are different entity types with different navigation targets. Telos has the tab badge, Task Board has its own surface. The feed is sessions-only by design.

  3. Date.now() in useMemo — acknowledged as minor. SSE events are frequent enough that stale done sessions are evicted promptly. A quiet-day edge case isn't worth adding a timer for.

@dimakis dimakis left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Centaur Review

Found 7 issue(s) (2 warning).

server/session-overview.ts

Solid replacement of two overlapping home-screen sections with a cleaner unified feed. Main concerns: previewCache not cleared in destroy(), loss of Telos/ATB attention items with no obvious replacement, and paused-session visibility change.

  • 🟡 bugs: The new previewCache Map is not cleared in destroy(). The diff adds previewCache (line 82 in diff) and cleans it in forget() (line 140), but destroy() (lines 457-473, unchanged) only clears lastEventTimes, uncommittedCache, and speakerCache. Add this.previewCache.clear() alongside the other cache clears in destroy(). [fixable]

frontend/src/hooks/useSessionFeed.ts

Solid replacement of two overlapping home-screen sections with a cleaner unified feed. Main concerns: previewCache not cleared in destroy(), loss of Telos/ATB attention items with no obvious replacement, and paused-session visibility change.

  • 🟡 regressions: The old AttentionFeed aggregated items from three sources — Telos todos, ATB tasks, and sessions — with tiered priority sorting. The new SessionFeed only shows sessions. Telos starred/urgent items and ATB pending_review/blocked/failed tasks no longer surface on the home screen. If this is intentional, consider whether those signals need a new home or if removing them is the desired UX change.
  • 🔵 regressions (L33): The old SessionOverview displayed paused sessions (it only excluded idle and init). The new isInWorkingBatch() excludes paused sessions — they won't appear in any filter. The STATE_CONFIG in SessionFeed.tsx defines a paused entry that can never be reached. If paused sessions should be visible, add a.state === 'paused' to isInWorkingBatch(). [fixable]
  • 🔵 unsafe_assumptions (L86): The SSE callback casts data as SessionActivity[] without runtime validation. The old useAttentionFeed filtered with typeof d.sessionId === 'string' && typeof d.state === 'string'. While this matches the pattern used by useSessionOverview and useSessionList, the lack of validation means a malformed SSE payload could propagate undefined fields into the filter/sort logic. Low risk since the server controls the data, but worth noting. [fixable]

frontend/src/components/SessionFeed.tsx

Solid replacement of two overlapping home-screen sections with a cleaner unified feed. Main concerns: previewCache not cleared in destroy(), loss of Telos/ATB attention items with no obvious replacement, and paused-session visibility change.

  • 🔵 regressions (L63): The old SessionOverview and AttentionFeed called selectionChanged() (haptic feedback) on card tap. The new SessionFeed.handleTap navigates without haptics. If this is a mobile-first app, users may notice the missing tactile feedback on session card taps. [fixable]
  • 🔵 style (L13): Dead config entry: STATE_CONFIG includes paused and init entries, but isInWorkingBatch() in the hook excludes both states from ever reaching the feed. These entries can never be rendered. Consider removing them or adding a comment explaining they're kept for completeness. [fixable]

frontend/src/hooks/__tests__/useSessionFeed.test.ts

Solid replacement of two overlapping home-screen sections with a cleaner unified feed. Main concerns: previewCache not cleared in destroy(), loss of Telos/ATB attention items with no obvious replacement, and paused-session visibility change.

  • 🔵 missing_tests: No test covers paused session exclusion from the working batch. The test at line 62 verifies idle and init are excluded but doesn't assert that paused is also filtered out. Add a paused entry to that test's activity list to prevent accidental inclusion in the future. [fixable]

if (a.state === 'working' || a.state === 'waiting') return true;
// Awaiting reply or uncommitted work — always relevant
if (a.awaitingReply || a.uncommittedWork) return true;
// Done sessions within the time window

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 regressions: The old SessionOverview displayed paused sessions (it only excluded idle and init). The new isInWorkingBatch() excludes paused sessions — they won't appear in any filter. The STATE_CONFIG in SessionFeed.tsx defines a paused entry that can never be reached. If paused sessions should be visible, add a.state === 'paused' to isInWorkingBatch(). [fixable]


useEffect(() => {
const unsubActivity = eventBus.on('session_activity', (data) => {
setActivities(data as SessionActivity[]);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 unsafe_assumptions: The SSE callback casts data as SessionActivity[] without runtime validation. The old useAttentionFeed filtered with typeof d.sessionId === 'string' && typeof d.state === 'string'. While this matches the pattern used by useSessionOverview and useSessionList, the lack of validation means a malformed SSE payload could propagate undefined fields into the filter/sort logic. Low risk since the server controls the data, but worth noting. [fixable]

<button
className="feed-card"
onClick={() => onTap(activity.sessionId)}
style={{ '--card-accent': iconColor } as React.CSSProperties}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 regressions: The old SessionOverview and AttentionFeed called selectionChanged() (haptic feedback) on card tap. The new SessionFeed.handleTap navigates without haptics. If this is a mobile-first app, users may notice the missing tactile feedback on session card taps. [fixable]

waiting: { icon: '⚠', color: '#ff6d6d', label: 'waiting' },
done: { icon: '✓', color: '#4ade80', label: 'done' },
idle: { icon: '○', color: 'var(--text-dim)', label: 'idle' },
init: { icon: '○', color: 'var(--text-dim)', label: 'starting' },

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 style: Dead config entry: STATE_CONFIG includes paused and init entries, but isInWorkingBatch() in the hook excludes both states from ever reaching the feed. These entries can never be rendered. Consider removing them or adding a comment explaining they're kept for completeness. [fixable]

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