Skip to content

feat(solo): fold chats quiet for 72 hours into Inactive - #3186

Draft
joahg wants to merge 1 commit into
block:mainfrom
joahg:joah/solo-fold-inactive
Draft

joahg wants to merge 1 commit into
block:mainfrom
joahg:joah/solo-fold-inactive

Conversation

@joahg

@joahg joahg commented Nov 10, 2026 •

Copy link
Copy Markdown
Member

Why

Solo lists every chat the viewer has, so the sidebar fills up with chats nobody has touched in days. Chats that have gone quiet should get out of the way without being archived.

What

Solo chats with no messages in the last 72 hours move into one Inactive for 72 hours (N) section below the workspaces.

Light, after opening a folded chat Dark, section open
Light theme: Design review moved back to Chats; four chats under "Inactive for 72 hours (4)" Dark theme: five chats under "Inactive for 72 hours (5)"
  • Collapsed by default. The heading shows how many chats it holds. Whether it's open is saved per community (solo:inactive-open), the same way workspace collapse is saved.
  • Chats that need you stay put. The open chat, a chat with a working agent and unread chats stay in their workspace. These are the chats Solo already keeps listed past a workspace's cap. A chat in the middle of a rename also stays in place until the editor closes.
  • A new message brings a chat back. One timer is set for the next chat to cross 72 hours, so the sidebar updates while it sits idle. This follows the Active filter in useSidebarFilters.
  • A chat only folds when its last-message time is known. A chat whose activity hasn't loaded, or that has no messages yet, stays listed. Nothing moves while activity loads.
  • Rows in Inactive keep their usual menu, drag, plugin row and keyboard behavior. Leaving the open chat still opens its nearest visible neighbor, and Inactive counts as a list for that.
  • Works with composed workspaces. When a plugin composes the workspace list (Nexus), Inactive still renders below it. Its rows are plain rows, not workspace cards, because they have left their workspace.

Activity loading (relay/session.ts, FOUNDATION)

Today Solo only knows a chat's last-message time when a Team section sorts by Recent or filters by Active. Otherwise the time comes from live events alone, so after a reload most chats have no known activity.

session.channels.retainActivity(include) loads the latest message time for the listed channels that include accepts, for as long as the handle is held. Solo holds it for its own chats only, so a community with many channels doesn't read all of them. A Recent sort or Active filter still reads every channel, as before. Releasing the last handle cancels the read, the same as turning those preferences off.

Test fixtures

The browser fixture dates seeded history to November 2023. Every seeded Solo chat would fold, which hides the rows many specs click. sign now moves seeded times in Solo chats to the last day and keeps their order. Times that are already current, such as live appends, are left alone. Other channels keep their 2023 times, which global search's date filters depend on. The vitest sidebar fixture's "newer" chats get current timestamps for the same reason.

Testing

  • solo-inactivity.test.ts: the 72-hour boundary, exempt chats, unknown activity, and the next deadline.
  • SoloInventory.test.tsx: a quiet chat folds while the open and unread chats stay listed. Reading the unread chat folds it. Opening Inactive shows its rows and saves that it's open. A chat crossing 72 hours while the sidebar is idle folds when the timer fires.
  • channel-activity-session.test.ts: retainActivity reads only the channels it names, without a Team Recent or Active section. Its release is idempotent, the read cancels after the last release, and a Recent sort still reads every channel.
  • tests/browser/solo-inactive.spec.mjs, in Chromium and WebKit: after the clock moves forward 72 hours, three chats fold. The section starts closed, opening a chat takes it out of Inactive, and a reload keeps the section open.
  • Chromium, 144 tests pass: every browser spec that uses Solo chats, plus navigation-sorting, relay-recovery and sidebar-unread. Those three failed on the first CI run, when Solo read activity for every channel. They also pass in WebKit.
  • Full vitest: 20,785 pass. Two tests fail locally and are unrelated. The HEIC tiling test in media-preparation fails on main too. The vite-config warning test passes when run on its own.
  • After rebasing onto the Nexus workspace layout (NEXUS-4156: Add workspace Leads and choose a chat's workspace #2788), Chromium passes 70 browser tests: the Solo specs plus navigation-sorting, relay-recovery and sidebar-unread. The Solo, channels, relay activity and composer vitest suites pass too (2,099 tests).
  • Typecheck and biome check pass. Rendered in light and dark themes at 1280×800.

This replaces the plugin approach in #2483 (a registerChatList hook), which is closed.

🤖 Generated with Claude Code

Solo chats whose last message is more than 72 hours old move to a collapsed
"Inactive for 72 hours (n)" section below the workspaces. The open chat,
chats with a working agent, unread chats and a chat being renamed stay in
their workspace; a new message moves a chat back. A deadline timer folds a
chat that crosses the window while the sidebar is idle, and the section
remembers whether it was opened.

Solo holds an activity demand scoped to Solo chats, so the relay session
reads their latest message time without a Recent sort or Active filter.

AGNTOPS-732

Co-authored-by: Hertz <c6741012416ef5c49a865db79682113385c5fb998e8c890234fb442b59e22748@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Joah Gerstenberg <[email protected]>
@joahg
joahg force-pushed the joah/solo-fold-inactive branch from e35928e to 7dbcbb8 Compare November 12, 2026 00:07
matt2e added a commit that referenced this pull request Nov 14, 2026
…es (#3442)

## Why

Two browser journeys fail intermittently in CI for the same underlying
reason. Both jump the page clock far enough to expire the live stream's
heartbeat, and the stream restart that follows was not accounted for.

| Test | Sightings |
|---|---|
| `agent-outcomes.spec.mjs`, "a remote failure shares its expandable
history and preserves its message anchor" | WebKit 1/18, Browser
journeys: [10 Nov,
main](https://github.com/block/buzz-app/actions/runs/42923245792/job/130206998334),
[10 Nov,
PR](https://github.com/block/buzz-app/actions/runs/42920092542/job/130193154955),
[11 Nov,
PR](https://github.com/block/buzz-app/actions/runs/43110752944/job/130826317616),
[11 Nov,
main](https://github.com/block/buzz-app/actions/runs/42952461094/job/130300041605)
|
| `relay-recovery.spec.mjs`, "relay reads recover automatically after
the network returns" | WebKit 5/18, Browser journeys: [10 Nov, PR,
passed on
re-run](https://github.com/block/buzz-app/actions/runs/42918607668/job/130187892464)
|

## Root cause

The live broker stream arms a 45 s heartbeat on the page clock. The
broker's 15 s keepalive runs on real time. A page-clock jump longer than
45 s therefore always expires the heartbeat, and the page aborts and
restarts its stream. `presence.spec.mjs` already records this hazard.

**`agent-outcomes`** pauses the page clock, freezes wall time, and runs
120 s of browser time. Two failures follow from the restart:

- In three of the four jobs the test published its signed replies before
the restarted stream had re-issued a channel REQ. The policy relay
rejected the publish with "event must traverse an explicit production
channel REQ". In each trace the publish came about 0.9 s after the clock
resumed, and the new stream opened 0.2 to 0.4 s later.
- In the fourth job (11 Nov, main) the 120 s jump took 85.6 s of real
time instead of the usual 4 to 12 s. The still-frozen wall clock drifted
more than 60 s from the broker, so read-state signing answered 400.

**`relay-recovery`** fast-forwards the page clock 30 s twice. The second
jump expires the heartbeat. In the same jump, session recovery's
scheduled retry posts `/stream-retry` with the old stream id. The broker
has already handled the stream close and answers 404 "Live stream no
longer available". The app drops the error for a superseded generation,
but the browser still logs "Failed to load resource … 404". The fixture
only excused retired-stream 404s for `/stream-observer` and
`/stream-priority`, so the unexplained-console-error check failed the
test. The trace shows the fast-forward, the 404 ten milliseconds later,
and the new stream 0.7 s after that.

Neither is a product defect. The restart only happens under a fake-clock
jump, and the app already ignores the late control response.

## Fix

- **`agent-outcomes.spec.mjs`**: re-anchor the page wall clock to real
time right after resuming, so signed read-state events follow the
broker's clock again. Then poll for the primary/alpha route before
publishing, mirroring the readiness wait the test already does at
startup.
- **`fixture.mjs`**: widen the retired-stream 404 match to the six
broker control routes that answer 404 once a stream is retired:
observer, priority, retry, interests, artifact-sync and
artifact-sync-close. The exact `retiredStreams.has(streamId)` guard
stays, so a 404 for a live or unknown stream still fails. Presence
controls answer 200 for a missing stream and stay out of the list.

No timeouts, retries or assertions were relaxed. No browser cases were
added or removed.

## Evidence

Local runs on Linux, WebKit, `bin/pnpm test:browser:ci --project webkit
--no-deps --workers=1 --repeat-each=8 --reporter=list`.

| Scope | Result |
|---|---|
| `agent-outcomes.spec.mjs -g "a remote failure shares"` | 8/8 passed,
17.8 to 20.2 s each |
| `relay-recovery.spec.mjs relay-refusal.spec.mjs -g "relay reads
recover automatically\|an upload is the first request"` | 16/16 passed |

## Not reproduced, not run

- The late `/stream-retry` 404 did not reproduce locally. No retired
controls were recorded in the local evidence, so the widened fixture
branch is only exercised under CI timing. The Flake check lane on this
PR is the real test of it.
- The unchanged `agent-outcomes` test also passed 8/8 locally, with two
or three stream restarts during each jump. The local run guards against
regression rather than reproducing the CI ordering.
- The read-state re-anchor addresses a failure seen once, when the jump
ran unusually slowly. That slowness was not reproduced.
- Chromium was not run for these two changes.
- No human has tested this yet, so `buzz-review-completed` is not set.

## Overlap with open PRs

Several open PRs also edit `tests/browser/fixture.mjs` (for example
#3332, #3239, #3186 and #3093). None touches the retired-stream match,
and a trial merge of each against this change is clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: mtoohey <[email protected]>
Signed-off-by: Matt Toohey <[email protected]>
Co-authored-by: Claude Fable 5.1 <[email protected]>

This branch has not been deployed

No deployments
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