Skip to content

fix(components): mark unread sub-session tabs on desktop - #332

Merged
lodystage[bot] merged 1 commit into
mainfrom
feat/unread-indicator-to-tabs
Sep 4, 2026
Merged

fix(components): mark unread sub-session tabs on desktop#332
lodystage[bot] merged 1 commit into
mainfrom
feat/unread-indicator-to-tabs

Conversation

@lodystage

@lodystage lodystage Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Related issue

Problem / pressure

A child session tab carried no unread indicator on desktop. Sub-sessions get no
sidebar row of their own, so the tab is the only place their new output can
surface — a subagent that finished while the user was reading the parent thread
stayed completely silent until they happened to click its tab.

While fixing that, the same slot turned out to have a dead branch: isWaiting
was tested after isWorking, but a permission request is itself live presence
(liveStatus != null), so a tab blocked on approval always rendered the busy
spinner and its own marker never reached the screen. "This tab needs you" was
indistinguishable from "this tab is busy".

Summary

The tab's leading icon becomes ONE priority-ordered status slot,
waiting > working > unread > agent icon — the same ladder the desktop sidebar
row (sidebar-row-shared.tsx) and the mobile tab sheet already use.

  • Unread reads through the new sessionHasUnreadMessages
    (lib/session-read-receipt.ts), which shares its comparison with
    shouldMarkSessionRead, so announcing unread cannot drift from deciding
    the read receipt.
  • Unread is suppressed on the ACTIVE tab: that surface is the one clearing
    unread, so a dot there would only flash between the click and the receipt
    landing.
  • Waiting renders the sidebar's Hand instead of the old amber dot.
    --primary and --status-warning are both amber in the shipped themes, so an
    amber waiting dot beside a primary unread dot read as the same marker (caught
    from the screenshots, not from the code).
  • The slot is a fixed 12px box, so switching states never shifts a tab label.

The invariant is recorded in components/sessions/AGENTS.md.

Before / after

Story Sessions/SessionTabBar › Unread sub-session tabs, six tabs: active
parent · unread child · never-read child · working child · child awaiting
permission · caught-up child.

Before After
No unread marker anywhere — the two unread children are indistinguishable from the caught-up one (all three show the plain agent icon). Both unread children carry the bg-primary dot (blue in light, amber in dark).
The child awaiting permission renders a spinner, identical to the merely-busy child (two spinners in the row). It renders the amber Hand; exactly one spinner remains.
The active parent stays unmarked.

DOM counts from the Playwright capture, both themes:
before = 0 unread dots / 2 spinners / 0 hands,
after = 2 unread dots / 1 spinner / 1 hand.

Test plan

  • New packages/components/tests/session-tab-bar-status-slot.test.tsx (7 cases):
    unread child, never-read child, caught-up child, unread parent while a child
    tab is active, active tab not marked, spinner outranks unread, hand outranks
    spinner. Verified they are not vacuous — run against the pre-fix
    session-tab-bar.tsx, 4 of the 7 fail.
  • New story Sessions/SessionTabBar › Unread sub-session tabs covering the
    whole ladder in one row; screenshots above were captured from it in both
    themes by swapping the component back to HEAD for the "before" pass.
  • pnpm typecheck (components), oxlint, pnpm lint:i18n: clean.
  • Full @lody/components unit suite: 422 files / 3044 tests passing.
  • Not run: the Playwright e2e suite (no story render-budget test references
    SessionTabBar), and the other workspace packages, which this change does not
    touch.

Note for anyone reproducing locally: this environment exports
NODE_ENV=production, under which React 19 does not export act and every
pre-existing jsdom test in the package fails with
(0 , act) is not a function. Unrelated to this change — the runs above used
NODE_ENV=development.

A child session tab had no unread indicator. Sub-sessions get no sidebar
row of their own, so the tab is the only place their new output can
surface — a subagent that finished while the user read the parent thread
stayed silent until they happened to click it.

The tab's leading icon slot becomes ONE priority-ordered status slot,
`waiting > working > unread > agent icon`, the same ladder the desktop
sidebar row and the mobile tab sheet already use. Unread reads through
the new `sessionHasUnreadMessages`, which shares its comparison with
`shouldMarkSessionRead` so announcing unread cannot drift from deciding
the read receipt, and is suppressed on the ACTIVE tab — that surface is
the one clearing unread, so a dot there would only flash between the
click and the receipt landing.

That reordering also revives a dead branch: `isWaiting` was tested after
`isWorking`, but a permission request is itself live presence, so a tab
blocked on approval always rendered the busy spinner and its marker
never showed. Waiting now renders the sidebar's `Hand` rather than the
old amber dot, because `--primary` and `--status-warning` are both amber
in the shipped themes and two dots side by side read as one marker.

Adds `tests/session-tab-bar-status-slot.test.tsx` over the whole ladder
and a `Sessions/SessionTabBar` story rendering it in one row.

Model: claude-opus-5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lodystage
lodystage Bot merged commit 5350490 into main Sep 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant