Keep the session list consistent across browser tabs #27
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#27
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
Sidebar.tsx(lines 39-53) puts a session in the top "live" group when it is insessionsById. That map only holds the sessions the current tab has subscribed to, so each tab builds its own live set.apps/server/src/routes.ts, archive (214), unarchive (233), delete (197), rename viaPATCH /sessions/:id(150) and create (88) all return without broadcasting anything.BroadcastFrameinbroadcast-bus.tshas no session frame. Only the tab that made the change updates its own store (store.ts:archiveSession474,unarchiveSession496,renameSession514).subscribedhandler (store.ts701-733) rebuilds thesessionsByIdentry from the snapshot, which has noarchivedAt. That drops the live row's archive flag.GET /sessions(routes.ts 73-79) setsarchivedAtdirectly on the objects held in the bridge's 10-secondsessionListCache(in-process.ts150-154 and 345-357) and never clears it. After an unarchive, a refresh can still returnarchivedAtfor up to 10 seconds. The cache is also not invalidated when a session is disposed (onDispose, in-process.ts 675-682).setSessionName(576) and new turns change the title,messageCountandupdatedAt, but the other tabs never hear about it.Decisions:
/session.GET /sessions, which is the only source for the list.Plan
Approach
Do the steps in this order. The tree typechecks after each step.
Protocol (
packages/protocol/src/index.ts)live: boolean;toSessionSummary(line 34) with the doc comment/** True when the server currently holds an active handle for this session. */.| { type: "sessions_changed" }to theServerFrameunion next totasks_changed(around line 1046), with the comment/** Broadcast frame: the session list changed (create/resume/dispose/archive/unarchive/rename/delete/turn end). Clients refetch. */.apps/server/src/broadcast-bus.ts, add| { type: "sessions_changed" }toBroadcastFrame.Server list is authoritative and never stale (
apps/server/src/bridge/in-process.ts)InProcessAgentBridge: ImportbroadcastBusfrom../broadcast-bus.ts.this.sessionListCache.clear()call (lines 233, 326, 386 and 399) withthis.notifySessionsChanged().onDisposecallback (line 675), callthis.notifySessionsChanged()afterthis.active.delete(sessionId).listSessions: the cache should keep only the rawsummarizeoutput read from disk. Build the return value fresh on every call, including cache hits:{ ...s, live: this.active.has(s.id) }. Never mutate the cached objects.this.activethat is missing from the disk list, append a summary built fromhandle.snapshot():{ id: snap.sessionId, path: snap.sessionFile ?? "", cwd: snap.cwd, title: snap.sessionName, createdAt: now, updatedAt: now, messageCount: snap.messages.length, live: true }, wherenowisnew Date().toISOString(). Ifopts.cwdis set, only include it whensnap.cwd === opts.cwd. OmittitlewhensessionNameis undefined (exactOptionalPropertyTypes-safe spread).summarize()(line 1699) must setlive: falseso its type matchesSessionSummary.onListChanged: () => voidargument toInProcessSessionHandle's constructor args (nearonDispose, line 993). Store it asprivate readonly onListChanged, and passonListChanged: () => this.notifySessionsChanged()where the handle is constructed (line 665). Callthis.onListChanged():setNameafter a successfulsetSessionName(after line 1630);maybeAutoTitleafter thesession_updatedemit (line 1612).actions.setSessionName(line 576), the bridge'sthisis in scope. Callthis.notifySessionsChanged()after theawait.this.notifySessionsChanged()whentype === "agent_end"only, somessageCountandupdatedAtrefresh once per prompt rather than once per turn.AgentBridgeimplementation or test stub that returnsSessionSummaryobjects must setlive. Find them withgrep -rn "messageCount" apps/server/src.Routes broadcast and stop mutating shared objects (
apps/server/src/routes.ts)GET /sessions: build the response assessions.map((s) => { const archivedAt = archivedMap.get(s.id); return archivedAt ? { ...s, archivedAt } : s; }). Do not assign intos.setArchived(id, true)(archive) andsetArchived(id, false)(unarchive), callbroadcastBus.broadcast({ type: "sessions_changed" }). Import it from./broadcast-bus.ts.bridge.deleteSession(step 2). Create and resume broadcast throughcreateSessionandresumeSession. Rename broadcasts throughsetName. Add nothing else in routes.Client: one list, refetched on broadcast (
apps/web/src/lib/store.ts)case "sessions_changed": void get().refreshSessions(); return;next tocase "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 onlyactiveId: s.activeId === id ? undefined : s.activeId, thenawait get().refreshSessions(). Delete the local edits tosessionsandsessionsById.unarchiveSession: after the API call,await get().refreshSessions(). Delete the local edits.renameSession(line 514): replace any local edit ofsessionswithawait get().refreshSessions(). Keep any update to thesessionsByIdsessionNamethat drives the open session's header.createSession(line 373) anddeleteSession: keep their existing refresh calls. They are redundant with the broadcast but harmless.archivedAtfrom the live session state type, so archive state lives only insessions. Find the remaining uses withgrep -rn archivedAt apps/web/src. After this change, onlySessionSummaryrows should carryarchivedAt.Sidebar partitions only on server data (
apps/web/src/components/Sidebar.tsx)useMemoat 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)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}onClick(selectSession+ navigate), with ids froms.id.Leave a session archived in another tab (
apps/web/src/views/SessionView.tsx)const archivedAt = useStore((s) => (id ? s.sessions.find((r) => r.id === id)?.archivedAt : undefined));,const navigate = useNavigate();andconst prevArchived = useRef(archivedAt);.[id, archivedAt]:idchanged since the last run, resetprevArchived.current = archivedAtand return. Track the previous id in a second ref.prevArchived.current === undefined && archivedAt !== undefined, callnavigate("/session").prevArchived.current = archivedAt.ArchivedNotice.Critical files & anchors
apps/server/src/bridge/in-process.ts:listSessions(345), theonDisposewiring (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 partitionuseMemo(39-53) and the live row renderers.Verification
bun run typecheckat the repo root passes.bun testinapps/serverpasses.http://127.0.0.1:8787/in tabs A and B, each on a different session):/session, and the row is under "Archived (n)" in both tabs.curl -s http://127.0.0.1:8787/api/sessions | jq '.sessions[] | {id, live, archivedAt}'returnslive: trueexactly 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 becauselivestill comes fromthis.active.renameSessioninstore.tsdoes not touchsessionstoday, step 4's rename change reduces to adding the refresh.session_disposedclient handler (store.ts 864) is never triggered by the server. This plan leaves it unchanged.