Skip to content

feat(web): session list status indicators (attention + scheduled)#699

Open
heavygee wants to merge 10 commits into
tiann:mainfrom
heavygee:feat/session-list-attention
Open

feat(web): session list status indicators (attention + scheduled)#699
heavygee wants to merge 10 commits into
tiann:mainfrom
heavygee:feat/session-list-attention

Conversation

@heavygee
Copy link
Copy Markdown
Contributor

@heavygee heavygee commented May 26, 2026

Summary

Adds session-list status indicators so background sessions show why they stopped or what is pending — not just spinners or generic pending counts. Builds on merged #694 (SSE freshness).

  • Extend SessionSummary with pendingRequestKinds, backgroundTaskCount, and futureScheduledMessageCount (shared/hub)
  • Settings → Display → Session list status: Standard (default) vs Detailed
  • Detailed mode attention: permission (amber), input (blue), background tasks, unread activity
  • Detailed mode scheduled: composer clock icon when future scheduled messages are pending
  • Track lastSeenAt per session in localStorage; advance watermark when opening/updating selected session
  • Replace generic pending N text when a kind-specific indicator is shown (Detailed)

Related: #270 (broader unread/session sidebar enhancements)

Test plan

  • bun run typecheck
  • bun run test (shared + hub + web)
  • Manual: open session A; trigger permission prompt or finish work in session B; confirm B shows amber/unread dot without selecting B; opening B clears unread
  • Manual (Detailed): schedule a message in session B; confirm clock icon on B in sidebar; cancel or send clears icon

Issues

Fixes #698

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Selected sessions can be marked unread after the user has already read them — markSessionSeen only runs when selectedSessionId changes, so a selected session's seen watermark is not advanced when SSE/refetch updates its updatedAt while the chat is open. When the user later navigates away, classifySessionAttention compares the newer updatedAt to the stale open-time lastSeenAt and shows a false New activity dot. Evidence: web/src/router.tsx:153 and web/src/lib/sessionAttention.ts:29.
    Suggested fix:
    const selectedSession = useMemo(
        () => sessions.find(session => session.id === selectedSessionId) ?? null,
        [sessions, selectedSessionId]
    )
    
    useEffect(() => {
        if (selectedSessionId) {
            markSessionSeen(selectedSessionId, Math.max(Date.now(), selectedSession?.updatedAt ?? 0))
        }
    }, [selectedSessionId, selectedSession?.updatedAt])

Summary

  • Review mode: initial
  • Found one issue in the unread indicator state flow: the selected session is cleared on open, but not kept cleared as new activity arrives while it remains selected.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/router.tsx Outdated
heavygee added a commit to heavygee/hapi that referenced this pull request May 26, 2026
Keep the unread watermark in sync with live session activity so
navigating away from an open session does not show a false unread dot.

Addresses review on tiann#699

Co-authored-by: Cursor <cursoragent@cursor.com>
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Inactive sessions can never show unread activity — classifySessionAttention returns before checking updatedAt whenever summary.active is false, so a background session that finishes/stops after the user last saw it loses the new-activity dot exactly when the sidebar needs to explain why it stopped. Evidence: web/src/lib/sessionAttention.ts:13.
    Suggested fix:

    if (options.selected || summary.thinking) {
        return null
    }
    
    if (summary.active && summary.backgroundTaskCount > 0) {
        return { kind: 'background' }
    }
    
    if (summary.updatedAt > options.lastSeenAt) {
        return { kind: 'unread' }
    }
  • [Major] Browser clock skew can hide future unread updates — the seen watermark is stored as Math.max(Date.now(), selectedSession.updatedAt), but unread compares hub/session updatedAt against that local browser timestamp. If a phone/browser clock is ahead of the hub, opening a session records a future watermark and later hub updates with smaller updatedAt values will not show New activity. Evidence: web/src/router.tsx:161 and web/src/lib/sessionAttention.ts:29.
    Suggested fix:

    useEffect(() => {
        if (!selectedSessionId || !selectedSession) {
            return
        }
        markSessionSeen(selectedSessionId, selectedSession.updatedAt)
    }, [selectedSessionId, selectedSession?.updatedAt])

Summary

  • Review mode: follow-up after new commits
  • The prior bot finding was addressed, but the current attention logic still drops unread indicators for inactive sessions and can suppress future unread indicators when client and hub clocks differ.

Testing

  • Not run (automation)

HAPI Bot

Comment thread web/src/lib/sessionAttention.ts Outdated
Comment thread web/src/router.tsx Outdated
@heavygee
Copy link
Copy Markdown
Contributor Author

Follow-up commits address bot review: keep last-seen watermark synced while selected session receives updates, and compare hub updatedAt only (no client clock). Local typecheck + targeted vitest pass.

@heavygee
Copy link
Copy Markdown
Contributor Author

Addressed remaining review findings in 99e9ad8:

  1. Inactive unreadclassifySessionAttention no longer bails on !summary.active; inactive sessions can show the new-activity dot. Background dot stays gated to active sessions only.
  2. Last-seen watermarkmarkSessionSeen now requires an explicit hub updatedAt (no Date.now() default). Router only tracks last-seen when Detailed status mode is enabled.

Also added a Display → Session list status setting:

  • Standard (default): thinking spinner + generic pending count
  • Detailed (opt-in): permission / input / background / new-activity dots

Tests + typecheck green locally.

@heavygee
Copy link
Copy Markdown
Contributor Author

Temporarily closing to retrigger CI on latest commits.

@heavygee heavygee closed this May 26, 2026
@heavygee heavygee reopened this May 26, 2026
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Detailed mode marks old viewed sessions as unread — markSessionSeen only runs when sessionListStatusMode === 'detailed', while unread falls back to lastSeenAt = 0. If a user views sessions in standard mode and later enables detailed status, every unselected session with any updatedAt value can show New activity even though the activity was already seen. Evidence: web/src/router.tsx:160.
    Suggested fix:

    useEffect(() => {
        if (!selectedSessionId || !selectedSession) {
            return
        }
        markSessionSeen(selectedSessionId, selectedSession.updatedAt)
    }, [selectedSessionId, selectedSession?.updatedAt])
  • [Minor] localStorage write failures can break the sessions page — the new last-seen writer catches read/parse failures but lets localStorage.setItem throw. In browsers where storage is disabled, full, or denied in an embedded context, selecting a session in detailed mode can throw from this effect path instead of degrading to “no unread persistence.” Evidence: web/src/lib/sessionLastSeen.ts:29.
    Suggested fix:

    function writeStore(store: LastSeenStore): void {
        if (typeof localStorage === 'undefined') {
            return
        }
        try {
            localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
        } catch {
            // Ignore storage errors
        }
    }

Summary

  • Review mode: follow-up after new commits
  • The previous inactive-session and clock-skew findings were addressed, but the new settings gate can still produce false unread dots, and the new persistence write path needs the same storage hardening used elsewhere.

Testing

  • Not run (automation)
  • Coverage gap: shared/src/sessionSummary.test.ts is not included in root bun run test because shared/package.json has no test script; the PR’s targeted shared command is manual-only.

HAPI Bot

Comment thread web/src/router.tsx Outdated
Comment thread web/src/lib/sessionLastSeen.ts Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Minor] Storage-denied browsers can still crash the session list — typeof localStorage runs before the try, so an embedded/opaque-origin browser that throws while resolving the localStorage property can still throw during getSessionLastSeenAt render or markSessionSeen, instead of degrading to no unread persistence. Evidence: web/src/lib/sessionLastSeen.ts:6.
    Suggested fix:
    function getLocalStorage(): Storage | null {
        if (typeof window === 'undefined') {
            return null
        }
        try {
            return window.localStorage
        } catch {
            return null
        }
    }
    
    function readStore(): LastSeenStore {
        const storage = getLocalStorage()
        if (!storage) {
            return {}
        }
        try {
            const raw = storage.getItem(STORAGE_KEY)
            // existing parse path...
        } catch {
            return {}
        }
    }
    
    function writeStore(store: LastSeenStore): void {
        const storage = getLocalStorage()
        if (!storage) {
            return
        }
        try {
            storage.setItem(STORAGE_KEY, JSON.stringify(store))
        } catch {
            // Ignore storage errors
        }
    }

Questions

  • None

Summary

  • Review mode: follow-up after new commits
  • The previous two findings are addressed. One storage-access edge remains in the new unread tracking helper.

Testing

  • Not run (automation)
  • Coverage gap: shared/src/sessionSummary.test.ts is still not included in root bun run test because shared/package.json has no test script.

HAPI Bot

Comment thread web/src/lib/sessionLastSeen.ts Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Minor] New shared test is not included in root test automation — the added shared/src/sessionSummary.test.ts exercises the new summary fields, but repo-level bun run test only runs cli/hub/web and shared/package.json has no test script, so this coverage is easy to miss in CI or local pre-merge checks. Evidence: shared/src/sessionSummary.test.ts:1, package.json:21, shared/package.json:1.
    Suggested fix:
    // package.json
    {
      "scripts": {
        "test": "bun run test:cli && bun run test:hub && bun run test:web && bun run test:shared",
        "test:shared": "cd shared && bun run test"
      }
    }
    
    // shared/package.json
    {
      "scripts": {
        "test": "bun test"
      }
    }

Questions

  • None

Summary
Review mode: follow-up after new commits

  • The prior storage-access finding is addressed.
  • One automation coverage gap remains for the new shared summary tests.

Testing

  • Attempted bun run typecheck, cd web && bun test ..., and cd shared && bun test ...; not run because bun is not installed in this runner shell.

HAPI Bot

Comment thread shared/src/sessionSummary.test.ts Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Shared test runner mismatch breaks root test automation — the follow-up wires bun run test to execute cd shared && bun run test, and that script runs bun test; however the new shared test imports from vitest. Existing Bun-runner tests in this repo import bun:test, while Vitest tests are executed through vitest run, so the standard root test path will fail before exercising the new coverage. Evidence: shared/src/sessionSummary.test.ts:1, shared/package.json:22.
    Suggested fix:
    import { describe, expect, it } from 'bun:test'

Questions

  • None

Summary
Review mode: follow-up after new commits

  • The prior automation gap was addressed, but the new shared package test command now points Bun's test runner at a Vitest-importing test file.

Testing

  • Not run (automation); bun is not installed in this runner shell.

HAPI Bot

Comment thread shared/src/sessionSummary.test.ts Outdated
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings
No issues found.

Summary
Review mode: follow-up after new commits
Reviewed the full latest diff, with prior-review/compare context unavailable from the provided review API responses. Residual risk: manual UI behavior for unread clearing and attention-dot rendering still needs browser validation.

Testing
Not run (automation: bun is not available on this runner PATH).

HAPI Bot

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings
No issues found.

Summary
Review mode: follow-up after new commits
Reviewed the full latest diff, using prior bot review and compare-diff context. No blocker/major/minor issues found in the added or modified lines. Residual risk: browser-only UI behavior for the detailed status setting and scheduled clock indicator still needs manual validation.

Testing
Not run (automation: bun is not available on this runner PATH).

HAPI Bot

@heavygee heavygee changed the title feat(web): show session list attention indicators for blocked and unread feat(web): session list status indicators (attention + scheduled) May 26, 2026
heavygee added a commit to heavygee/hapi that referenced this pull request May 26, 2026
Keep the unread watermark in sync with live session activity so
navigating away from an open session does not show a false unread dot.

Addresses review on tiann#699

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee heavygee force-pushed the feat/session-list-attention branch from f90dae5 to 2a785eb Compare May 26, 2026 23:38
heavygee added a commit to heavygee/hapi that referenced this pull request May 26, 2026
Keep the unread watermark in sync with live session activity so
navigating away from an open session does not show a false unread dot.

Addresses review on tiann#699

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee heavygee force-pushed the feat/session-list-attention branch from 2a785eb to 84f4723 Compare May 26, 2026 23:39
Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • [Major] Keep the global SSE stream while viewing a session — eventSubscription switches to { sessionId } whenever a chat is open, so SSEManager.shouldSend() will only deliver that one session's events. Updates from background sessions no longer reach the client, which breaks the new sidebar attention/unread indicators exactly when the user is inside another session. Evidence: web/src/App.tsx:298.
    Suggested fix:
    const globalEventSubscription = useMemo(() => ({ all: true }), [])
    const sessionEventSubscription = useMemo(
        () => selectedSessionId ? { sessionId: selectedSessionId } : null,
        [selectedSessionId]
    )
    const sseEnabled = Boolean(api && token)
    
    const { subscriptionId: globalSubscriptionId } = useSSE({
        enabled: sseEnabled,
        token: token ?? '',
        baseUrl,
        subscription: globalEventSubscription,
        onConnect: handleSseConnect,
        onDisconnect: handleSseDisconnect,
        onEvent: () => {},
        onToast: handleToast
    })
    
    const { subscriptionId: sessionSubscriptionId } = useSSE({
        enabled: sseEnabled && Boolean(sessionEventSubscription),
        token: token ?? '',
        baseUrl,
        subscription: sessionEventSubscription ?? undefined,
        onEvent: handleSseEvent
    })
  • [Major] Refresh scheduled counts when queued scheduled state changes — the /sessions response now computes futureScheduledMessageCount, but the SSE cache path preserves the old value from existing and handles message-received/message-cancelled/messages-consumed without invalidating the session list. After scheduling or canceling a future message, the sidebar clock can stay missing or stuck until a full reconnect/refetch. Evidence: web/src/hooks/useSSE.ts:292.
    Suggested fix:
    if (event.type === 'message-received') {
        ingestIncomingMessages(event.sessionId, [event.message])
        if (event.message.scheduledAt != null) {
            queueSessionListInvalidation()
        }
    }
    
    if (event.type === 'message-cancelled' || event.type === 'messages-consumed') {
        queueSessionListInvalidation()
    }

Questions

  • None.

Summary
Review mode: follow-up after new commits
The latest diff introduces two regressions in the live session-list status path: background-session events are no longer subscribed while a session is selected, and scheduled-count state is not refreshed after schedule/cancel/consume events.

Testing
Not run (automation: bun is not available on this runner PATH).

HAPI Bot

Comment thread web/src/App.tsx Outdated
Comment thread web/src/hooks/useSSE.ts
@heavygee
Copy link
Copy Markdown
Contributor Author

Rebased onto feat/pluggable-voice-backend @ 7492e9d (which is already rebased on latest upstream/main @ 166b711).

Stack order for daily driver: voice (#692) → session attention (#699).

One conflict in web/src/hooks/useSSE.ts: resolved by keeping the voice branch’s single-SSE model and retaining this PR’s session-summary field patches (futureScheduledMessageCount, backgroundTaskCount). Dropped the dual-SSE scope === 'global' invalidation block — incompatible with #692’s App/SSE shape.

Branch force-pushed: 84f4723

Daily driver rebuilt and active on this tip.

heavygee and others added 7 commits May 27, 2026 03:07
Extend SessionSummary with pending request kinds and background task
counts, classify sidebar attention state (permission, input, background,
unread), and track last-seen timestamps client-side when opening sessions.

Fixes tiann#698

Co-authored-by: Cursor <cursoragent@cursor.com>
Keep the unread watermark in sync with live session activity so
navigating away from an open session does not show a false unread dot.

Addresses review on tiann#699

Co-authored-by: Cursor <cursoragent@cursor.com>
Compare unread state using hub timestamps only so client clock skew
cannot hide or fabricate sidebar activity indicators.

Co-authored-by: Cursor <cursoragent@cursor.com>
Allow inactive sessions to show unread activity, require explicit
last-seen timestamps, and gate detailed attention dots behind a new
Display setting (Standard default, Detailed opt-in).

Co-authored-by: Cursor <cursoragent@cursor.com>
Track selected session updatedAt in standard and detailed modes so
enabling detailed status does not flood unread dots. Ignore
localStorage write failures on the write path.

Co-authored-by: Cursor <cursoragent@cursor.com>
Access window.localStorage inside try/catch so embedded browsers
that throw on the property getter degrade safely.

Co-authored-by: Cursor <cursoragent@cursor.com>
Wire shared tests into bun run test so sessionSummary coverage runs in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
heavygee and others added 3 commits May 27, 2026 03:07
Match the shared package test runner so root bun run test succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add futureScheduledMessageCount to SessionSummary (hub batch query) and
render the composer ScheduleIcon in Detailed mode when a session has
future scheduled messages pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
Invalidate session list when scheduled messages are received, cancelled,
or consumed so futureScheduledMessageCount stays accurate in Detailed mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
@heavygee heavygee force-pushed the feat/session-list-attention branch from 84f4723 to 84a40ff Compare May 27, 2026 02:08
@heavygee
Copy link
Copy Markdown
Contributor Author

heavygee commented May 27, 2026

Branch recovered for upstream review

The prior rebase onto `feat/pluggable-voice-backend` was a mistake for this PR. The branch is now rebased on `upstream/main` only:

Copy link
Copy Markdown

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Findings

  • None.

Questions

  • None.

Summary
Review mode: follow-up after new commits
No issues found above the reporting threshold in the latest diff. The previous bot findings appear addressed by keeping separate global/session SSE subscriptions and invalidating the session list on scheduled-message, cancel, and consume events. Residual risk: UI behavior was not manually exercised in a browser.

Testing
Not run (automation/security: did not execute PR code).

HAPI Bot

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.

feat(web): session list attention indicators for blocked, ready, and unread states

1 participant