Skip to content

Sidebar: backend activity stamps, snooze, and a wrapping-up tier - #206

Merged
Zeus-Deus merged 5 commits into
mainfrom
sidebar-workspace-settling-behavior
Jul 27, 2026
Merged

Sidebar: backend activity stamps, snooze, and a wrapping-up tier#206
Zeus-Deus merged 5 commits into
mainfrom
sidebar-workspace-settling-behavior

Conversation

@Zeus-Deus

Copy link
Copy Markdown
Owner

Why

Auto-settle measured idleness against a client-side stamp invented the first time each build saw a workspace. Installing an update therefore restarted every workspace's idle clock, and real history was thrown away — a machine full of month-old worktrees looked brand new and nothing swept for another full window.

The fix

WorkspaceSnapshot now carries persisted last_active_at / last_visited_at, stamped on non-idle pane transitions, at create, and on activation. A one-time boot backfill dates undated checkouts from their last git commit, falling back to directory mtime, and never invents now — an unknown stamp means "don't sweep", so a bad read fails toward staying visible.

effectiveActivityAt makes the backend stamp authoritative over the client map. That part matters: a first-seen-only backfill would have fixed new installs and done nothing for anyone who had already updated, because their client map was already populated with synthetic baselines. There is a regression test pinning exactly that, and it is the only test that fails if the line is reverted.

Also in here

  • Snooze — a sibling lifecycle to settle. DST-safe presets built from local date components (never +86400000), resolved when the menu opens rather than off the coarse tick, and labelled with the absolute wake time. Own collapsed shelf showing time-until-wake, a precise wake timer clamped against the signed 32-bit setTimeout overflow, and an immediate hand-raise wake when a snoozed agent goes working or blocked.
  • "Wrapping up" tier — an open, non-draft PR that is idle and already read drops below a divider so nearly-done work stops holding prime space. Deliberately not collapsible: offering to hide live-but-nearly-done work is the failure the tier exists to prevent. Sending a follow-up makes the agent run, which restores the card to the top on its own — no extra state to get stuck.
  • Static newest-first order — position changes only at lifecycle transitions the user caused, never on status churn, which is what makes Alt+1..9 muscle memory. Alt+1 is now the newest workspace.
  • Merged/closed PRs settle only after an hour of extra idle, so returning for follow-up work doesn't get re-buried the moment the agent stops.
  • Unread markers, multi-select with bulk parking, forward navigation on park, shelf counts, and forced visibility of the currently-open workspace.
  • Scale: CSS containment on off-screen rows (intrinsic sizes measured from live nodes, not guessed) and a stable scrollbar gutter. Containment over virtualization deliberately — rows stay mounted so find, focus, and jump targets keep working.

Defects fixed along the way

  • The collapsed rail filtered on settled only, so snoozed workspaces still appeared in the 52px rail.
  • Switching away didn't mark the outgoing workspace visited, so work you sat and watched came back reported unread.
  • Cross-device adoption stamped last_active_at = now, defeating the backfill.
  • SnoozeEntry.at was documented as guarding stale hand-raise wakes but nothing read it; traced every path, confirmed the wake is unreachable, and made the comment honest rather than implementing a no-op guard.

Anti-oscillation

Four states (active / settled / snoozed / pinned-active) with disjoint status bands: auto-settle fires only at status null, auto-un-settle and the hand-raise only at working/blocked, the wake sweep only on an elapsed timer. The shelves are mutually exclusive from both sides. Documented as prose in docs/features/sidebar.md alongside a Load-Bearing Assumptions section for the things a future change could silently break.

Verification

npm run check clean · 3156 frontend tests green · cargo check clean · Rust suite green apart from pre-existing flakes that shell out to git/find/grep and pass in isolation.

Ordering, guardrails, and the full snooze flow were also exercised against the running app, not just the test suite.

Note

no-scrollbar is used in several places but is not defined anywhere — no @utility block, no plugin. It is a dead class, so those surfaces have had real scrollbars all along. Out of scope here; worth a follow-up.

The idle sweep measured against a client-side stamp invented the first
time each build saw a workspace, so installing an update restarted every
workspace's idle clock and months of real history were discarded.

Workspaces now carry backend-owned `last_active_at` / `last_visited_at`
(persisted, stamped on non-idle pane transitions, at create, and on
activation), with a one-time boot backfill dating undated checkouts from
their last git commit, then directory mtime, and never inventing `now`.
`effectiveActivityAt` makes the backend stamp authoritative so installs
that already wrote a synthetic baseline are corrected rather than skipped.

Alongside that:

- Snooze as a sibling lifecycle to settle: DST-safe presets resolved when
  the menu opens and labelled with the absolute wake time, its own
  collapsed shelf, a precise wake timer clamped against the signed 32-bit
  setTimeout overflow, and an immediate hand-raise wake on working/blocked.
- A "wrapping up" tier: an open (non-draft) PR that is idle and already
  read drops below a non-collapsible divider, so nearly-done work stops
  holding prime space without being hidden. A follow-up message restores
  it to the top on its own.
- Cards ordered newest-first and statically: position changes only at
  lifecycle transitions the user caused, never on status churn, so
  Alt+1..9 stays muscle memory.
- Merged/closed PRs settle only after an hour of additional idle, so
  follow-up work stays visible while it is warm.
- Unread markers, multi-select with bulk parking, forward navigation on
  park, shelf counts, and forced visibility of the open workspace.
- Off-screen rows use CSS containment and the sidebar reserves a stable
  scrollbar gutter, so long lists stop costing layout and stop jogging
  sideways as workspaces park.

The collapsed rail now hides snoozed workspaces as well as settled ones,
switching away marks the outgoing workspace visited so watched work is not
reported unread, and adopted workspaces leave their stamp unknown so the
backfill can date them from real history.
@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review — Opus 5 (high effort)

This is the strongest reasoning of the eleven open PRs — the effectiveActivityAt insight in particular (a first-seen-only backfill fixing new installs and doing nothing for anyone who had already updated, because their client map was already poisoned with synthetic baselines) is the kind of thing that only comes from actually thinking about the migration. I verified the pieces that matter:

  • Unknown never sweeps. stamp === undefined → idleFor = 0, and both prSwept and idleSwept independently require stamp !== undefined. Belt and braces — reverting either guard still fails safe. ✓
  • The backfill never invents now. derive_last_activity_ms returns None for a non-directory, and last_commit_ms returns None for a non-repo / unborn HEAD / missing git binary. ✓
  • The backfill can't clobber a real stamp. Re-checking w.last_active_at.is_none() after retaking the lock is exactly right, since the git work runs unlocked. ✓
  • Adoption paths leave last_active_at: None in both create_synced_workspace_shell and the adopted-root path, while create_remote_attach_workspace (an explicit user action) stamps now. That asymmetry is correct and the comments say why. ✓
  • The change-guard placement is real, not just claimed: set_pane_status_by_thread returns early on pane_statuses.get(&pane_id.0) == Some(&status), so the content_delta re-assert of Working doesn't restamp. ✓
  • atLocalTimeOnDay / localDaysBetween / daysToNextMonday are all correct, including the deliberate Monday → 7, and the rounded-day-starts trick for 23/25-hour days.

One real bug: "Tomorrow" and "Next week" are the same option on Sundays

daysToNextMonday returns (8 - 0) % 7 = 1 on a Sunday, so:

  • tomorrowatLocalTimeOnDay(now, 1, 9) → Monday 09:00
  • next-weekatLocalTimeOnDay(now, 1, 9)Monday 09:00

Two menu entries, identical at, and the labels actively hide it: tomorrow strips its Tomorrow prefix so it reads Tomorrow · 09:00, while next-week keeps the full label and reads Next week · Tomorrow 09:00. A user picking "Next week" on a Sunday evening gets a twelve-hour snooze.

This is the same class of problem MIN_PRESET_LEAD_MS already solves for "This evening" — a preset that has collapsed into uselessness should drop out. Simplest fix: skip next-week when daysToNextMonday(now) <= 1, or require it to be at least a day past the tomorrow preset.

Two notes, not defects

directory_mtime_ms is effectively now for a live checkout. Your own test comment says as much ("unavoidably 'now'"). So on the first boot after upgrade, a non-git workspace — or a repo whose git log fails for any reason other than the ones enumerated — gets a fresh full idle window rather than being dated. That's consistent with the stated "fails toward staying visible" policy, but it means the backfill's success rate is quietly a function of how many of a user's workspaces are real git repos. Worth a line in the doc.

Only the thread-keyed writer is change-guarded. set_pane_status and set_pane_status_by_session stamp on every non-idle write with no equality check, so anything that re-asserts a terminal pane's status at high frequency writes last_active_at each time. Harmless for correctness (monotonic clock, same workspace), just worth confirming no terminal-side path re-asserts Working on a tight loop the way content_delta does.

Overlap — this needs sequencing, not independent merging

#207 implements the same static newest-first ordering, independently, in the same file: orderActiveWorkspaces (a plain reverse()) vs your compareNewestFirst over a captured storedIndex. Two implementations of one behaviour. #207 also mounts SidebarNeedsYouStrip, which is a second attention surface alongside your shelves.

Full overlap set: sidebar-inbox.tsx (#207, #208, #213), sidebar-inbox-card.tsx (#208, #213), sidebar-rail-workspaces.tsx (#207, #208), sidebar-workspace-row.tsx (#210, #213), workspace-inbox-menu.tsx (#213), state_impl.rs (#214), settings-view.tsx (#210), plus both sidebar test files with #207.

Also: on the no-scrollbar note at the end — #210 confirms and fixes the same class of dead utility, removing six of them (scroll-fade-x, scroll-fade-b, scrollbar-thin, scrollbar-gutter-stable, and the two data-autoscrolling:scrollbar-*). I verified independently that none of them is defined anywhere: no @utility block in globals.css, and no tailwind-scrollbar plugin in package.json. scrollbar-none survives in attachment.tsx and is equally dead.

@Zeus-Deus

Copy link
Copy Markdown
Owner Author

Review by GPT 5.6 Sol xhigh found three functional issues:

  1. Bulk Settle bypasses the live-work guardrail. SidebarInboxCard correctly defines canSettle = status !== "working" && status !== "permission" and hides both per-row parking actions for live/blocked work. The bulk path only computes canBulkSnooze; handleBulkSettle at sidebar-inbox.tsx:1061-1066 settles every selected ID unconditionally, and the menu always renders Settle (N) at lines 1634-1636. The current test at roughly 2084 explicitly expects Settle (2) for a selection containing a working row. This also calls navigateAfterPark(selection) before the safety effect resurfaces the live row, so the user can be navigated away for an action the guardrail says must not be possible. Bulk Settle needs the same all-selected canSettle predicate as the card.

  2. Not every workspace switch stamps visits. The new unread invariant says every switch surface funnels through activate_workspace, but AppStateStore::activate_terminal_session (state_impl.rs:720-730) and activate_pane (806-815) assign active_workspace_id directly. ResourceMonitor.navigateToSession uses activateTerminalSession, so a cross-workspace jump through that shipped UI neither closes the outgoing visit nor stamps the incoming one, and it also skips the activation cleanup. This can make watched work appear unread or leave the destination visit stale. The switch bookkeeping should be shared by all three activation paths.

  3. Keyboard activation leaves a hidden multi-selection behind on shelf rows. Active cards call selectAndActivate(), but SettledRow and SnoozeRow handle Enter/Space by calling only activateFromSidebar (sidebar-inbox.tsx:465-470 and 587-591). A prior Ctrl/Shift selection therefore remains selected after keyboard navigation, contrary to the nearby invariant that plain activation collapses selection. Call onSelect(id, "single") before activation, matching click and active-card behavior.

Focused validation passed: 142/142 sidebar frontend tests and 13/13 workspace-activity Rust tests.

Coordination note: this head has pairwise textual conflicts with open PRs #207, #208, #212, and #213; #212 also needs a semantic resolution for this PR's new snoozed lifecycle.

…tomorrow

On Sundays daysToNextMonday returns 1, so the next-week preset resolved
to the exact instant the tomorrow preset already offers — two menu
entries, one wake time ("Next week · Tomorrow 09:00"). Withhold it
unless it lands at least a calendar day past tomorrow, the same
conditional-offer pattern this-evening already uses, and pin the two
wake-label tests to a fixed Wednesday so they cannot flake on real
Sundays.
…k guardrails

Bulk Settle settled every selected row unconditionally while the per-row
action and bulk Snooze both refuse to park working or permission-blocked
workspaces. Gate the menu item and the handler on the same all-selected
predicate, and show a disabled explanatory line instead of an empty menu
when the selection contains live work.

Enter/Space on settled and snoozed shelf rows activated the workspace
without collapsing the multi-selection the way plain clicks and active
cards do, leaving an invisible selection behind for the next bulk action
to act on. Collapse it first, matching the click path.
…tches

activate_terminal_session and activate_pane assigned active_workspace_id
directly, skipping the visit bookkeeping activate_workspace performs —
so a jump-to-session navigation switched workspaces without closing the
outgoing visit or stamping the incoming one, leaving the sidebar's
unread state wrong after every such jump.

Extract the switch bookkeeping (outgoing visit close, incoming visit
stamp, notification read/clear, review-status cleanup) into a shared
record_workspace_switch helper and call it from all three activation
paths whenever the active workspace actually changes. Same-workspace
focus moves still stamp nothing. Covered by three new tests in
workspace_activity_tests.
…otes

- last_visited_at is now written by record_workspace_switch, shared by
  all three activation paths on cross-workspace switches
- the next-week preset is conditional (dropped on Sundays when it would
  duplicate tomorrow)
- bulk Settle rides the same guardrail as bulk Snooze
- note that backfill quality depends on the checkout being a git repo,
  since the mtime fallback is usually close to now
@Zeus-Deus
Zeus-Deus merged commit 4ad098e into main Jul 27, 2026
4 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