Keep the session list consistent across browser tabs #27

Closed
opened 2026-10-16 14:07:52 +02:00 by omp-deck · 0 comments
Collaborator

Problem

With several browser tabs open on the sessions page (usually each on a different session), the session list never matches between tabs, after a live update or after a refresh. The tabs disagree about which sessions are live ("active") and which are archived. The list must be identical in every tab.

Causes found in the code:

  • Live is decided per tab. Sidebar.tsx (lines 39-53) puts a session in the top "live" group when it is in sessionsById. That map only holds the sessions the current tab has subscribed to, so each tab builds its own live set.
  • Mutations are not broadcast. In apps/server/src/routes.ts, archive (214), unarchive (233), delete (197), rename via PATCH /sessions/:id (150) and create (88) all return without broadcasting anything. BroadcastFrame in broadcast-bus.ts has no session frame. Only the tab that made the change updates its own store (store.ts: archiveSession 474, unarchiveSession 496, renameSession 514).
  • Archive state lives in two places on the client. The subscribed handler (store.ts 701-733) rebuilds the sessionsById entry from the snapshot, which has no archivedAt. That drops the live row's archive flag.
  • The server cache keeps stale archive flags. GET /sessions (routes.ts 73-79) sets archivedAt directly on the objects held in the bridge's 10-second sessionListCache (in-process.ts 150-154 and 345-357) and never clears it. After an unarchive, a refresh can still return archivedAt for up to 10 seconds. The cache is also not invalidated when a session is disposed (onDispose, in-process.ts 675-682).
  • Title and count changes are not pushed. Auto-titles (in-process.ts 1608), extension setSessionName (576) and new turns change the title, messageCount and updatedAt, but the other tabs never hear about it.

Decisions:

  • "Live" means the server holds an active handle for the session. It is the same in every tab.
  • If tab A archives a session that tab B has open, tab B navigates to /session.
  • Every change that affects the list (create, resume, dispose, archive, unarchive, rename or auto-title, delete, end of a turn) broadcasts one frame. Each tab then refetches GET /sessions, which is the only source for the list.

Plan

Approach

Do the steps in this order. The tree typechecks after each step.

  1. Protocol (packages/protocol/src/index.ts)

    • Add live: boolean; to SessionSummary (line 34) with the doc comment /** True when the server currently holds an active handle for this session. */.
    • Add | { type: "sessions_changed" } to the ServerFrame union next to tasks_changed (around line 1046), with the comment /** Broadcast frame: the session list changed (create/resume/dispose/archive/unarchive/rename/delete/turn end). Clients refetch. */.
    • In apps/server/src/broadcast-bus.ts, add | { type: "sessions_changed" } to BroadcastFrame.
  2. Server list is authoritative and never stale (apps/server/src/bridge/in-process.ts)

    • Add a private method to InProcessAgentBridge:
      private notifySessionsChanged(): void {
        this.sessionListCache.clear();
        broadcastBus.broadcast({ type: "sessions_changed" });
      }
      
      Import broadcastBus from ../broadcast-bus.ts.
    • Replace each existing this.sessionListCache.clear() call (lines 233, 326, 386 and 399) with this.notifySessionsChanged().
    • In the onDispose callback (line 675), call this.notifySessionsChanged() after this.active.delete(sessionId).
    • listSessions: the cache should keep only the raw summarize output read from disk. Build the return value fresh on every call, including cache hits:
      • Map each cached summary to a copy, { ...s, live: this.active.has(s.id) }. Never mutate the cached objects.
      • For each entry in this.active that is missing from the disk list, append a summary built from handle.snapshot(): { id: snap.sessionId, path: snap.sessionFile ?? "", cwd: snap.cwd, title: snap.sessionName, createdAt: now, updatedAt: now, messageCount: snap.messages.length, live: true }, where now is new Date().toISOString(). If opts.cwd is set, only include it when snap.cwd === opts.cwd. Omit title when sessionName is undefined (exactOptionalPropertyTypes-safe spread).
      • summarize() (line 1699) must set live: false so its type matches SessionSummary.
    • Add an onListChanged: () => void argument to InProcessSessionHandle's constructor args (near onDispose, line 993). Store it as private readonly onListChanged, and pass onListChanged: () => this.notifySessionsChanged() where the handle is constructed (line 665). Call this.onListChanged():
      • in setName after a successful setSessionName (after line 1630);
      • in maybeAutoTitle after the session_updated emit (line 1612).
    • In the extension actions.setSessionName (line 576), the bridge's this is in scope. Call this.notifySessionsChanged() after the await.
    • In the event listener block at line 749, call this.notifySessionsChanged() when type === "agent_end" only, so messageCount and updatedAt refresh once per prompt rather than once per turn.
    • Any other AgentBridge implementation or test stub that returns SessionSummary objects must set live. Find them with grep -rn "messageCount" apps/server/src.
  3. Routes broadcast and stop mutating shared objects (apps/server/src/routes.ts)

    • GET /sessions: build the response as sessions.map((s) => { const archivedAt = archivedMap.get(s.id); return archivedAt ? { ...s, archivedAt } : s; }). Do not assign into s.
    • After the successful setArchived(id, true) (archive) and setArchived(id, false) (unarchive), call broadcastBus.broadcast({ type: "sessions_changed" }). Import it from ./broadcast-bus.ts.
    • Delete already broadcasts through bridge.deleteSession (step 2). Create and resume broadcast through createSession and resumeSession. Rename broadcasts through setName. Add nothing else in routes.
  4. Client: one list, refetched on broadcast (apps/web/src/lib/store.ts)

    • Add case "sessions_changed": void get().refreshSessions(); return; next to case "workspaces_changed" (line 750). refreshSessions() without a cwd returns every session, and the Sidebar already filters by scope.
    • archiveSession: after the API call succeeds, set only activeId: s.activeId === id ? undefined : s.activeId, then await get().refreshSessions(). Delete the local edits to sessions and sessionsById.
    • unarchiveSession: after the API call, await get().refreshSessions(). Delete the local edits.
    • renameSession (line 514): replace any local edit of sessions with await get().refreshSessions(). Keep any update to the sessionsById sessionName that drives the open session's header.
    • createSession (line 373) and deleteSession: keep their existing refresh calls. They are redundant with the broadcast but harmless.
    • Remove archivedAt from the live session state type, so archive state lives only in sessions. Find the remaining uses with grep -rn archivedAt apps/web/src. After this change, only SessionSummary rows should carry archivedAt.
  5. Sidebar partitions only on server data (apps/web/src/components/Sidebar.tsx)

    • Replace the useMemo at lines 39-53:
      • const rows = sessions.filter((s) => inScope(s.cwd));
      • activeLive = rows.filter((s) => s.live && !s.archivedAt)
      • activePersisted = rows.filter((s) => !s.live && !s.archivedAt)
      • archivedLive = rows.filter((s) => s.live && s.archivedAt)
      • archivedPersisted = rows.filter((s) => !s.live && s.archivedAt)
    • The live row renderers (lines 151-167 and 213-230) now receive SessionSummary. Use:
      • key={s.id}, id={s.id}
      • title={sessionsById[s.id]?.sessionName || s.title || formatSessionId(s.id)}
      • subtitle={shortPath(s.cwd, 30)}
      • active={s.id === activeId}
      • planMode={sessionsById[s.id]?.planMode?.enabled === true}
      • ideaMode={sessionsById[s.id]?.ideaMode?.enabled === true}
      • Keep the current onClick (selectSession + navigate), with ids from s.id.
    • Persisted rows stay as they are.
  6. Leave a session archived in another tab (apps/web/src/views/SessionView.tsx)

    • Add const archivedAt = useStore((s) => (id ? s.sessions.find((r) => r.id === id)?.archivedAt : undefined));, const navigate = useNavigate(); and const prevArchived = useRef(archivedAt);.
    • Add an effect on [id, archivedAt]:
      • If id changed since the last run, reset prevArchived.current = archivedAt and return. Track the previous id in a second ref.
      • If prevArchived.current === undefined && archivedAt !== undefined, call navigate("/session").
      • Then set prevArchived.current = archivedAt.
    • This only reacts to the transition from not archived to archived. Opening an already-archived session still shows ArchivedNotice.

Critical files & anchors

  • apps/server/src/bridge/in-process.ts: listSessions (345), the onDispose wiring (665-682), setName (1619), maybeAutoTitle (1608), the event hook (749). This is where list-changing events start.
  • apps/server/src/routes.ts: GET /sessions (70-86), where the shared cache objects get mutated; archive and unarchive (214-242).
  • apps/web/src/lib/store.ts: the frame switch (750) and the archive, unarchive and rename actions (474-529).
  • apps/web/src/components/Sidebar.tsx: the partition useMemo (39-53) and the live row renderers.

Verification

  • bun run typecheck at the repo root passes.
  • bun test in apps/server passes.
  • Manual two-tab check (start the deck as usual, open http://127.0.0.1:8787/ in tabs A and B, each on a different session):
    1. Send a prompt in A. A's session shows as live in B's sidebar without a reload, and B's message count updates when the turn ends.
    2. Archive A's session from B's sidebar. A navigates to /session, and the row is under "Archived (n)" in both tabs.
    3. Unarchive it from A, then reload B right away (within 10 seconds). The row is not archived in B.
    4. Rename a session in A. B shows the new title without a reload.
    5. Delete a session in B. It disappears from A.
    6. Create a new session in A and send no prompt. It shows as live in B.
    7. Reload both tabs. Both show the same rows in the same groups.
  • curl -s http://127.0.0.1:8787/api/sessions | jq '.sessions[] | {id, live, archivedAt}' returns live: true exactly for the sessions currently open on the server.

Assumptions & contingencies

  • SessionManager.listAll() may already list a brand-new session that has not been persisted. The synthesized-entry branch then never fires, which is fine because live still comes from this.active.
  • If renameSession in store.ts does not touch sessions today, step 4's rename change reduces to adding the refresh.
  • The session_disposed client handler (store.ts 864) is never triggered by the server. This plan leaves it unchanged.
## Problem With several browser tabs open on the sessions page (usually each on a different session), the session list never matches between tabs, after a live update or after a refresh. The tabs disagree about which sessions are live ("active") and which are archived. The list must be identical in every tab. Causes found in the code: - **Live is decided per tab.** `Sidebar.tsx` (lines 39-53) puts a session in the top "live" group when it is in `sessionsById`. That map only holds the sessions the current tab has subscribed to, so each tab builds its own live set. - **Mutations are not broadcast.** In `apps/server/src/routes.ts`, archive (214), unarchive (233), delete (197), rename via `PATCH /sessions/:id` (150) and create (88) all return without broadcasting anything. `BroadcastFrame` in `broadcast-bus.ts` has no session frame. Only the tab that made the change updates its own store (`store.ts`: `archiveSession` 474, `unarchiveSession` 496, `renameSession` 514). - **Archive state lives in two places on the client.** The `subscribed` handler (`store.ts` 701-733) rebuilds the `sessionsById` entry from the snapshot, which has no `archivedAt`. That drops the live row's archive flag. - **The server cache keeps stale archive flags.** `GET /sessions` (routes.ts 73-79) sets `archivedAt` directly on the objects held in the bridge's 10-second `sessionListCache` (`in-process.ts` 150-154 and 345-357) and never clears it. After an unarchive, a refresh can still return `archivedAt` for up to 10 seconds. The cache is also not invalidated when a session is disposed (`onDispose`, in-process.ts 675-682). - **Title and count changes are not pushed.** Auto-titles (in-process.ts 1608), extension `setSessionName` (576) and new turns change the title, `messageCount` and `updatedAt`, but the other tabs never hear about it. Decisions: - "Live" means the server holds an active handle for the session. It is the same in every tab. - If tab A archives a session that tab B has open, tab B navigates to `/session`. - Every change that affects the list (create, resume, dispose, archive, unarchive, rename or auto-title, delete, end of a turn) broadcasts one frame. Each tab then refetches `GET /sessions`, which is the only source for the list. ## Plan ### Approach Do the steps in this order. The tree typechecks after each step. 1. **Protocol** (`packages/protocol/src/index.ts`) - Add `live: boolean;` to `SessionSummary` (line 34) with the doc comment `/** True when the server currently holds an active handle for this session. */`. - Add `| { type: "sessions_changed" }` to the `ServerFrame` union next to `tasks_changed` (around line 1046), with the comment `/** Broadcast frame: the session list changed (create/resume/dispose/archive/unarchive/rename/delete/turn end). Clients refetch. */`. - In `apps/server/src/broadcast-bus.ts`, add `| { type: "sessions_changed" }` to `BroadcastFrame`. 2. **Server list is authoritative and never stale** (`apps/server/src/bridge/in-process.ts`) - Add a private method to `InProcessAgentBridge`: ```ts private notifySessionsChanged(): void { this.sessionListCache.clear(); broadcastBus.broadcast({ type: "sessions_changed" }); } ``` Import `broadcastBus` from `../broadcast-bus.ts`. - Replace each existing `this.sessionListCache.clear()` call (lines 233, 326, 386 and 399) with `this.notifySessionsChanged()`. - In the `onDispose` callback (line 675), call `this.notifySessionsChanged()` after `this.active.delete(sessionId)`. - `listSessions`: the cache should keep only the raw `summarize` output read from disk. Build the return value fresh on every call, including cache hits: - Map each cached summary to a copy, `{ ...s, live: this.active.has(s.id) }`. Never mutate the cached objects. - For each entry in `this.active` that is missing from the disk list, append a summary built from `handle.snapshot()`: `{ id: snap.sessionId, path: snap.sessionFile ?? "", cwd: snap.cwd, title: snap.sessionName, createdAt: now, updatedAt: now, messageCount: snap.messages.length, live: true }`, where `now` is `new Date().toISOString()`. If `opts.cwd` is set, only include it when `snap.cwd === opts.cwd`. Omit `title` when `sessionName` is undefined (`exactOptionalPropertyTypes`-safe spread). - `summarize()` (line 1699) must set `live: false` so its type matches `SessionSummary`. - Add an `onListChanged: () => void` argument to `InProcessSessionHandle`'s constructor args (near `onDispose`, line 993). Store it as `private readonly onListChanged`, and pass `onListChanged: () => this.notifySessionsChanged()` where the handle is constructed (line 665). Call `this.onListChanged()`: - in `setName` after a successful `setSessionName` (after line 1630); - in `maybeAutoTitle` after the `session_updated` emit (line 1612). - In the extension `actions.setSessionName` (line 576), the bridge's `this` is in scope. Call `this.notifySessionsChanged()` after the `await`. - In the event listener block at line 749, call `this.notifySessionsChanged()` when `type === "agent_end"` only, so `messageCount` and `updatedAt` refresh once per prompt rather than once per turn. - Any other `AgentBridge` implementation or test stub that returns `SessionSummary` objects must set `live`. Find them with `grep -rn "messageCount" apps/server/src`. 3. **Routes broadcast and stop mutating shared objects** (`apps/server/src/routes.ts`) - `GET /sessions`: build the response as `sessions.map((s) => { const archivedAt = archivedMap.get(s.id); return archivedAt ? { ...s, archivedAt } : s; })`. Do not assign into `s`. - After the successful `setArchived(id, true)` (archive) and `setArchived(id, false)` (unarchive), call `broadcastBus.broadcast({ type: "sessions_changed" })`. Import it from `./broadcast-bus.ts`. - Delete already broadcasts through `bridge.deleteSession` (step 2). Create and resume broadcast through `createSession` and `resumeSession`. Rename broadcasts through `setName`. Add nothing else in routes. 4. **Client: one list, refetched on broadcast** (`apps/web/src/lib/store.ts`) - Add `case "sessions_changed": void get().refreshSessions(); return;` next to `case "workspaces_changed"` (line 750). `refreshSessions()` without a cwd returns every session, and the Sidebar already filters by scope. - `archiveSession`: after the API call succeeds, set only `activeId: s.activeId === id ? undefined : s.activeId`, then `await get().refreshSessions()`. Delete the local edits to `sessions` and `sessionsById`. - `unarchiveSession`: after the API call, `await get().refreshSessions()`. Delete the local edits. - `renameSession` (line 514): replace any local edit of `sessions` with `await get().refreshSessions()`. Keep any update to the `sessionsById` `sessionName` that drives the open session's header. - `createSession` (line 373) and `deleteSession`: keep their existing refresh calls. They are redundant with the broadcast but harmless. - Remove `archivedAt` from the live session state type, so archive state lives only in `sessions`. Find the remaining uses with `grep -rn archivedAt apps/web/src`. After this change, only `SessionSummary` rows should carry `archivedAt`. 5. **Sidebar partitions only on server data** (`apps/web/src/components/Sidebar.tsx`) - Replace the `useMemo` at lines 39-53: - `const rows = sessions.filter((s) => inScope(s.cwd));` - `activeLive = rows.filter((s) => s.live && !s.archivedAt)` - `activePersisted = rows.filter((s) => !s.live && !s.archivedAt)` - `archivedLive = rows.filter((s) => s.live && s.archivedAt)` - `archivedPersisted = rows.filter((s) => !s.live && s.archivedAt)` - The live row renderers (lines 151-167 and 213-230) now receive `SessionSummary`. Use: - `key={s.id}`, `id={s.id}` - `title={sessionsById[s.id]?.sessionName || s.title || formatSessionId(s.id)}` - `subtitle={shortPath(s.cwd, 30)}` - `active={s.id === activeId}` - `planMode={sessionsById[s.id]?.planMode?.enabled === true}` - `ideaMode={sessionsById[s.id]?.ideaMode?.enabled === true}` - Keep the current `onClick` (`selectSession` + navigate), with ids from `s.id`. - Persisted rows stay as they are. 6. **Leave a session archived in another tab** (`apps/web/src/views/SessionView.tsx`) - Add `const archivedAt = useStore((s) => (id ? s.sessions.find((r) => r.id === id)?.archivedAt : undefined));`, `const navigate = useNavigate();` and `const prevArchived = useRef(archivedAt);`. - Add an effect on `[id, archivedAt]`: - If `id` changed since the last run, reset `prevArchived.current = archivedAt` and return. Track the previous id in a second ref. - If `prevArchived.current === undefined && archivedAt !== undefined`, call `navigate("/session")`. - Then set `prevArchived.current = archivedAt`. - This only reacts to the transition from not archived to archived. Opening an already-archived session still shows `ArchivedNotice`. ### Critical files & anchors - `apps/server/src/bridge/in-process.ts`: `listSessions` (345), the `onDispose` wiring (665-682), `setName` (1619), `maybeAutoTitle` (1608), the event hook (749). This is where list-changing events start. - `apps/server/src/routes.ts`: `GET /sessions` (70-86), where the shared cache objects get mutated; archive and unarchive (214-242). - `apps/web/src/lib/store.ts`: the frame switch (750) and the archive, unarchive and rename actions (474-529). - `apps/web/src/components/Sidebar.tsx`: the partition `useMemo` (39-53) and the live row renderers. ### Verification - `bun run typecheck` at the repo root passes. - `bun test` in `apps/server` passes. - Manual two-tab check (start the deck as usual, open `http://127.0.0.1:8787/` in tabs A and B, each on a different session): 1. Send a prompt in A. A's session shows as live in B's sidebar without a reload, and B's message count updates when the turn ends. 2. Archive A's session from B's sidebar. A navigates to `/session`, and the row is under "Archived (n)" in both tabs. 3. Unarchive it from A, then reload B right away (within 10 seconds). The row is not archived in B. 4. Rename a session in A. B shows the new title without a reload. 5. Delete a session in B. It disappears from A. 6. Create a new session in A and send no prompt. It shows as live in B. 7. Reload both tabs. Both show the same rows in the same groups. - `curl -s http://127.0.0.1:8787/api/sessions | jq '.sessions[] | {id, live, archivedAt}'` returns `live: true` exactly for the sessions currently open on the server. ### Assumptions & contingencies - `SessionManager.listAll()` may already list a brand-new session that has not been persisted. The synthesized-entry branch then never fires, which is fine because `live` still comes from `this.active`. - If `renameSession` in `store.ts` does not touch `sessions` today, step 4's rename change reduces to adding the refresh. - The `session_disposed` client handler (store.ts 864) is never triggered by the server. This plan leaves it unchanged.
lucas closed this issue 2026-10-26 13:58:33 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#27
No description provided.