Merge main

This commit is contained in:
Bohdan Triapitsyn
2026-08-28 01:25:55 +03:00
726 changed files with 41703 additions and 15539 deletions
+31 -5
View File
@@ -18,7 +18,8 @@ There are **two distinct session data scopes** in the UI:
- Holds:
- global active sessions
- global archived sessions
- active sessions indexed by directory
- active and archived entities indexed by ID
- active root, parent/child, and directory indexes
These two scopes are intentionally different, but they are no longer equal peers for live UI truth.
@@ -43,11 +44,11 @@ So:
|---|---|---|
| `ChildStoreManager` and child directory stores | Priority-scheduled directory bootstrap plus `session`, `message`, `part`, `permission`, `question`, etc. | One runtime and one store per directory |
| `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID |
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime |
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots, plus a reference-stable active-ID membership collection maintained from the same mutations | All known directories in the active runtime |
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
| `session-ui-store.ts` | Session selection, draft lifecycle, one-shot draft-materialization transition identity, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active/archived entities plus root, parent/child, and directory indexes | All opened project/worktree session lists |
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments |
@@ -63,6 +64,10 @@ The composer compares normalized attachment MIME types with the selected model's
## Session list rules
### Layout-mounted session-list lifecycle
`MainLayout` and `VSCodeLayout` each call `useSessionListSync({ isVSCode })` directly and unconditionally, outside Sidebar visibility, responsive, editor, settings, and compact-view branches. The hook selects the real topology inputs, publishes complete directory bootstrap demand through `ChildStoreManager`, refreshes topology additions (including all VS Code directories on its first mount), coalesces OpenChamber control events for 500ms, and supplies a memoized complete global active+archived input to authoritative cleanup. The root-level global poller owns the initial global refresh. MainLayout includes available worktrees; VS Code intentionally excludes them. Sidebar-local `session-created` worktree discovery is separate and full-app-only.
### Directory bootstrap scheduling
`ChildStoreManager` is the single owner of directory bootstrap scheduling. Consumers publish demand; they must not start bootstrap from row mount effects.
@@ -113,6 +118,16 @@ Session materialization recency is keyed by runtime and directory. Foreground lo
Use `useGlobalSessionsStore` when the UI needs a **shared global session cache**.
Each full app root owns one global polling lifecycle through
`useGlobalSessionsPolling`. The web/desktop root and VS Code chat root load once
when mounted and refresh every 45 seconds so sessions created by another
OpenCode process are discovered without relying on the sidebar or native tray
being visible. Embedded chats and the VS Code agent-manager panel do not poll.
The sidebar and tray consume the same store and must not start their own
full-list timers. Surface-specific refreshes, such as opening the mobile session
sheet or returning from suspension, may still request freshness at their
explicit lifecycle edge; the store coalesces an overlapping in-flight load.
Current consumers:
- `useSessionAutoCleanup.ts`
@@ -201,7 +216,7 @@ The profiler also emits a user-timing mark when pending global-session recency i
Streaming assistant and reasoning text is throttled once before reaching the markdown renderer. The renderer incrementally reconciles changed markdown blocks but does not add a second character-pacing timer, which would multiply parse/morph work while catching up on large streamed chunks.
The event pipeline delivers each ordered per-directory flush as one reducer batch. Events retain their individual global indexes, notifications, cleanup, routing, materialization, and debug side effects, while their directory mutations accumulate in order and publish one store transaction per touched directory. Each top-level state slice is cloned lazily at most once in that batch; no-op events do not change references.
The event pipeline delivers each ordered per-directory flush as one reducer batch. Events retain their individual notifications, cleanup, routing, materialization, and debug side effects, while directory mutations accumulate in order and publish one store transaction per touched directory. Global session mutations and live status, ordering, and timing transitions also accumulate in event order and each owner publishes at most once for the flush. Each top-level state slice is cloned lazily at most once in that batch; no-op events do not change references.
Streaming lifecycle derivation has two paths. Directory attach, switch, bootstrap, and reconnect may perform a full reconciliation. Normal store publications reconcile only sessions whose `session_status` or `message` bucket changed; part-only events update the affected streaming message heartbeat directly and must not rescan all busy sessions.
@@ -253,6 +268,7 @@ Rules:
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation.
8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
9. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative.
Examples of global-store updates performed in `session-actions.ts`:
@@ -324,6 +340,16 @@ metadata and the next authoritative load reconciles it.
## The golden rule
### Managed chat directories
Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under `~/.config/openchamber/chats/YYYY-MM-DD/session-<id>` before creating the OpenCode session. The shared `~/.config/openchamber/chats` root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion removes that managed directory and never removes project directories.
Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory.
The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list.
VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively.
When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly.
```typescript
@@ -114,6 +114,22 @@ describe("applyDirectoryEvent", () => {
expect(draft.part.msg_1).toEqual([legacyPart, currentPart])
})
test("replaces an optimistic user part in place instead of appending it", () => {
const optimisticText = { id: "prt_optimistic_text", messageID: "msg_1", type: "text", text: "hi" } as Part
const optimisticFile = { id: "prt_optimistic_file", messageID: "msg_1", type: "file", filename: "a.png" } as Part
const serverText = { id: "prt_server_text", messageID: "msg_1", sessionID: "ses_1", type: "text", text: "hi" } as Part
const draft = state({
message: { ses_1: [{ id: "msg_1", sessionID: "ses_1", role: "user", time: { created: 1 } } as Message] },
part: { msg_1: [optimisticText, optimisticFile] },
})
expect(applyDirectoryEvent(draft, {
type: "message.part.updated",
properties: { part: serverText },
} as Event)).toBe(true)
expect(draft.part.msg_1).toEqual([serverText, optimisticFile])
})
test("returns typed materialization when delta arrives before parts", () => {
const result = applyDirectoryEvent(state(), deltaEvent())
@@ -4,6 +4,11 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto
const storage = new Map<string, string>()
const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = []
const permissionAutoAcceptCalls: Array<[string, boolean]> = []
const savedVariantCalls: Array<string | undefined> = []
let configVariantOverride: string | null | undefined
// Sync's session→directory index. `createSession` writes it, and directory
// resolution reads it as the authoritative source, so the mock has to keep one.
const sessionDirectoryRegistry = new Map<string, string>()
let createdSessionDirectory: string | undefined
const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? [])
@@ -73,6 +78,8 @@ mock.module("@/stores/utils/safeStorage", () => ({
mock.module("@/lib/opencode/client", () => ({
opencodeClient: {
getDirectory: () => null,
getFilesystemHome: mock(async () => "/home/test"),
createDirectory: mock(async (path: string) => ({ success: true, path })),
setDirectory: mock(() => undefined),
},
}))
@@ -91,6 +98,9 @@ mock.module("@/stores/useConfigStore", () => ({
useConfigStore: {
getState: () => ({
currentAgentName: "agent-default",
currentProviderId: "provider",
currentModelId: "model",
currentVariantSelection: { override: configVariantOverride, inherited: "high" },
agents: [],
activateDirectory: mock(async () => undefined),
applyDefaultModelAgentSelection: mock(() => undefined),
@@ -165,7 +175,9 @@ mock.module("../selection-store", () => ({
saveSessionModelSelection: () => undefined,
saveSessionAgentSelection: () => undefined,
saveAgentModelForSession: () => undefined,
saveAgentModelVariantForSession: () => undefined,
saveAgentModelVariantForSession: (_sessionId: string, _agent: string, _provider: string, _model: string, variant: string | undefined) => {
savedVariantCalls.push(variant)
},
getSessionAgentSelection: () => null,
getSessionModelSelection: () => null,
getAgentModelForSession: () => null,
@@ -239,13 +251,34 @@ mock.module("../sync-refs", () => ({
getSyncMessages: () => [],
getSyncParts: () => [],
getAllSyncSessions: () => [],
getSyncSessionDirectory: () => null,
getSyncSessionDirectory: (sessionId: string) => sessionDirectoryRegistry.get(sessionId) ?? null,
registerSessionDirectory: (sessionId: string, directory: string) => {
sessionDirectoryRegistry.set(sessionId, directory)
},
}))
mock.module("../session-actions", () => ({
createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => {
// Mirrors the real action's authoritative steps: the created session becomes
// current under the directory the server confirmed, and that directory enters
// the routing index. Everything these tests assert about routing depends on
// those two, so a mock without them tests nothing.
createSession: mock(async (
title: string | undefined,
directory: string | null,
parentID: string | null,
metadata?: unknown,
selectionTransition?: "submitted-draft",
) => {
createSessionCalls.push({ title, directory, parentID, metadata })
return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
const session = { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory }
const sessionDirectory = session.directory ?? null
if (sessionDirectory) {
sessionDirectoryRegistry.set(session.id, sessionDirectory)
}
const { useSessionUIStore: store } = await import("../session-ui-store")
store.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
store.getState().markSessionAsOpenChamberCreated(session.id)
return session
}),
deleteSession: mock(async () => true),
deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })),
@@ -320,16 +353,21 @@ describe("issue 2039 draft auto-accept", () => {
beforeEach(() => {
storage.clear()
createSessionCalls.length = 0
sessionDirectoryRegistry.clear()
permissionAutoAcceptCalls.length = 0
savedVariantCalls.length = 0
configVariantOverride = undefined
createdSessionDirectory = undefined
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: {
draftId: 0,
open: false,
directoryOverride: null,
parentID: null,
target: "chat",
},
})
})
@@ -355,6 +393,29 @@ describe("issue 2039 draft auto-accept", () => {
expect(useSessionUIStore.getState().currentSessionId).toBe("ses_issue_2039")
})
test("stores only an explicit draft variant as the session override", async () => {
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
agent: "agent-default",
variant: "high",
})
expect(savedVariantCalls).toEqual([undefined])
configVariantOverride = "high"
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({
providerID: "provider",
modelID: "model",
agent: "agent-default",
variant: "high",
})
expect(savedVariantCalls).toEqual([undefined, "high"])
})
test("does not apply draft auto-accept after the draft is closed", async () => {
useSessionUIStore.getState().openNewSessionDraft()
useSessionUIStore.getState().setDraftPermissionAutoAcceptEnabled(true)
@@ -144,6 +144,37 @@ describe("materializeSessionSnapshots", () => {
expect(result.message.ses_1[0]).not.toBe(staleMessage)
})
test("replaces a locally aborted assistant message with the authoritative completed snapshot", () => {
const unfinishedMessage = message("msg_1")
if (unfinishedMessage.role !== "assistant") throw new Error("Expected assistant fixture")
const abortedMessage: Message = {
...unfinishedMessage,
time: { created: 1, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" } },
}
const completedMessage: Message = {
...unfinishedMessage,
time: { created: 1, completed: 4000 },
}
const state = {
message: { ses_1: [abortedMessage] },
part: { msg_1: [] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: completedMessage, parts: [] }],
)
const reconciled = result.message.ses_1[0]
expect(reconciled).toBe(completedMessage)
expect(reconciled?.role).toBe("assistant")
if (reconciled?.role !== "assistant") throw new Error("Expected assistant result")
expect("error" in reconciled).toBe(false)
expect(reconciled.time.completed).toBe(4000)
})
test("does not preserve omitted optimistic user text parts beside server snapshot parts", () => {
const optimisticPart = { id: "prt_optimistic", messageID: "msg_1", type: "text", text: "Hello" } as Part
const serverPart = part("prt_server", "msg_1", "text", "Hello")
@@ -4,6 +4,7 @@ import type { Event, Session } from "@opencode-ai/sdk/v2/client"
let currentSessions: Session[] = []
const upsertedSessions: Session[] = []
const removedSessionIds: string[] = []
let mutationCalls = 0
let runtimeKey = "runtime-a"
let runtimeWillChange: (() => void) | null = null
@@ -15,6 +16,7 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
getState: () => ({
activeSessions: currentSessions,
archivedSessions: [] as Session[],
entityById: new Map(currentSessions.map((session) => [session.id, session])),
upsertSession: (session: Session) => {
upsertedSessions.push(session)
},
@@ -24,6 +26,15 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({
removeSessions: (ids: string[]) => {
removedSessionIds.push(...ids)
},
applySessionMutations: (mutations: Array<
{ type: "upsert"; session: Session } | { type: "remove"; sessionId: string }
>) => {
mutationCalls += 1
for (const mutation of mutations) {
if (mutation.type === "upsert") upsertedSessions.push(mutation.session)
else removedSessionIds.push(mutation.sessionId)
}
},
}),
},
}))
@@ -34,7 +45,7 @@ mock.module("@/lib/runtime-switch", () => ({
return () => undefined
},
}))
import { applySessionEventToGlobalSessions } from "../session-event-router"
import { applySessionEventsToGlobalSessions, applySessionEventToGlobalSessions } from "../session-event-router"
const buildSession = (title: string, time: Session["time"]): Session => ({
id: "ses_1",
@@ -66,6 +77,7 @@ describe("applySessionEventToGlobalSessions", () => {
currentSessions = []
upsertedSessions.length = 0
removedSessionIds.length = 0
mutationCalls = 0
})
test("skips stale global session.updated echoes after a newer rename", () => {
@@ -116,4 +128,22 @@ describe("applySessionEventToGlobalSessions", () => {
expect(upsertedSessions).toEqual([])
})
test("commits an ordered event batch once", () => {
const events = Array.from({ length: 1_000 }, (_, index) => ({
type: "session.created",
properties: {
info: {
id: `ses_${index}`,
title: `Session ${index}`,
time: { created: index, updated: index },
},
},
} as Event))
applySessionEventsToGlobalSessions(events)
expect(mutationCalls).toBe(1)
expect(upsertedSessions).toHaveLength(1_000)
})
})
+52
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test';
import {
ChildStoreManager,
type DirectoryBootstrapContext,
markDirectorySessionPartChanged,
subscribeDirectoryPermission,
subscribeDirectoryQuestion,
@@ -558,3 +559,54 @@ describe('ChildStoreManager directory bootstrap scheduler', () => {
manager.disposeAll();
});
});
describe('ChildStoreManager bootstrap context liveness', () => {
test('isCurrent stays true after the run settles so deferred recovery work can commit', async () => {
const manager = new ChildStoreManager();
let captured: DirectoryBootstrapContext | undefined;
const cleanup = manager.configure({
onBootstrap: (context) => {
captured = context;
},
});
manager.requestBootstrap({ directory: '/workspace', priority: 'selected', reason: 'current-directory' });
await settle();
expect(manager.getBootstrapState('/workspace')).toBe('complete');
// bootstrapDirectory schedules deferred recovery pulls (permission.list
// and friends) from a setTimeout(0), which always runs after the pump's
// .finally() has cleaned up the run entry. isCurrent must remain true
// there, or those pulls and every commit they make get skipped.
await new Promise((resolve) => setTimeout(resolve, 0));
expect(captured?.isCurrent()).toBe(true);
cleanup();
expect(captured?.isCurrent()).toBe(false);
manager.disposeAll();
});
test('a newer same-directory run invalidates the previous context', async () => {
const manager = new ChildStoreManager();
const contexts: DirectoryBootstrapContext[] = [];
const cleanup = manager.configure({
onBootstrap: (context) => {
contexts.push(context);
},
});
manager.requestBootstrap({ directory: '/workspace', priority: 'selected', reason: 'current-directory' });
await settle();
expect(contexts[0]?.isCurrent()).toBe(true);
// A forced rerun for the same directory must retire the previous
// context: its in-flight deferred responses may no longer commit over
// whatever the newer run synchronizes.
manager.requestBootstrap({ directory: '/workspace', priority: 'selected', reason: 'server-connected', force: true });
await settle();
expect(contexts).toHaveLength(2);
expect(contexts[0]?.isCurrent()).toBe(false);
expect(contexts[1]?.isCurrent()).toBe(true);
cleanup();
manager.disposeAll();
});
});
+17 -2
View File
@@ -307,6 +307,8 @@ export class ChildStoreManager {
private bootstrapConcurrency = 2
private bootstrapGeneration = 0
private bootstrapSequence = 0
private bootstrapRunSequence = 0
private readonly directoryBootstrapRuns = new Map<string, number>()
private manualBootstrapDemandRevision = 0
private disposed = false
@@ -596,11 +598,23 @@ export class ChildStoreManager {
queuedMs: Math.max(0, Date.now() - next.enqueuedAt),
})
// Store liveness, not run-token ownership. The pump deletes the run
// token in `.finally()` as soon as onBootstrap settles, while
// bootstrapDirectory schedules deferred recovery pulls (permission.list
// and friends) from a `setTimeout(0)` that always runs after that
// cleanup — gating those on the token made them dead code. A per-
// directory run sequence keeps the context current across settle (so
// deferred pulls commit) while invalidating it as soon as a newer run
// starts, so a late deferred response cannot overwrite a newer run's
// state. Mirrors the isCurrent contract in session-message-loader.
const runSequence = ++this.bootstrapRunSequence
this.directoryBootstrapRuns.set(next.directory, runSequence)
const store = this.children.get(next.directory)
const isCurrent = () => (
!this.disposed
&& this.bootstrapGeneration === running.generation
&& this.runningBootstraps.get(next.directory)?.token === token
&& this.children.has(next.directory)
&& this.directoryBootstrapRuns.get(next.directory) === runSequence
&& this.children.get(next.directory) === store
)
let bootstrapPromise: Promise<void>
try {
@@ -673,6 +687,7 @@ export class ChildStoreManager {
this.manualBootstrapDemands.delete(directory)
this.bootstrapStates.delete(directory)
this.bootstrapFailures.delete(directory)
this.directoryBootstrapRuns.delete(directory)
for (const demands of this.bootstrapDemandsByOwner.values()) demands.delete(directory)
this.children.delete(directory)
this.notifyRegistrySubscribers()
+5 -2
View File
@@ -441,9 +441,12 @@ export function applyDirectoryEvent(
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
: -1
if (optimisticIndex >= 0) {
next.splice(optimisticIndex, 1)
// Replace in place: pushing to the end reorders text/file parts of a
// just-sent message and remounts its rendered subtree.
next[optimisticIndex] = part
} else {
next.push(part)
}
next.push(part)
}
draft.part[messageID] = next
return missingOwningMessage
@@ -2,17 +2,23 @@ import { beforeEach, describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2/client"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
applyGlobalSessionStatusSnapshot,
useGlobalSessionStatusStore,
replaceGlobalSessionStatusById,
} from "./global-session-status"
import { resetSessionOrdering, useSessionOrderingStore } from "./session-ordering"
import { resetSessionActivityTiming, useSessionActivityTimingStore } from "./session-activity-timing"
beforeEach(() => {
useGlobalSessionStatusStore.setState({ statusById: new Map() })
replaceGlobalSessionStatusById(new Map())
resetSessionOrdering()
resetSessionActivityTiming()
})
describe("global session status index", () => {
const activeSessionIds = (): ReadonlySet<string> => useGlobalSessionStatusStore.getState().activeSessionIds
test("preserves full retry status details from live events", () => {
applyGlobalSessionStatusEvent("/repo", {
type: "session.status",
@@ -29,6 +35,62 @@ describe("global session status index", () => {
})
})
test("keeps active membership stable across active status detail and directory updates", () => {
applyGlobalSessionStatusEvent("/repo", {
type: "session.status",
properties: { sessionID: "session-a", status: { type: "busy" } },
} as Event)
const before = activeSessionIds()
applyGlobalSessionStatusEvent("/other-repo", {
type: "session.status",
properties: { sessionID: "session-a", status: { type: "retry", attempt: 2, message: "waiting" } },
} as Event)
expect(activeSessionIds()).toBe(before)
})
test("replaces active membership only when a session becomes idle or active", () => {
applyGlobalSessionStatusEvent("/repo", {
type: "session.status",
properties: { sessionID: "session-a", status: { type: "busy" } },
} as Event)
const active = activeSessionIds()
applyGlobalSessionStatusEvent("/repo", {
type: "session.idle",
properties: { sessionID: "session-a" },
} as Event)
const idle = activeSessionIds()
expect(idle).not.toBe(active)
expect(idle?.has("session-a")).toBe(false)
applyGlobalSessionStatusEvent("/repo", {
type: "session.status",
properties: { sessionID: "session-a", status: { type: "busy" } },
} as Event)
expect(activeSessionIds()).not.toBe(idle)
expect(activeSessionIds()?.has("session-a")).toBe(true)
})
test("removes deleted sessions from active membership", () => {
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
applyGlobalSessionStatusEvent("/repo", {
type: "session.status",
properties: { sessionID: "session-a", status: { type: "busy" } },
} as Event)
const active = activeSessionIds()
applyGlobalSessionStatusEvent("/repo", {
type: "session.deleted",
properties: { sessionID: "session-a" },
} as Event)
expect(activeSessionIds()).not.toBe(active)
expect(activeSessionIds().has("session-a")).toBe(false)
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
})
test("promotes on active and settled lifecycle edges only", () => {
applyGlobalSessionStatusEvent("/repo", {
type: "session.status",
@@ -64,6 +126,48 @@ describe("global session status index", () => {
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
})
test("keeps active membership stable for snapshots with the same active IDs", () => {
applyGlobalSessionStatusSnapshot("/repo", { "session-a": { type: "busy" } }, ["session-a"])
const before = activeSessionIds()
applyGlobalSessionStatusSnapshot("/repo", {
"session-a": { type: "retry" },
}, ["session-a"])
expect(activeSessionIds()).toBe(before)
})
test("updates active membership when a snapshot adds and removes IDs", () => {
applyGlobalSessionStatusSnapshot("/repo", { "session-a": { type: "busy" } }, ["session-a"])
const before = activeSessionIds()
applyGlobalSessionStatusSnapshot("/repo", {
"session-a": { type: "busy" },
"session-b": { type: "busy" },
}, ["session-a", "session-b"])
const added = activeSessionIds()
expect(added).not.toBe(before)
expect(added?.has("session-a")).toBe(true)
expect(added?.has("session-b")).toBe(true)
applyGlobalSessionStatusSnapshot("/repo", { "session-b": { type: "busy" } }, ["session-a", "session-b"])
const removed = activeSessionIds()
expect(removed).not.toBe(added)
expect(removed?.has("session-a")).toBe(false)
expect(removed?.has("session-b")).toBe(true)
})
test("clears active membership when a runtime reset replaces status state", () => {
applyGlobalSessionStatusEvent("/repo", {
type: "session.status",
properties: { sessionID: "session-a", status: { type: "busy" } },
} as Event)
replaceGlobalSessionStatusById(new Map())
expect(activeSessionIds()?.size).toBe(0)
})
test("clears an explicitly idle known session when directory aliases differ", () => {
applyGlobalSessionStatusSnapshot("/canonical/repo", { "session-a": { type: "busy" } }, ["session-a"])
@@ -71,4 +175,44 @@ describe("global session status index", () => {
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
})
test("publishes status, ordering, and timing once for a large event batch", () => {
let statusPublications = 0
let orderingPublications = 0
let timingPublications = 0
const unsubscribeStatus = useGlobalSessionStatusStore.subscribe(() => { statusPublications += 1 })
const unsubscribeOrdering = useSessionOrderingStore.subscribe(() => { orderingPublications += 1 })
const unsubscribeTiming = useSessionActivityTimingStore.subscribe(() => { timingPublications += 1 })
const events = Array.from({ length: 1_000 }, (_, index) => ({
type: "session.status",
properties: { sessionID: `session-${index}`, status: { type: "busy" } },
} as Event))
applyGlobalSessionStatusEvents("/repo", events)
unsubscribeStatus()
unsubscribeOrdering()
unsubscribeTiming()
expect(useGlobalSessionStatusStore.getState().activeSessionIds.size).toBe(1_000)
expect(statusPublications).toBe(1)
expect(orderingPublications).toBe(1)
expect(timingPublications).toBe(1)
})
test("keeps lifecycle event order inside a batch", () => {
applyGlobalSessionStatusEvents("/repo", [
{
type: "session.status",
properties: { sessionID: "session-a", status: { type: "busy" } },
} as Event,
{
type: "session.deleted",
properties: { sessionID: "session-a" },
} as Event,
])
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
expect(useSessionOrderingStore.getState().rankById.has("session-a")).toBe(false)
expect(useSessionActivityTimingStore.getState().startedAt.has("session-a")).toBe(false)
})
})
+127 -54
View File
@@ -2,15 +2,16 @@ import { create } from 'zustand';
import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client';
import { normalizeProjectPath } from '@/lib/projectResolution';
import {
observeSessionActivityEvent,
applySessionOrderingMutations,
reconcileSessionActivitySnapshot,
removeSessionOrdering,
type SessionOrderingMutation,
} from './session-ordering';
import {
observeSessionActivityTiming,
applySessionActivityTimingMutations,
reconcileSessionActivityTiming,
removeSessionActivityTiming,
type SessionActivityTimingMutation,
} from './session-activity-timing';
import { countSyncPerformance } from './performance-diagnostics';
// Shared live busy/retry index for every directory. Global events update it
// incrementally and authoritative directory snapshots reconcile it, so each
@@ -26,13 +27,43 @@ type GlobalSessionStatusEntry = { status: SessionStatus; directory: string };
type GlobalSessionStatusState = {
statusById: Map<string, GlobalSessionStatusEntry>;
activeSessionIds: ReadonlySet<string>;
};
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => ({
statusById: new Map(),
}));
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => {
const initialState: GlobalSessionStatusState = {
statusById: new Map(),
activeSessionIds: EMPTY_ACTIVE_SESSION_IDS,
};
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => initialState);
useGlobalSessionStatusStore.subscribe(() => countSyncPerformance('globalStatusPublications'));
/**
* Replaces the status map wholesale and derives active membership from it.
* This is the ONE sanctioned way to swap statusById from outside the event
* reducers (runtime switch, tests) previously a setState monkeypatch
* derived membership for arbitrary callers, which silently trusted any
* caller passing both fields to keep them consistent.
*/
export const replaceGlobalSessionStatusById = (statusById: Map<string, GlobalSessionStatusEntry>): void => {
const current = useGlobalSessionStatusStore.getState();
const nextActiveSessionIds = new Set<string>();
for (const [sessionId, entry] of statusById) {
if (entry.status.type === 'busy' || entry.status.type === 'retry') {
nextActiveSessionIds.add(sessionId);
}
}
const sameMembership = nextActiveSessionIds.size === current.activeSessionIds.size
&& [...nextActiveSessionIds].every((sessionId) => current.activeSessionIds.has(sessionId));
useGlobalSessionStatusStore.setState({
statusById,
activeSessionIds: sameMembership ? current.activeSessionIds : nextActiveSessionIds,
});
};
const normalizeStatusType = (type: string | undefined): ActiveStatusType | 'idle' => {
if (type === 'busy') return 'busy';
if (type === 'retry') return 'retry';
return 'idle';
@@ -48,63 +79,84 @@ const statusesEqual = (left: SessionStatus, right: SessionStatus): boolean => (
const normalizeDirectory = (directory: string): string =>
normalizeProjectPath(directory) ?? directory;
const setStatus = (sessionId: string, directory: string, status: SessionStatus | { type: 'idle' }): void => {
useGlobalSessionStatusStore.setState((state) => {
const current = state.statusById.get(sessionId);
if (status.type === 'idle') {
if (!current) return state;
const next = new Map(state.statusById);
next.delete(sessionId);
return { statusById: next };
}
if (current && current.directory === directory && statusesEqual(current.status, status)) return state;
const next = new Map(state.statusById);
next.set(sessionId, { status, directory });
return { statusById: next };
});
};
// Event-driven path: called by the sync dispatcher for status-bearing events
// whose directory has no child store. Mirrors the child reducer's semantics
// (`session.idle` / `session.error` both resolve to idle).
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
switch (payload.type) {
case 'session.status': {
export const applyGlobalSessionStatusEvents = (directory: string, payloads: readonly Event[]): void => {
if (payloads.length === 0) return;
const normalizedDirectory = normalizeDirectory(directory);
const state = useGlobalSessionStatusStore.getState();
let statusById: Map<string, GlobalSessionStatusEntry> | null = null;
let activeSessionIds: Set<string> | null = null;
const orderingMutations: SessionOrderingMutation[] = [];
const timingMutations: SessionActivityTimingMutation[] = [];
const currentStatuses = (): ReadonlyMap<string, GlobalSessionStatusEntry> => statusById ?? state.statusById;
const draftStatuses = (): Map<string, GlobalSessionStatusEntry> => (statusById ??= new Map(state.statusById));
const draftActiveIds = (): Set<string> => (activeSessionIds ??= new Set(state.activeSessionIds));
const settle = (sessionId: string): void => {
if (currentStatuses().has(sessionId)) {
draftStatuses().delete(sessionId);
draftActiveIds().delete(sessionId);
}
orderingMutations.push({ type: 'observe', sessionId, phase: 'settled' });
timingMutations.push({ type: 'observe', sessionId, phase: 'settled' });
};
for (const payload of payloads) {
if (payload.type === 'session.status') {
// SAFETY: OpenCode event properties for this event contain the optional session ID and status payload.
const props = payload.properties as { sessionID?: string; status?: { type?: string } } | undefined;
if (typeof props?.sessionID !== 'string' || !props.sessionID) return;
if (typeof props?.sessionID !== 'string' || !props.sessionID) continue;
const type = normalizeStatusType(props.status?.type);
setStatus(
props.sessionID,
normalizeDirectory(directory),
type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus,
);
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
// `retry` is still a running turn, so the elapsed counter keeps going.
observeSessionActivityTiming(props.sessionID, type === 'idle' ? 'settled' : 'active');
return;
}
case 'session.idle':
case 'session.error': {
const props = payload.properties as { sessionID?: string } | undefined;
if (typeof props?.sessionID === 'string' && props.sessionID) {
setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' });
observeSessionActivityEvent(props.sessionID, 'settled');
observeSessionActivityTiming(props.sessionID, 'settled');
if (type === 'idle') {
settle(props.sessionID);
continue;
}
return;
// SAFETY: the normalized discriminator is one of the SDK's active status types.
const status = { ...(props.status ?? {}), type } as SessionStatus;
const current = currentStatuses().get(props.sessionID);
if (!current || current.directory !== normalizedDirectory || !statusesEqual(current.status, status)) {
draftStatuses().set(props.sessionID, { status, directory: normalizedDirectory });
if (!current) draftActiveIds().add(props.sessionID);
}
orderingMutations.push({ type: 'observe', sessionId: props.sessionID, phase: 'active' });
timingMutations.push({ type: 'observe', sessionId: props.sessionID, phase: 'active' });
continue;
}
case 'session.deleted': {
if (payload.type === 'session.idle' || payload.type === 'session.error') {
// SAFETY: OpenCode terminal event properties contain the optional addressed session ID.
const props = payload.properties as { sessionID?: string } | undefined;
if (typeof props?.sessionID === 'string' && props.sessionID) settle(props.sessionID);
continue;
}
if (payload.type === 'session.deleted') {
// SAFETY: OpenCode deletion event properties identify the deleted session directly or through info.id.
const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined;
const sessionId = props?.sessionID ?? props?.info?.id;
if (sessionId) {
removeSessionOrdering(sessionId);
removeSessionActivityTiming(sessionId);
if (!sessionId) continue;
if (currentStatuses().has(sessionId)) {
draftStatuses().delete(sessionId);
draftActiveIds().delete(sessionId);
}
return;
orderingMutations.push({ type: 'remove', sessionId });
timingMutations.push({ type: 'remove', sessionId });
}
default:
return;
}
if (statusById) {
useGlobalSessionStatusStore.setState({
statusById,
activeSessionIds: activeSessionIds ?? state.activeSessionIds,
});
}
applySessionOrderingMutations(orderingMutations);
applySessionActivityTimingMutations(timingMutations);
};
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
applyGlobalSessionStatusEvents(directory, [payload]);
};
// Polled path: an authoritative `/session/status?directory=X` snapshot. Entries
@@ -137,10 +189,25 @@ export const applyGlobalSessionStatusSnapshot = (
useGlobalSessionStatusStore.setState((state) => {
let changed = false;
const next = new Map(state.statusById);
let nextActiveSessionIds: Set<string> | null = null;
const hasActiveSession = (sessionId: string): boolean => (
(nextActiveSessionIds ?? state.activeSessionIds).has(sessionId)
);
const removeActiveSession = (sessionId: string): void => {
if (!hasActiveSession(sessionId)) return;
nextActiveSessionIds ??= new Set(state.activeSessionIds);
nextActiveSessionIds.delete(sessionId);
};
const addActiveSession = (sessionId: string): void => {
if (hasActiveSession(sessionId)) return;
nextActiveSessionIds ??= new Set(state.activeSessionIds);
nextActiveSessionIds.add(sessionId);
};
for (const [sessionId, entry] of state.statusById) {
if ((entry.directory === directory || known.has(sessionId)) && !(sessionId in raw)) {
next.delete(sessionId);
removeActiveSession(sessionId);
changed = true;
}
}
@@ -151,17 +218,23 @@ export const applyGlobalSessionStatusSnapshot = (
if (type === 'idle') {
if (current && (current.directory === directory || known.has(sessionId))) {
next.delete(sessionId);
removeActiveSession(sessionId);
changed = true;
}
continue;
}
// SAFETY: normalizeStatusType has narrowed this snapshot entry to the SDK's busy/retry status discriminator.
const normalizedStatus = { ...status, type } as SessionStatus;
if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) {
next.set(sessionId, { status: normalizedStatus, directory });
if (!current) addActiveSession(sessionId);
changed = true;
}
}
return changed ? { statusById: next } : state;
return changed ? {
statusById: next,
activeSessionIds: nextActiveSessionIds ?? state.activeSessionIds,
} : state;
});
};
+2
View File
@@ -4,6 +4,7 @@
*/
import { create } from "zustand"
import type { ContextPartMetadata } from '@/lib/messages/contextParts'
import type { AttachedFile } from "@/stores/types/sessionTypes"
import { prepareAttachmentFiles } from "./attachment-files"
@@ -115,6 +116,7 @@ export type SyntheticContextPart = {
text: string
attachments?: AttachedFile[]
synthetic?: boolean
metadata?: ContextPartMetadata
}
export type VSCodeActiveEditorFile = {
+15 -1
View File
@@ -270,7 +270,21 @@ export function materializeSessionSnapshots(
const snapshots = nextMessages.map((message) => recordsByMessageID.get(message.id)!)
const existingMessages = state.message[sessionID]
const currentMessages = existingMessages ?? []
const messages = mergeMessages(currentMessages, nextMessages)
const incomingByID = new Map(nextMessages.map((message) => [message.id, message] as const))
let reconciledCurrentMessages = currentMessages
for (let index = 0; index < currentMessages.length; index += 1) {
const existing = currentMessages[index]
const incoming = incomingByID.get(existing.id)
if (
existing.role !== "assistant"
|| existing.error?.name !== "MessageAbortedError"
|| incoming?.role !== "assistant"
|| incoming.time.completed === undefined
) continue
if (reconciledCurrentMessages === currentMessages) reconciledCurrentMessages = [...currentMessages]
reconciledCurrentMessages[index] = incoming
}
const messages = mergeMessages(reconciledCurrentMessages, nextMessages)
const messagesChanged = messages !== currentMessages || (existingMessages === undefined && snapshots.length === 0)
let partsChanged = false
@@ -16,6 +16,15 @@ export type SyncPerformanceCounters = {
reducerEvents: number
reducerChangedEvents: number
directoryStorePublications: number
globalSessionPublications: number
globalStatusPublications: number
orderingPublications: number
timingPublications: number
liveSessionAggregateRuns: number
sidebarStructureBuilds: number
sidebarOrderBuilds: number
sidebarOrderMetadataEntries: number
recentCandidatesVisited: number
streamingFullReconciliations: number
streamingIncrementalReconciliations: number
streamingStatusEntriesVisited: number
@@ -50,6 +59,15 @@ const createCounters = (): SyncPerformanceCounters => ({
reducerEvents: 0,
reducerChangedEvents: 0,
directoryStorePublications: 0,
globalSessionPublications: 0,
globalStatusPublications: 0,
orderingPublications: 0,
timingPublications: 0,
liveSessionAggregateRuns: 0,
sidebarStructureBuilds: 0,
sidebarOrderBuilds: 0,
sidebarOrderMetadataEntries: 0,
recentCandidatesVisited: 0,
streamingFullReconciliations: 0,
streamingIncrementalReconciliations: 0,
streamingStatusEntriesVisited: 0,
+12 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { switchRuntimeEndpoint } from "@/lib/runtime-switch"
import { persistSessions, readDirCache } from "./persist-cache"
import { persistManagedChatSessions, persistSessions, readDirCache, readManagedChatSessions } from "./persist-cache"
import { getSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from "./performance-diagnostics"
class TestStorage implements Storage {
@@ -81,6 +81,17 @@ afterEach(() => {
})
describe("persisted directory sessions", () => {
test("keeps one runtime-scoped startup snapshot for managed chats", async () => {
const chat = session(1, 2, "Chat", "/home/user/.config/openchamber/chats/2026-08-21/session-a")
persistManagedChatSessions([session(2, 3), chat])
await waitForPersistence()
expect(readManagedChatSessions().map((item) => item.id)).toEqual([chat.id])
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-other.test", runtimeKey: "runtime-other" })
expect(readManagedChatSessions()).toEqual([])
})
test("keeps the 50 most recently updated sessions across restart reads", async () => {
const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated))
+18
View File
@@ -10,11 +10,14 @@ import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProjectMeta } from "./types"
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics"
import { isChatDirectoryPath } from "@/lib/chatDirectories"
import { isVSCodeRuntime } from "@/lib/desktop"
/** Cap persisted session lists so localStorage stays bounded per directory. */
const PERSISTED_SESSION_LIMIT = 50
const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const
const SESSION_PERSIST_DEBOUNCE_MS = 50
const MANAGED_CHATS_CACHE_SCOPE = "openchamber:managed-chats"
type PendingSessionWrite = {
runtimeKey: string
@@ -241,6 +244,21 @@ export function persistSessions(directory: string, sessions: Session[] | undefin
scheduleSessionCacheWrite(directory, sessions)
}
export function readManagedChatSessions(expectedRuntimeKey = getRuntimeKey()): Session[] {
if (isVSCodeRuntime()) return []
if (expectedRuntimeKey !== getRuntimeKey()) return []
return readDirCache(MANAGED_CHATS_CACHE_SCOPE).sessions?.filter((session) => (
isChatDirectoryPath(session.directory)
)) ?? []
}
export function persistManagedChatSessions(sessions: Session[]): void {
if (isVSCodeRuntime()) return
persistSessions(MANAGED_CHATS_CACHE_SCOPE, sessions.filter((session) => (
isChatDirectoryPath(session.directory)
)))
}
/** Write vcs info to cache */
export function persistVcs(directory: string, vcs: VcsInfo | undefined): void {
writeCache(directory, "vcs", vcs)
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test';
import { retry } from './retry';
describe('retry transient classification (#2470)', () => {
test("'terminated' (undici half-open socket teardown) is retried", async () => {
let attempts = 0;
await expect(
retry(async () => {
attempts += 1;
throw new TypeError('terminated');
}),
).rejects.toThrow('terminated');
expect(attempts).toBe(3);
});
test("normalized 'request timed out' (SDK read timeout) is retried", async () => {
let attempts = 0;
await expect(
retry(async () => {
attempts += 1;
throw new Error('OpenCode request timed out after 30000ms');
}),
).rejects.toThrow('request timed out');
expect(attempts).toBe(3);
});
test('caller-initiated abort (AbortError) is NOT retried', async () => {
let attempts = 0;
await expect(
retry(async () => {
attempts += 1;
throw new DOMException('Aborted', 'AbortError');
}),
).rejects.toThrow('Aborted');
expect(attempts).toBe(1);
});
});
+6
View File
@@ -6,6 +6,10 @@ export interface RetryOptions {
retryIf?: (error: unknown) => boolean
}
// undici tears down half-open upstream connection with `TypeError: terminated`
// (exact failure from the #2470 logs); the SDK client also rejects reads with
// normalized "request timed out" error after OPENCODE_REQUEST_TIMEOUT_MS.
// Both are transient — managed process may be restarting.
const TRANSIENT_MESSAGES = [
"load failed",
"network connection was lost",
@@ -18,6 +22,8 @@ const TRANSIENT_MESSAGES = [
"opencode api unavailable",
"503",
"502",
"terminated",
"request timed out",
]
function isTransientError(error: unknown): boolean {
+134 -2
View File
@@ -13,6 +13,9 @@ let permissionReplyError: unknown | null = null
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionUpdateResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let sessionMessagesResult: { data?: unknown; error?: unknown; response?: { status?: number } } = { data: [] }
const sessionMessageRecords = new Map<string, Array<{ info: Message; parts: Part[] }>>()
const failingRevertSessionIds = new Set<string>()
const failingUnrevertSessionIds = new Set<string>()
let sessionDeleteError: unknown | null = null
let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null
let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null
@@ -68,6 +71,13 @@ const mockSdk = {
replyCalls.push({ method: "session.revert", params })
return Promise.resolve(sessionRevertResult)
}),
unrevert: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.unrevert", params })
if (failingUnrevertSessionIds.has(String(params.sessionID))) {
return Promise.resolve({ error: { message: "rejected" }, response: { status: 500 } })
}
return Promise.resolve({ data: { id: params.sessionID, time: { created: 1 } } })
}),
abort: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "session.abort", params })
return Promise.resolve({ data: true })
@@ -125,7 +135,12 @@ mock.module("@/lib/opencode/client", () => ({
return mockScopedClient
},
getDirectory: () => "/test/project",
getFilesystemHome: mock(async () => "/home/test"),
getSdkClient: () => mockSdk,
getSessionMessages: mock((sessionId: string, _limit?: number, directory?: string | null) => {
replyCalls.push({ method: "session.messages", params: { sessionID: sessionId, directory } })
return Promise.resolve(sessionMessageRecords.get(sessionId) ?? [])
}),
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
return Promise.resolve(true)
@@ -139,11 +154,11 @@ mock.module("@/lib/opencode/client", () => ({
method: "session.revert",
params: { sessionID: sessionId, messageID: messageId, partID: partId, directory },
})
if (sessionRevertResult.error) {
if (sessionRevertResult.error || failingRevertSessionIds.has(sessionId)) {
const status = sessionRevertResult.response?.status
throw new Error(`session.revert failed${status ? ` (${status})` : ""}: rejected`)
}
return Promise.resolve(sessionRevertResult.data)
return Promise.resolve(sessionRevertResult.data ?? { id: sessionId, time: { created: 1 }, revert: { messageID: messageId } })
}),
updateSession: mock((sessionId: string, changes: Record<string, unknown>, directory?: string | null) => {
replyCalls.push({ method: "session.update", params: { sessionID: sessionId, ...changes, directory } })
@@ -1292,6 +1307,8 @@ describe("revertToMessage passes session directory", () => {
replyCalls.length = 0
scopedClientDirectories.length = 0
sessionRevertResult = {}
sessionMessageRecords.clear()
failingRevertSessionIds.clear()
Object.assign(inputState, {
pendingInputText: "previous draft",
pendingInputMode: "normal" as const,
@@ -1353,6 +1370,121 @@ describe("revertToMessage passes session directory", () => {
expect((sessionStore.getState().session[0] as Session & { revert?: { messageID?: string } }).revert).toBe(undefined)
expect(inputState.pendingInputText).toBe("previous draft")
})
test("reverts recursive descendants at their first user message on or after the parent cutoff", async () => {
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 } },
{ id: "child", parentID: "root", directory: "/tree", time: { created: 2 } },
{ id: "grandchild", parentID: "child", directory: "/tree", time: { created: 3 } },
{ id: "old-child", parentID: "root", directory: "/tree", time: { created: 4 } },
] as Session[]
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
sessionMessageRecords.set("child", [
{ info: { id: "child-before", sessionID: "child", role: "user", time: { created: 10 } } as Message, parts: [] },
{ info: { id: "child-boundary", sessionID: "child", role: "user", time: { created: 20 } } as Message, parts: [] },
{ info: { id: "child-later", sessionID: "child", role: "user", time: { created: 30 } } as Message, parts: [] },
])
sessionMessageRecords.set("grandchild", [
{ info: { id: "grandchild-assistant", sessionID: "grandchild", role: "assistant", time: { created: 20 } } as Message, parts: [] },
{ info: { id: "grandchild-user", sessionID: "grandchild", role: "user", time: { created: 21 } } as Message, parts: [] },
])
sessionMessageRecords.set("old-child", [
{ info: { id: "old-child-user", sessionID: "old-child", role: "user", time: { created: 19 } } as Message, parts: [] },
])
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await revertToMessage("root", "root-cutoff")
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => [
call.params.sessionID,
call.params.messageID,
])).toEqual([
["child", "child-boundary"],
["grandchild", "grandchild-user"],
["root", "root-cutoff"],
])
})
test("continues reverting other descendants and the parent when one child fails", async () => {
const rootMessage = { id: "root-cutoff", sessionID: "root", role: "user", time: { created: 20 } } as Message
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 } },
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 } },
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 } },
] as Session[]
const store = createStore({}, { session: sessions, message: { root: [rootMessage] } })
for (const id of ["failing-child", "healthy-child"]) {
sessionMessageRecords.set(id, [{
info: { id: `${id}-target`, sessionID: id, role: "user", time: { created: 20 } } as Message,
parts: [],
}])
}
failingRevertSessionIds.add("failing-child")
const { setActionRefs, revertToMessage } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await revertToMessage("root", "root-cutoff")
expect(replyCalls.filter((call) => call.method === "session.revert").map((call) => call.params.sessionID)).toEqual([
"failing-child",
"healthy-child",
"root",
])
})
})
describe("unrevertSession descendant cascade", () => {
beforeEach(() => {
replyCalls.length = 0
sessionMessagesResult = { data: [] }
failingUnrevertSessionIds.clear()
})
test("unreverts only marked descendants before the parent", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "marked-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "child-target" } },
{ id: "plain-child", parentID: "root", directory: "/tree", time: { created: 3 } },
{ id: "marked-grandchild", parentID: "plain-child", directory: "/tree", time: { created: 4 }, revert: { messageID: "grandchild-target" } },
] as Session[]
const store = createStore({}, { session: sessions })
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
"marked-child",
"marked-grandchild",
"root",
])
})
test("continues after a descendant unrevert fails", async () => {
const sessions = [
{ id: "root", directory: "/tree", time: { created: 1 }, revert: { messageID: "root-target" } },
{ id: "failing-child", parentID: "root", directory: "/tree", time: { created: 2 }, revert: { messageID: "first-target" } },
{ id: "healthy-child", parentID: "root", directory: "/tree", time: { created: 3 }, revert: { messageID: "second-target" } },
] as Session[]
const store = createStore({}, { session: sessions })
failingUnrevertSessionIds.add("failing-child")
const { setActionRefs, unrevertSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/tree", store]]), () => "/tree")
await unrevertSession("root")
expect(replyCalls.filter((call) => call.method === "session.unrevert").map((call) => call.params.sessionID)).toEqual([
"failing-child",
"healthy-child",
"root",
])
})
})
describe("dismissPermission passes directory", () => {
+140 -21
View File
@@ -26,6 +26,7 @@ import {
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessionLink } from "@/lib/sessionBtwMetadata"
import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
@@ -35,6 +36,7 @@ import { getStaleRunningToolMessageID } from "./materialization"
import { normalizePath } from "@/lib/pathNormalization"
import { mergeMessages } from "./optimistic"
import { messagesBefore, messagesFrom } from "./message-ordering"
import { deleteChatDirectory } from "@/lib/chatDirectories"
const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
@@ -436,6 +438,76 @@ type SessionListSnapshot = {
type DirectoryStoreApi = ReturnType<ChildStoreManager["ensureChild"]>
type DescendantSession = {
session: Session
directory: string
}
function getDescendantSessions(rootId: string): DescendantSession[] {
const stores = _childStores
if (!stores) return []
const sessionsById = new Map<string, DescendantSession>()
for (const [storeDirectory, store] of stores.children) {
for (const session of store.getState().session) {
const directory = session.directory || storeDirectory
const current = sessionsById.get(session.id)
if (!current || session.directory) sessionsById.set(session.id, { session, directory })
}
}
const subtreeIds = computeSubtreeIds(
[...sessionsById.values()].map(({ session }) => session),
rootId,
)
subtreeIds.delete(rootId)
return [...subtreeIds]
.map((id) => sessionsById.get(id))
.filter((entry): entry is DescendantSession => !!entry)
}
function firstUserMessageAtOrAfter(messages: Message[], cutoff: number): Message | null {
let target: Message | null = null
for (const message of messages) {
if (message.role !== "user" || message.time.created < cutoff) continue
if (!target || message.time.created < target.time.created) target = message
}
return target
}
async function fetchSessionMessages(sessionId: string, directory?: string | null): Promise<Message[]> {
const records = await opencodeClient.getSessionMessages(sessionId, undefined, directory)
return records.map(({ info }) => info)
}
async function cascadeRevertToDescendants(rootId: string, cutoff: number): Promise<void> {
for (const { session, directory } of getDescendantSessions(rootId)) {
try {
const messages = await fetchSessionMessages(session.id, directory)
// Equal timestamps belong to the reverted side of the boundary. Keeping
// them would rely on unrelated message IDs to decide chronology.
const target = firstUserMessageAtOrAfter(messages, cutoff)
if (!target) continue
const reverted = await opencodeClient.revertSession(session.id, target.id, undefined, directory)
mirrorSessionIntoLiveStores(reverted, directory)
} catch (error) {
console.error(`[session-actions] Failed to cascade revert to descendant ${session.id}:`, error)
}
}
}
async function cascadeUnrevertToDescendants(rootId: string): Promise<void> {
for (const { session, directory } of getDescendantSessions(rootId)) {
if (!session.revert) continue
try {
const result = await sdk().session.unrevert({ sessionID: session.id, directory })
mirrorSessionIntoLiveStores(assertSdkData(result, "session.unrevert"), directory)
} catch (error) {
console.error(`[session-actions] Failed to cascade unrevert to descendant ${session.id}:`, error)
}
}
}
function getGlobalSessionSnapshot(sessionId: string): Session | null {
const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
@@ -726,6 +798,7 @@ export async function createSession(
directoryOverride?: string | null,
parentID?: string | null,
metadata?: Record<string, unknown>,
selectionTransition?: "submitted-draft",
): Promise<Session | null> {
try {
// Capture the effective directory used for session creation so we can fall
@@ -746,7 +819,7 @@ export async function createSession(
if (sessionDirectory) {
registerSessionDirectory(session.id, sessionDirectory)
}
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory)
useSessionUIStore.getState().setCurrentSession(session.id, sessionDirectory, selectionTransition)
useSessionUIStore.getState().markSessionAsOpenChamberCreated(session.id)
useGlobalSessionsStore.getState().upsertSession(session)
return session
@@ -792,6 +865,7 @@ export async function patchSessionMetadata(
useGlobalSessionsStore.getState().upsertSession(updated)
const sessionDirectory = (updated as { directory?: string | null }).directory ?? targetDirectory
if (sessionDirectory) registerSessionDirectory(updated.id, sessionDirectory)
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
return updated
}
@@ -801,11 +875,8 @@ export async function setLinkedIssue(
issue: LinkedIssue,
linked: boolean,
): Promise<Session> {
const updated = await patchSessionMetadata(sessionId, directory, (metadata) =>
return patchSessionMetadata(sessionId, directory, (metadata) =>
withLinkedIssue(metadata, issue, linked))
const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
return updated
}
export async function setContextObligatoryMessage(
@@ -814,11 +885,8 @@ export async function setContextObligatoryMessage(
message: ContextObligatoryMessage,
pinned: boolean,
): Promise<Session> {
const updated = await patchSessionMetadata(sessionId, directory, (metadata) =>
return patchSessionMetadata(sessionId, directory, (metadata) =>
withContextObligatoryMessage(metadata, message, pinned))
const sessionDirectory = (updated as Session & { directory?: string | null }).directory ?? directory ?? undefined
mirrorSessionIntoLiveStores(updated, sessionDirectory ?? undefined)
return updated
}
async function cleanupReviewMetadataBeforeDelete(
@@ -834,18 +902,41 @@ async function cleanupReviewMetadataBeforeDelete(
return
}
if (isStaleRuntime(expectedRuntimeKey)) return
if (!isReviewSession(session)) return
const originalSessionID = getOriginalSessionID(session)
if (!originalSessionID) return
try {
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), (metadata) =>
withoutReviewSessionLink(metadata, sessionId),
expectedRuntimeKey,
)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/not found/i.test(message)) return
console.warn("[session-actions] review metadata cleanup failed before delete", error)
const unlinkParent = async (originalSessionID: string, unlink: (metadata: SessionMetadataRecord) => SessionMetadataRecord) => {
try {
await patchSessionMetadata(originalSessionID, directory ?? getSessionDirectory(originalSessionID), unlink, expectedRuntimeKey)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/not found/i.test(message)) return
console.warn("[session-actions] linked-session metadata cleanup failed before delete", error)
}
}
if (isReviewSession(session)) {
const originalSessionID = getOriginalSessionID(session)
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutReviewSessionLink(metadata, sessionId))
return
}
if (isBtwSession(session)) {
const originalSessionID = getBtwOriginalSessionID(session)
if (originalSessionID) await unlinkParent(originalSessionID, (metadata) => withoutBtwSessionLink(metadata, sessionId))
return
}
// Deleting or archiving a session that has an active btw fork also removes
// the fork: it is a temporary session that only exists for its parent's
// panel. Best-effort — a failed fork delete must not block the parent's
// operation; the orphaned fork stays visible in the sidebar.
const btwSessionID = getBtwSessionID(session)
if (btwSessionID) {
try {
if (isStaleRuntime(expectedRuntimeKey)) return
await deleteSession(btwSessionID, { expectedRuntimeKey })
} catch (error) {
console.warn("[session-actions] failed to delete btw fork before parent delete", error)
}
}
}
@@ -919,6 +1010,15 @@ function finalizeConfirmedSessionDeletion(
}
}
async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise<void> {
if (!directory || !deleteDirectory) return
try {
await deleteChatDirectory(directory)
} catch (error) {
console.warn("[session-actions] deleted chat directory cleanup failed", error)
}
}
export type DeleteSessionOptions = {
/**
* Runtime key the deletion is scoped to. Defaults to the active runtime when
@@ -947,6 +1047,8 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey()
if (isStaleRuntime(expectedRuntimeKey)) return false
const sessionDirectory = getSessionDirectory(sessionId)
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey)
if (isStaleRuntime(expectedRuntimeKey)) return false
@@ -956,6 +1058,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
throw new Error("session.delete failed: server did not confirm deletion")
}
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
return true
} catch (error) {
console.error("[session-actions] deleteSession failed", error)
@@ -965,6 +1068,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp
if ((error as { status?: number })?.status === 404) {
if (isStaleRuntime(expectedRuntimeKey)) return false
finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory)
return true
}
return false
@@ -978,6 +1082,8 @@ export async function deleteSessionInDirectory(
expectedRuntimeKey = getRuntimeKey(),
): Promise<boolean> {
if (isStaleRuntime(expectedRuntimeKey)) return false
const sessionSnapshot = getGlobalSessionSnapshot(sessionId)
const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null)
try {
await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey)
if (isStaleRuntime(expectedRuntimeKey)) return false
@@ -987,12 +1093,14 @@ export async function deleteSessionInDirectory(
throw new Error("session.delete failed: server did not confirm deletion")
}
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
return true
} catch (error) {
console.error("[session-actions] deleteSessionInDirectory failed", error)
if ((error as { status?: number })?.status === 404) {
if (isStaleRuntime(expectedRuntimeKey)) return false
finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey)
await cleanupDeletedChatDirectory(directory, deleteManagedDirectory)
return true
}
return false
@@ -1821,6 +1929,11 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
const { store, directory } = dirStoreForSession(sessionId)
const state = store.getState()
const localTarget = state.message[sessionId]?.find((message) => message.id === messageId)
const targetMessage = localTarget
?? (await fetchSessionMessages(sessionId, directory)).find((message) => message.id === messageId)
if (!targetMessage) throw new Error(`Cannot revert session: message ${messageId} was not found`)
// Abort if busy before mutating session state
const status = state.session_status[sessionId]
if (status && status.type !== "idle") {
@@ -1891,6 +2004,9 @@ export async function revertToMessage(sessionId: string, messageId: string): Pro
// Call SDK and merge authoritative result into store
try {
// Descendants go first because OpenCode also restores file snapshots during
// revert. All sessions share a directory, so the parent's snapshot must win.
await cascadeRevertToDescendants(sessionId, targetMessage.time.created)
const revertedSession = await opencodeClient.revertSession(sessionId, messageId, undefined, directory)
const current = store.getState()
const updated = [...current.session]
@@ -1973,6 +2089,9 @@ export async function unrevertSession(sessionId: string): Promise<void> {
}
}
// Descendants go first because unrevert can also restore shared file state.
// Applying the parent last leaves the working tree at the parent's snapshot.
await cascadeUnrevertToDescendants(sessionId)
const result = await sdk().session.unrevert({ sessionID: sessionId, directory })
const unrevertedSession = assertSdkData(result, "session.unrevert")
const current = store.getState()
+70 -31
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { countSyncPerformance } from './performance-diagnostics';
// Per-session turn timing behind the sidebar activity readout.
//
@@ -50,6 +51,14 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
type SessionActivityPhase = 'active' | 'settled';
export type SessionActivityTimingMutation =
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
| { type: 'remove'; sessionId: string };
type ActivityTimingDraft = {
startedAt: Map<string, number> | null;
settledMs: Map<string, number> | null;
};
type SessionActivityTimingState = {
startedAt: ReadonlyMap<string, number>;
@@ -87,13 +96,13 @@ const RESTORE_ADOPTION_WINDOW_MS = 90_000;
// page did not write to looks exactly like "no turn was running".
const STORAGE_KEY = 'oc.session-activity.v1';
const EMPTY_ACTIVE: ReadonlySet<string> = new Set();
const EMPTY_RESTORED: ReadonlyMap<string, PersistedStart> = new Map();
export const useSessionActivityTimingStore = create<SessionActivityTimingState>(() => ({
startedAt: new Map(),
settledMs: new Map(),
}));
useSessionActivityTimingStore.subscribe(() => countSyncPerformance('timingPublications'));
/** Last moment each live start was observed active, for the liveness stamp. */
const liveSeen = new Map<string, number>();
@@ -355,11 +364,66 @@ export const observeSessionActivityTiming = (
sessionId: string,
phase: SessionActivityPhase,
): void => {
if (phase === 'active') {
applyTransitions(new Set([sessionId]), null);
return;
applySessionActivityTimingMutations([{ type: 'observe', sessionId, phase }]);
};
export const applySessionActivityTimingMutations = (
mutations: readonly SessionActivityTimingMutation[],
): void => {
if (mutations.length === 0) return;
const now = Date.now();
const restored = getAdoptableStarts(now);
const state = useSessionActivityTimingStore.getState();
const next: ActivityTimingDraft = { startedAt: null, settledMs: null };
let restoredChanged = false;
let sawActive = false;
const currentStarted = (): ReadonlyMap<string, number> => next.startedAt ?? state.startedAt;
const currentSettled = (): ReadonlyMap<string, number> => next.settledMs ?? state.settledMs;
const draftStarted = (): Map<string, number> => (next.startedAt ??= new Map(state.startedAt));
const draftSettled = (): Map<string, number> => (next.settledMs ??= new Map(state.settledMs));
for (const mutation of mutations) {
if (mutation.type === 'remove') {
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
liveSeen.delete(mutation.sessionId);
if (currentStarted().has(mutation.sessionId)) draftStarted().delete(mutation.sessionId);
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
continue;
}
if (mutation.phase === 'active') {
sawActive = true;
liveSeen.set(mutation.sessionId, now);
if (!currentStarted().has(mutation.sessionId)) {
draftStarted().set(mutation.sessionId, restored.get(mutation.sessionId)?.start ?? now);
}
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
continue;
}
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
const start = currentStarted().get(mutation.sessionId);
if (start === undefined) continue;
draftStarted().delete(mutation.sessionId);
liveSeen.delete(mutation.sessionId);
draftSettled().set(mutation.sessionId, Math.max(0, now - start));
}
if (next.settledMs) trimSettled(next.settledMs);
if (next.startedAt || next.settledMs) {
useSessionActivityTimingStore.setState({
startedAt: next.startedAt ?? state.startedAt,
settledMs: next.settledMs ?? state.settledMs,
});
}
if (next.startedAt) {
if (next.startedAt.size > 0) ensureLivenessStampOnHide();
persistStarts(next.startedAt, now);
} else if (restoredChanged) {
persistStarts(state.startedAt, now);
} else if (sawActive && state.startedAt.size > 0 && now - lastPersistAt >= LIVENESS_PERSIST_INTERVAL_MS) {
persistStarts(state.startedAt, now);
}
applyTransitions(EMPTY_ACTIVE, { source: 'event', sessionId });
};
/**
@@ -377,32 +441,7 @@ export const reconcileSessionActivityTiming = (
};
export const removeSessionActivityTiming = (sessionId: string): void => {
const restoredChanged = getRestoredStarts().delete(sessionId);
const state = useSessionActivityTimingStore.getState();
const hadStart = state.startedAt.has(sessionId);
const hadSettled = state.settledMs.has(sessionId);
liveSeen.delete(sessionId);
if (!hadStart && !hadSettled) {
if (restoredChanged) persistStarts(state.startedAt, Date.now());
return;
}
let startedAt = state.startedAt;
if (hadStart) {
const draft = new Map(state.startedAt);
draft.delete(sessionId);
startedAt = draft;
}
let settledMs = state.settledMs;
if (hadSettled) {
const draft = new Map(state.settledMs);
draft.delete(sessionId);
settledMs = draft;
}
useSessionActivityTimingStore.setState({ startedAt, settledMs });
if (hadStart || restoredChanged) persistStarts(startedAt, Date.now());
applySessionActivityTimingMutations([{ type: 'remove', sessionId }]);
};
/**
+81 -62
View File
@@ -1,5 +1,10 @@
import type { Event, Session } from "@opencode-ai/sdk/v2/client"
import { isGlobalSessionRecencyOnlyUpdate, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import {
isGlobalSessionRecencyOnlyUpdate,
mergeSessionDirectoryMetadata,
useGlobalSessionsStore,
type GlobalSessionMutation,
} from "@/stores/useGlobalSessionsStore"
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
import { streamPerfCount, streamPerfMark } from "@/stores/utils/streamDebug"
import { stripSessionDiffSnapshots } from "./sanitize"
@@ -11,23 +16,6 @@ const clearPendingGlobalSessionUpdates = (): void => {
pendingGlobalSessionUpdates.clear()
}
const flushPendingGlobalSessionUpdate = (sessionID: string): void => {
const update = pendingGlobalSessionUpdates.get(sessionID)
pendingGlobalSessionUpdates.delete(sessionID)
if (!update) return
const runtimeKey = getRuntimeKey()
if (update.runtimeKey !== runtimeKey) return
const currentSession = getGlobalSessionSnapshot(update.session.id)
if (
!currentSession
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
) return
streamPerfMark("global_sessions.event_update_flush")
useGlobalSessionsStore.getState().upsertSession(update.session)
streamPerfCount("ui.global_sessions.event_update_publication")
}
const scheduleGlobalSessionUpdate = (session: Session): void => {
pendingGlobalSessionUpdates.set(session.id, { runtimeKey: getRuntimeKey(), session })
streamPerfCount("ui.global_sessions.event_update_deferred")
@@ -58,51 +46,82 @@ const getSessionInfoFromPayload = (event: Event): Session | null => {
return stripSessionDiffSnapshots(session as Session)
}
const getGlobalSessionSnapshot = (sessionId: string): Session | null => {
const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
export const applySessionEventsToGlobalSessions = (payloads: readonly Event[]): void => {
if (payloads.length === 0) return
const runtimeKey = getRuntimeKey()
const store = useGlobalSessionsStore.getState()
const overlay = new Map(store.entityById)
const mutations: GlobalSessionMutation[] = []
let flushedRecency = false
const appendUpsert = (session: Session): void => {
const existing = overlay.get(session.id) ?? null
const merged = mergeSessionDirectoryMetadata(session, existing)
overlay.set(session.id, merged)
mutations.push({ type: "upsert", session: merged })
}
for (const payload of payloads) {
if (payload.type === "session.idle" || payload.type === "session.error") {
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
if (typeof sessionID !== "string") continue
const update = pendingGlobalSessionUpdates.get(sessionID)
pendingGlobalSessionUpdates.delete(sessionID)
if (!update || update.runtimeKey !== runtimeKey) continue
const currentSession = overlay.get(sessionID) ?? null
if (
!currentSession
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
) continue
appendUpsert(update.session)
flushedRecency = true
continue
}
if (payload.type === "session.created") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = overlay.get(session.id) ?? null
if (!shouldSkipStaleSessionEvent(currentSession, session)) appendUpsert(session)
}
continue
}
if (payload.type === "session.updated") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = overlay.get(session.id) ?? null
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
scheduleGlobalSessionUpdate(session)
} else {
pendingGlobalSessionUpdates.delete(session.id)
appendUpsert(session)
streamPerfCount("ui.global_sessions.event_update_immediate")
}
}
}
continue
}
if (payload.type === "session.deleted") {
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID
?? getSessionInfoFromPayload(payload)?.id
if (sessionID) {
pendingGlobalSessionUpdates.delete(sessionID)
overlay.delete(sessionID)
mutations.push({ type: "remove", sessionId: sessionID })
}
}
}
if (mutations.length === 0 || runtimeKey !== getRuntimeKey()) return
if (flushedRecency) streamPerfMark("global_sessions.event_update_flush")
store.applySessionMutations(mutations)
streamPerfCount("ui.global_sessions.event_update_publication")
}
export const applySessionEventToGlobalSessions = (payload: Event): void => {
if (payload.type === "session.idle" || payload.type === "session.error") {
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
if (typeof sessionID === "string") flushPendingGlobalSessionUpdate(sessionID)
return
}
if (payload.type === "session.created") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = getGlobalSessionSnapshot(session.id)
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
useGlobalSessionsStore.getState().upsertSession(session)
}
}
return
}
if (payload.type === "session.updated") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = getGlobalSessionSnapshot(session.id)
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
scheduleGlobalSessionUpdate(session)
} else {
pendingGlobalSessionUpdates.delete(session.id)
useGlobalSessionsStore.getState().upsertSession(session)
streamPerfCount("ui.global_sessions.event_update_immediate")
}
}
}
return
}
if (payload.type === "session.deleted") {
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID ?? getSessionInfoFromPayload(payload)?.id
if (sessionID) {
pendingGlobalSessionUpdates.delete(sessionID)
useGlobalSessionsStore.getState().removeSessions([sessionID])
}
}
applySessionEventsToGlobalSessions([payload])
}
@@ -119,6 +119,32 @@ describe('session lifecycle ordering', () => {
]);
});
test('orders roots, siblings, orphan parents, and cyclic parent scopes deterministically', () => {
const rootOlder = session('root-older', 10);
const rootNewer = session('root-newer', 20);
const childOlder = session('child-older', 5, 'root-older');
const childNewer = session('child-newer', 6, 'root-older');
const orphanOlder = session('orphan-older', 10, 'missing-parent');
const orphanNewer = session('orphan-newer', 20, 'missing-parent');
const cycleOlder = session('cycle-older', 10, 'cycle-newer');
const cycleNewer = session('cycle-newer', 20, 'cycle-older');
expect(orderSessionsByLifecycleScopes(
[cycleOlder, rootOlder, childOlder, orphanOlder, cycleNewer, rootNewer, childNewer, orphanNewer],
new Set(),
new Map(),
).map((item) => item.id)).toEqual([
'orphan-newer',
'root-newer',
'orphan-older',
'root-older',
'child-newer',
'child-older',
'cycle-newer',
'cycle-older',
]);
});
test('does not promote a root when only its child has lifecycle activity', () => {
const rootOlder = session('root-older', 10);
const rootNewer = session('root-newer', 20);
+95 -20
View File
@@ -1,9 +1,13 @@
import { create } from 'zustand';
import type { Session } from '@opencode-ai/sdk/v2';
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
import { normalizePath } from '@/lib/pathNormalization';
import { countSyncPerformance } from './performance-diagnostics';
type SessionActivityPhase = 'active' | 'settled';
export type SessionActivityPhase = 'active' | 'settled';
export type SessionOrderingMutation =
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
| { type: 'remove'; sessionId: string };
type SessionOrderingState = {
rankById: Map<string, number>;
@@ -18,6 +22,7 @@ let lastRank = 0;
export const useSessionOrderingStore = create<SessionOrderingState>(() => ({
rankById: new Map(),
}));
useSessionOrderingStore.subscribe(() => countSyncPerformance('orderingPublications'));
const nextRank = (): number => {
lastRank = Math.max(lastRank + 1, Date.now());
@@ -42,12 +47,36 @@ export const observeSessionActivityEvent = (
sessionId: string,
phase: SessionActivityPhase,
): void => {
const previous = phaseById.get(sessionId);
phaseById.set(sessionId, phase);
applySessionOrderingMutations([{ type: 'observe', sessionId, phase }]);
};
if (previous === phase) return;
if (previous === undefined && phase === 'settled') return;
promoteSessions([sessionId]);
export const applySessionOrderingMutations = (
mutations: readonly SessionOrderingMutation[],
): void => {
if (mutations.length === 0) return;
const currentRanks = useSessionOrderingStore.getState().rankById;
let rankById: Map<string, number> | null = null;
for (const mutation of mutations) {
if (mutation.type === 'remove') {
phaseById.delete(mutation.sessionId);
baselineRankById.delete(mutation.sessionId);
if ((rankById ?? currentRanks).has(mutation.sessionId)) {
rankById ??= new Map(currentRanks);
rankById.delete(mutation.sessionId);
}
continue;
}
const previous = phaseById.get(mutation.sessionId);
phaseById.set(mutation.sessionId, mutation.phase);
if (previous === mutation.phase) continue;
if (previous === undefined && mutation.phase === 'settled') continue;
rankById ??= new Map(currentRanks);
rankById.set(mutation.sessionId, nextRank());
}
if (rankById) useSessionOrderingStore.setState({ rankById });
};
export const reconcileSessionActivitySnapshot = (
@@ -71,14 +100,7 @@ export const reconcileSessionActivitySnapshot = (
};
export const removeSessionOrdering = (sessionId: string): void => {
phaseById.delete(sessionId);
baselineRankById.delete(sessionId);
useSessionOrderingStore.setState((state) => {
if (!state.rankById.has(sessionId)) return state;
const rankById = new Map(state.rankById);
rankById.delete(sessionId);
return { rankById };
});
applySessionOrderingMutations([{ type: 'remove', sessionId }]);
};
export const resetSessionOrdering = (): void => {
@@ -107,7 +129,7 @@ const sessionDirectory = (session: Session): string | null => {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
return normalizePath(record.directory ?? null) ?? normalizePath(record.project?.worktree ?? null);
return record.directory ?? record.project?.worktree ?? null;
};
const baselineRank = (session: Session, pinned: boolean): number => {
@@ -197,12 +219,39 @@ export const orderSessionsByLifecycleScopes = (
sessions: Session[],
pinnedSessionIds: Set<string>,
rankById: ReadonlyMap<string, number>,
hierarchy?: {
rootIds: readonly string[];
childrenByParentId: ReadonlyMap<string, readonly string[]>;
},
): Session[] => {
countSyncPerformance('sidebarOrderBuilds');
const sessionIds = new Set(sessions.map((session) => session.id));
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const roots: Session[] = [];
const childrenByParent = new Map<string, Session[]>();
const indexedIds = new Set<string>();
if (hierarchy) {
for (const sessionId of hierarchy.rootIds) {
const session = sessionById.get(sessionId);
if (!session) continue;
indexedIds.add(sessionId);
roots.push(session);
}
for (const [parentId, childIds] of hierarchy.childrenByParentId) {
if (!sessionIds.has(parentId)) continue;
const children = childIds.flatMap((sessionId) => {
const session = sessionById.get(sessionId);
if (!session) return [];
indexedIds.add(sessionId);
return [session];
});
if (children.length > 0) childrenByParent.set(parentId, children);
}
}
for (const session of sessions) {
if (indexedIds.has(session.id)) continue;
const parentId = parentIdOf(session);
if (!parentId || !sessionIds.has(parentId)) {
roots.push(session);
@@ -217,9 +266,34 @@ export const orderSessionsByLifecycleScopes = (
}
}
const compare = (left: Session, right: Session) => (
compareSessionsByLifecycleOrder(left, right, pinnedSessionIds, rankById)
);
const metadataById = new Map(sessions.map((session) => {
const parentId = parentIdOf(session);
const pinned = isSessionPinned(pinnedSessionIds, sessionDirectory(session), session.id);
const fallback = baselineRank(session, pinned);
return [session.id, {
parentId,
pinned,
fallback,
lifecycle: rankById.get(session.id) ?? fallback,
created: baselineRank(session, true),
}] as const;
}));
countSyncPerformance('sidebarOrderMetadataEntries', metadataById.size);
const compare = (left: Session, right: Session): number => {
const leftMetadata = metadataById.get(left.id);
const rightMetadata = metadataById.get(right.id);
if (!leftMetadata || !rightMetadata) return left.id.localeCompare(right.id);
if (leftMetadata.pinned !== rightMetadata.pinned) return leftMetadata.pinned ? -1 : 1;
if (leftMetadata.parentId === rightMetadata.parentId) {
const rankDelta = rightMetadata.lifecycle - leftMetadata.lifecycle;
if (rankDelta !== 0) return rankDelta;
}
const baselineDelta = rightMetadata.fallback - leftMetadata.fallback;
if (baselineDelta !== 0) return baselineDelta;
const createdDelta = rightMetadata.created - leftMetadata.created;
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
};
roots.sort(compare);
for (const siblings of childrenByParent.values()) {
siblings.sort(compare);
@@ -238,7 +312,8 @@ export const orderSessionsByLifecycleScopes = (
for (const root of roots) {
append(root);
}
for (const session of sessions) {
const remaining = sessions.filter((session) => !visited.has(session.id)).sort(compare);
for (const session of remaining) {
append(session);
}
return ordered;
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2"
import { upsertSessionRecord } from "./session-records"
const session = (id: string, overrides: Partial<Session> = {}): Session => ({
id, slug: id, projectID: "project", directory: "/workspace", title: id, version: "1",
time: { created: 1, updated: 1 }, ...overrides,
})
describe("upsertSessionRecord", () => {
test("inserts missing IDs in binary order", () => {
expect(upsertSessionRecord([session("a"), session("c")], session("b")).map((item) => item.id)).toEqual(["a", "b", "c"])
})
test("preserves references for separately allocated equivalent metadata", () => {
const current = [session("a", {
metadata: { nested: ["value", { count: 1 }] },
summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
}), session("b")]
const incoming = session("a", {
metadata: { nested: ["value", { count: 1 }] },
summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
})
const result = upsertSessionRecord(current, incoming)
expect(result).toBe(current)
expect(result[0]).toBe(current[0])
expect(result[1]).toBe(current[1])
})
test("replaces a same-ID record when an unlisted semantic field changes", () => {
const current = [session("a")]
// SAFETY: The runtime SDK payload may contain an additive field before the local Session type is updated.
const incoming = {
...session("a"),
// SDK records can gain fields independently of this synchronization boundary.
customField: "changed",
} as Session
expect(upsertSessionRecord(current, incoming)).not.toBe(current)
})
const changes: Array<[string, Partial<Session>, Partial<Session>]> = [
["scalars", { workspaceID: "one", path: "a", parentID: "p", cost: 1, agent: "a" }, { workspaceID: "two", path: "b", parentID: "q", cost: 2, agent: "b" }],
["tokens", { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } } }, { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 6 } } }],
["share and model", { share: { url: "a" }, model: { id: "m", providerID: "p", variant: "a" } }, { share: { url: "b" }, model: { id: "m", providerID: "p", variant: "b" } }],
["metadata", { metadata: { key: "a" } }, { metadata: { key: "b" } }],
["permission", { permission: [{ permission: "bash", pattern: "*", action: "ask" }] }, { permission: [{ permission: "bash", pattern: "*", action: "allow" }] }],
["revert", { revert: { messageID: "m", partID: "a", snapshot: "s", diff: "d" } }, { revert: { messageID: "m", partID: "b", snapshot: "s", diff: "d" } }],
["summary diffs", { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] } }, { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 2, deletions: 0 }] } }],
["time", { time: { created: 1, updated: 1, compacting: 2, archived: 3 } }, { time: { created: 1, updated: 2, compacting: 2, archived: 3 } }],
]
for (const [field, current, incoming] of changes) {
test(`replaces only target when ${field} changes`, () => {
const first = session("a")
const target = session("b", current)
const last = session("c")
const list = [first, target, last]
const result = upsertSessionRecord(list, session("b", incoming))
expect(result).not.toBe(list)
expect(result[0]).toBe(first)
expect(result[1]).not.toBe(target)
expect(result[2]).toBe(last)
})
}
})
+16
View File
@@ -0,0 +1,16 @@
import type { Session } from "@opencode-ai/sdk/v2"
import { Binary } from "./binary"
function areSessionsEqual(left: Session, right: Session): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
export function upsertSessionRecord(current: Session[], incoming: Session): Session[] {
const result = Binary.search(current, incoming.id, (session) => session.id)
if (!result.found) return [...current.slice(0, result.index), incoming, ...current.slice(result.index)]
// Equivalent authoritative detail must retain sidebar session-list references.
if (areSessionsEqual(current[result.index], incoming)) return current
const next = [...current]
next[result.index] = incoming
return next
}
+65 -9
View File
@@ -10,6 +10,7 @@ import { useCommandsStore } from '@/stores/useCommandsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
/**
* Unit tests for session worktree routing through the authoritative store.
@@ -199,6 +200,47 @@ describe('session-worktree-store worktree routing', () => {
});
});
describe('draft materialization transition identity', () => {
beforeEach(() => {
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
materializedDraftSessionId: null,
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
});
});
test('marks and consumes only the submitted draft session', () => {
useSessionUIStore.getState().setCurrentSession(
'session-created',
'/projects/alpha',
'submitted-draft',
);
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
useSessionUIStore.getState().clearMaterializedDraftSession('another-session');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
useSessionUIStore.getState().clearMaterializedDraftSession('session-created');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
});
test('clears the marker when navigating from a draft to an existing session', () => {
useSessionUIStore.getState().setCurrentSession(
'session-created',
'/projects/alpha',
'submitted-draft',
);
useSessionUIStore.setState({
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
});
useSessionUIStore.getState().setCurrentSession('session-existing', '/projects/alpha');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
});
});
describe('routeMessage directory scoping', () => {
test('runs sends in the provided session directory', async () => {
// The session directory travels as an explicit request param (not via
@@ -370,16 +412,17 @@ describe('openNewSessionDraft project binding', () => {
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
});
test('keeps implicit draft on current directory when active project differs', () => {
test('defaults an implicit draft to Chat when active project differs', () => {
useSessionUIStore.getState().openNewSessionDraft();
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBe(projectB.id);
expect(draft.directoryOverride).toBe(projectB.path);
expect(draft.target).toBe('chat');
expect(draft.selectedProjectId).toBeNull();
expect(draft.directoryOverride).toBeNull();
});
test('does not attach active project when current directory is unmatched', () => {
test('defaults an implicit draft to Chat when current directory is unmatched', () => {
useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
@@ -387,7 +430,8 @@ describe('openNewSessionDraft project binding', () => {
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBeNull();
expect(draft.directoryOverride).toBe('/external/worktree');
expect(draft.target).toBe('chat');
expect(draft.directoryOverride).toBeNull();
});
test('respects explicit directoryOverride over active project', () => {
@@ -464,7 +508,7 @@ describe('createSession draft lifecycle', () => {
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
opencodeClient.getDirectoryAvailability = async () => 'missing';
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
await Bun.sleep(0);
expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main');
@@ -482,7 +526,7 @@ describe('createSession draft lifecycle', () => {
activeProjectId: 'project-active',
});
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
opencodeClient.getDirectoryAvailability = async () => 'missing';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
@@ -542,7 +586,7 @@ describe('createSession draft lifecycle', () => {
activeProjectId: 'project-main',
});
useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/unavailable-worktree' });
opencodeClient.getDirectoryAvailability = async () => 'unknown';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
@@ -571,7 +615,7 @@ describe('createSession draft lifecycle', () => {
return { id: 'session-race', directory };
};
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
const createPromise = useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
expect(availabilityResolvers.length).toBe(2);
@@ -654,9 +698,19 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
currentSessionDirectory: null,
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
});
useProjectsStore.setState({ projects: [], activeProjectId: null });
useSessionDisplayStore.setState({ singleProjectId: null });
});
test('draft send snapshots the draft; switching to another project mid-flight still targets the materialized session', async () => {
useProjectsStore.setState({
projects: [
{ id: 'project-alpha', path: '/projects/alpha', label: 'Alpha' },
{ id: 'project-beta', path: '/projects/beta', label: 'Beta' },
],
activeProjectId: 'project-alpha',
});
useSessionDisplayStore.setState({ singleProjectId: 'project-alpha' });
const draftSnapshot = {
open: true,
directoryOverride: '/projects/alpha',
@@ -684,6 +738,7 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
// A sidebar switch while the send is still in flight must not reroute it.
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-beta');
await sendPromise;
@@ -692,6 +747,7 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
expect(sendMessageCalls).toHaveLength(1);
expect(sendMessageCalls[0].id).toBe('session-materialized');
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-alpha');
});
test('existing-session send keeps the submit-time target even when selection changes', async () => {
+211 -61
View File
@@ -12,6 +12,7 @@
* SDK-calling actions that need domain data read it from sync-refs.
*/
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
import { create } from "zustand"
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
@@ -20,6 +21,7 @@ import { opencodeClient } from "@/lib/opencode/client"
import { runtimeFetch } from "@/lib/runtime-fetch"
import { useConfigStore } from "@/stores/useConfigStore"
import { useProjectsStore } from "@/stores/useProjectsStore"
import { useSessionDisplayStore } from "@/stores/useSessionDisplayStore"
import { fetchSessionKnowledge, reportSessionKnowledgeDelivered } from "@/lib/sessionKnowledgeApi"
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from "@/stores/useGlobalSessionsStore"
import { useDirectoryStore } from "@/stores/useDirectoryStore"
@@ -29,6 +31,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore"
import { getDeferredSafeStorage } from "@/stores/utils/safeStorage"
import { markPendingUserSendAnimation } from "@/lib/userSendAnimation"
import { normalizePath } from "@/lib/pathNormalization"
import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories"
import { isVSCodeRuntime } from "@/lib/desktop"
import { flattenAssistantTextParts } from "@/lib/messages/messageText"
import { composeForkSessionMessage } from "@/lib/messages/executionMeta"
import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice"
@@ -135,7 +139,7 @@ export function routeMessage(params: {
variant?: string
inputMode?: "normal" | "shell"
files?: Array<{ type: "file"; mime: string; url: string; filename: string }>
additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
additionalParts?: Array<{ text: string; synthetic?: boolean; metadata?: ContextPartMetadata; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }>
delivery?: 'steer'
}): Promise<void> {
const requestDirectory = params.directory ?? undefined
@@ -258,6 +262,7 @@ function notifyMessageSent(sessionId: string): void {
// ---------------------------------------------------------------------------
export type NewSessionDraftState = {
draftId: number
open: boolean
selectedProjectId?: string | null
directoryOverride: string | null
@@ -271,6 +276,8 @@ export type NewSessionDraftState = {
syntheticParts?: SyntheticContextPart[]
targetFolderId?: string
projectContextPins?: { notes: string[]; plans: string[] }
target: "chat" | "project"
preparedChatDirectory?: string | null
}
export type ViewportAnchor = {
@@ -290,6 +297,7 @@ export type SessionHistoryMeta = {
export type SessionUIState = {
currentSessionId: string | null
currentSessionDirectory: string | null
materializedDraftSessionId: string | null
newSessionDraft: NewSessionDraftState
abortPromptSessionId: string | null
abortPromptExpiresAt: number | null
@@ -312,10 +320,16 @@ export type SessionUIState = {
dismissPendingChangesBar: (sessionId: string, signature: string | null) => void
// Actions — UI state management
setCurrentSession: (id: string | null, directoryHint?: string | null) => void
setCurrentSession: (
id: string | null,
directoryHint?: string | null,
transition?: "submitted-draft",
) => void
clearMaterializedDraftSession: (sessionId: string) => void
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void
restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void
openNewSessionDraft: (options?: Partial<NewSessionDraftState> & { automatic?: boolean }) => void
prepareChatDraftDirectory: () => Promise<string | null>
closeNewSessionDraft: () => void
setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void
setDraftPreserveDirectoryOverride: (value: boolean) => void
@@ -344,13 +358,18 @@ export type SessionUIState = {
agent?: string,
attachments?: AttachedFile[],
agentMentionName?: string,
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
variant?: string,
inputMode?: "normal" | "shell",
options?: SendMessageOptions,
) => Promise<void>
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null, metadata?: Record<string, unknown>) => Promise<Session | null>
createSession: (
title?: string,
directoryOverride?: string | null,
parentID?: string | null,
metadata?: Record<string, unknown>,
) => Promise<Session | null>
deleteSession: (id: string, options?: DeleteSessionOptions) => Promise<boolean>
deleteSessions: (ids: string[], options?: DeleteSessionsOptions) => Promise<{ deletedIds: string[]; failedIds: string[] }>
archiveSession: (id: string) => Promise<boolean>
@@ -548,10 +567,14 @@ const activateConfigForDirectory = async (directory: string | null | undefined):
}
const DEFAULT_DRAFT: NewSessionDraftState = {
draftId: 0,
open: false,
directoryOverride: null,
parentID: null,
target: "chat",
}
let nextDraftId = 1
const pendingChatDirectoryByDraft = new Map<string, Promise<string | null>>()
const activeSessionByRuntime = new Map<string, string | null>()
type RuntimeSessionMemory = {
@@ -704,6 +727,48 @@ const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Pr
void activateConfigForDirectory(recovered)
}
const createSessionWithDraftLifecycle = async (
title?: string,
directoryOverride?: string | null,
parentID?: string | null,
metadata?: Record<string, unknown>,
selectionTransition?: "submitted-draft",
): Promise<Session | null> => {
const store = useSessionUIStore.getState()
const draft = store.newSessionDraft
const targetFolderId = draft.targetFolderId
try {
const resolved = await resolveCreatableDraftDirectory(draft, directoryOverride)
if (resolved.status === "aborted") return null
const directory = resolved.directory
const session = await createSessionAction(
title,
directory,
parentID ?? null,
metadata,
selectionTransition,
)
if (!session) return null
useSessionUIStore.getState().closeNewSessionDraft()
if (targetFolderId) {
const currentStore = useSessionUIStore.getState()
const scopeDirectory = directory || currentStore.lastLoadedDirectory || session.directory
const scopeKey = getChatsRootFromDirectory(scopeDirectory) ?? scopeDirectory
if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
}
}
return session
} catch (error) {
console.error("[session-ui-store] createSession failed", error)
return null
}
}
export async function materializeOpenDraftSession(selection: {
providerID: string
modelID: string
@@ -726,18 +791,36 @@ export async function materializeOpenDraftSession(selection: {
store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride)
}
const isChatDraft = draft.target === "chat"
if (isChatDraft) {
draftDirectoryOverride = await store.prepareChatDraftDirectory()
if (!draftDirectoryOverride) throw new Error("Failed to prepare chat directory")
const currentDraft = useSessionUIStore.getState().newSessionDraft
if (currentDraft.draftId === draft.draftId) {
useSessionUIStore.setState({
newSessionDraft: { ...currentDraft, preparedChatDirectory: null },
})
}
}
await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId)
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
const created = await store.createSession(
const created = await createSessionWithDraftLifecycle(
draft.title,
draftDirectoryOverride,
draft.parentID ?? null,
draftPins.notes.length > 0 || draftPins.plans.length > 0
? { openchamber: { project_context_pins: draftPins } }
: undefined,
"submitted-draft",
)
if (!created?.id) throw new Error("Failed to create session")
if (!created?.id) {
if (isChatDraft && draftDirectoryOverride) {
await deleteChatDirectory(draftDirectoryOverride).catch(() => undefined)
}
throw new Error("Failed to create session")
}
// The server response is authoritative. It may canonicalize a requested
// worktree path (for example through a symlink or platform path casing).
@@ -757,19 +840,22 @@ export async function materializeOpenDraftSession(selection: {
})
const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName
const variantOverride = configState.currentProviderId === selection.providerID
&& configState.currentModelId === selection.modelID
&& configState.currentAgentName === effectiveDraftAgent
? configState.currentVariantSelection.override ?? undefined
: selection.variant
useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID)
if (effectiveDraftAgent) {
useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent)
useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID)
useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant)
useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, variantOverride)
}
store.initializeNewOpenChamberSession(created.id, configState.agents ?? [])
store.setCurrentSession(created.id, createdDirectory)
if (draftPermissionAutoAcceptEnabled) {
void import("@/stores/permissionStore")
.then(({ usePermissionStore }) => usePermissionStore.getState().setSessionAutoAccept(created.id, true))
@@ -810,6 +896,7 @@ const PERSISTED_WORKTREE_MAP = readPersistedWorktreeTopology(runtimeMemoryKey())
export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
currentSessionId: null,
currentSessionDirectory: null,
materializedDraftSessionId: null,
newSessionDraft: { ...DEFAULT_DRAFT },
abortPromptSessionId: null,
abortPromptExpiresAt: null,
@@ -828,7 +915,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// ---------------------------------------------------------------------------
// setCurrentSession
// ---------------------------------------------------------------------------
setCurrentSession: (id, directoryHint?: string | null) => {
setCurrentSession: (id, directoryHint?: string | null, transition?: "submitted-draft") => {
const materializedDraftSessionId = id && transition === "submitted-draft" ? id : null
// Publish the transition identity before closing the draft. Those are two
// separate store updates, and ChatContainer must never observe a closed
// draft with the previous transition identity.
if (get().materializedDraftSessionId !== materializedDraftSessionId) {
set({ materializedDraftSessionId })
}
if (id) {
get().closeNewSessionDraft()
}
@@ -862,7 +956,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// Set the directory together with the session id so chat hooks read the
// same child store that send/SSE events will update during startup races.
set({ currentSessionId: id, currentSessionDirectory: id ? resolvedDir ?? null : null })
set({
currentSessionId: id,
currentSessionDirectory: id ? resolvedDir ?? null : null,
})
guessedSelectionSessionId = isGuessedDir && id ? id : null
const rememberedDir = isGuessedDir ? null : resolvedDir ?? null
writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir })
@@ -887,6 +984,9 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (sessionProject && projectsState.activeProjectId !== sessionProject.id) {
projectsState.setActiveProjectIdOnly(sessionProject.id)
}
if (id && !isGuessedDir && sessionProject) {
useSessionDisplayStore.getState().setSingleProjectId(sessionProject.id)
}
opencodeClient.setDirectory(resolvedDir ?? undefined)
} catch (e) {
console.warn("Failed to set OpenCode directory for session switch:", e)
@@ -896,7 +996,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// skeleton to render and reads messages which can be expensive.
if (previousSessionId && previousSessionId !== id) {
const prevId = previousSessionId
setTimeout(() => {
const newId = id
// queueMicrotask runs after the current synchronous call stack (and
// before the next macrotask / setTimeout(0) / paint), so the previous
// session's anchor is saved before the new session's restoreSnapshot
// effect fires. This eliminates the race where save and restore
// interleave against the same viewport store entry.
queueMicrotask(() => {
// Bail if the user already switched again — save is now stale.
const current = get().currentSessionId
if (current !== newId) return
const memState = getViewportSessionMemory(prevId)
if (!memState?.isStreaming) {
const prevMessages = getSyncMessages(prevId)
@@ -904,7 +1013,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
useViewportStore.getState().updateViewportAnchor(prevId, prevMessages.length - 1)
}
}
}, 0)
});
}
// Mark session viewed in notification store + update active session ref
@@ -914,6 +1023,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
}
},
clearMaterializedDraftSession: (sessionId) => {
if (get().materializedDraftSessionId !== sessionId) return
set({ materializedDraftSessionId: null })
},
prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => {
const key = runtimeMemoryKey(apiBaseUrl)
const directory = useDirectoryStore.getState().currentDirectory || null
@@ -989,7 +1103,16 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const explicitDirectory = options?.directoryOverride !== undefined
? normalizePath(options.directoryOverride)
: null
const explicitProject = options?.selectedProjectId
let target = isVSCodeRuntime() ? "project" : options?.target
if (!target) {
const hasExplicitProjectTarget = options?.directoryOverride !== undefined
|| (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID)
|| isVSCodeRuntime()
target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget
? "chat"
: "project"
}
const explicitProject = target === "project" && options?.selectedProjectId
? projects.find((p) => p.id === options.selectedProjectId) ?? null
: null
@@ -1006,14 +1129,14 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null)
const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory)
const selectedProject = (() => {
const selectedProject = target === "chat" ? null : (() => {
if (explicitProject) return explicitProject
if (explicitDirectory !== null) return inferredProjectFromDir
if (currentDirectory) return currentDirProject
return persistedProjectByDir ?? persistedProjectById ?? fallbackProject
})()
const directory = (() => {
const directory = target === "chat" ? null : (() => {
if (explicitDirectory !== null) return explicitDirectory
if (explicitProject) return normalizePath(explicitProject.path ?? null)
if (currentDirectory) return currentDirectory
@@ -1021,10 +1144,17 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return normalizePath(selectedProject?.path ?? null)
})()
if (target === "chat") {
warmChatsRootDirectory()
}
persistDraftTarget({ projectId: selectedProject?.id ?? null, directory })
const nextDraft: NewSessionDraftState = {
draftId: nextDraftId++,
open: true,
target,
preparedChatDirectory: null,
selectedProjectId: selectedProject?.id ?? null,
directoryOverride: directory,
permissionAutoAcceptEnabled: options?.permissionAutoAcceptEnabled === true,
@@ -1040,9 +1170,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
}
set({
newSessionDraft: {
...nextDraft,
},
newSessionDraft: nextDraft,
currentSessionId: null,
currentSessionDirectory: null,
error: null,
@@ -1068,6 +1196,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
void activateConfigForDirectory(configDirectory).then(() => {
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: selectedProject?.defaultModel,
projectDefaultVariant: selectedProject?.defaultVariant,
})
})
@@ -1078,11 +1207,44 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
void recoverStaleDraftDirectory(nextDraft)
},
prepareChatDraftDirectory: async () => {
const draft = get().newSessionDraft
if (!draft.open || draft.target !== "chat") return null
if (draft.preparedChatDirectory) return draft.preparedChatDirectory
const runtimeKey = getRuntimeKey()
const key = `${runtimeKey}:${draft.draftId}`
const existing = pendingChatDirectoryByDraft.get(key)
if (existing) return existing
const pending = createChatDirectory().then(async (directory) => {
const current = get().newSessionDraft
if (
getRuntimeKey() !== runtimeKey
|| !current.open
|| current.target !== "chat"
|| current.draftId !== draft.draftId
) {
await deleteChatDirectory(directory).catch(() => undefined)
return null
}
set({ newSessionDraft: { ...current, preparedChatDirectory: directory } })
return directory
}).finally(() => {
pendingChatDirectoryByDraft.delete(key)
})
pendingChatDirectoryByDraft.set(key, pending)
return pending
},
// ---------------------------------------------------------------------------
// closeNewSessionDraft
// ---------------------------------------------------------------------------
closeNewSessionDraft: () => {
const currentDraft = get().newSessionDraft
if (currentDraft.preparedChatDirectory) {
void deleteChatDirectory(currentDraft.preparedChatDirectory).catch(() => undefined)
}
if (
!currentDraft.open
&& currentDraft.selectedProjectId == null
@@ -1100,18 +1262,21 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return
}
const nextDraft: NewSessionDraftState = {
open: false,
selectedProjectId: null,
directoryOverride: null,
pendingWorktreeRequestId: null,
bootstrapPendingDirectory: null,
preserveDirectoryOverride: false,
parentID: null,
title: undefined,
initialPrompt: undefined,
syntheticParts: undefined,
targetFolderId: undefined,
}
draftId: currentDraft.draftId,
open: false,
target: "chat",
preparedChatDirectory: null,
selectedProjectId: null,
directoryOverride: null,
pendingWorktreeRequestId: null,
bootstrapPendingDirectory: null,
preserveDirectoryOverride: false,
parentID: null,
title: undefined,
initialPrompt: undefined,
syntheticParts: undefined,
targetFolderId: undefined,
}
set({
newSessionDraft: nextDraft,
})
@@ -1119,14 +1284,21 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
},
setNewSessionDraftTarget: (target) => {
if (isVSCodeRuntime() && target.projectId === CHAT_DRAFT_PROJECT_ID) return
const previousDraft = get().newSessionDraft
if (previousDraft.preparedChatDirectory && target.projectId !== CHAT_DRAFT_PROJECT_ID) {
void deleteChatDirectory(previousDraft.preparedChatDirectory).catch(() => undefined)
}
let nextDirectory: string | null = null
set((s) => {
nextDirectory = normalizePath(target.directoryOverride ?? s.newSessionDraft.directoryOverride)
return {
newSessionDraft: {
...s.newSessionDraft,
target: target.projectId === CHAT_DRAFT_PROJECT_ID ? "chat" : "project",
preparedChatDirectory: target.projectId === CHAT_DRAFT_PROJECT_ID ? s.newSessionDraft.preparedChatDirectory : null,
selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId,
directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride,
directoryOverride: target.projectId === CHAT_DRAFT_PROJECT_ID ? null : target.directoryOverride ?? s.newSessionDraft.directoryOverride,
},
}
})
@@ -1333,7 +1505,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
agent?: string,
attachments?: AttachedFile[],
agentMentionName?: string,
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>,
additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }>,
variant?: string,
inputMode?: "normal" | "shell",
options?: SendMessageOptions,
@@ -1421,7 +1593,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
createdDraftSession.directory,
createdDraftSession.sessionId,
)
const draftPrefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> =
const draftPrefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
draftKnowledge.text ? [{ text: draftKnowledge.text, synthetic: true }] : []
// Left undefined when nothing was added, as before: an empty array is not
// the same as no additional parts to everything downstream.
@@ -1456,6 +1628,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
additionalParts: mergedAdditionalParts?.map((p) => ({
text: p.text,
synthetic: p.synthetic,
metadata: p.metadata,
files: p.attachments?.map((a: AttachedFile) => ({
type: "file" as const,
mime: a.mimeType,
@@ -1537,7 +1710,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// Prepended so it reads as background before the message it accompanies,
// and empty unless the session is actually missing it.
const knowledge = await fetchSessionKnowledge(currentSessionDirectory, targetSessionId || "")
const prefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }> =
const prefixParts: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean; metadata?: ContextPartMetadata }> =
knowledge.text ? [{ text: knowledge.text, synthetic: true }] : []
const partsWithPinnedContext = prefixParts.length > 0
? [...prefixParts, ...(additionalParts || [])]
@@ -1559,6 +1732,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
additionalParts: partsWithPinnedContext?.map((p) => ({
text: p.text,
synthetic: p.synthetic,
metadata: p.metadata,
files: p.attachments?.map((a) => ({
type: "file" as const,
mime: a.mimeType,
@@ -1575,32 +1749,8 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
// ---------------------------------------------------------------------------
// createSession
// ---------------------------------------------------------------------------
createSession: async (title, directoryOverride, parentID, metadata) => {
const draft = get().newSessionDraft
const targetFolderId = draft.targetFolderId
try {
const resolved = await resolveCreatableDraftDirectory(draft, directoryOverride)
if (resolved.status === "aborted") return null
const dir = resolved.directory
const session = await createSessionAction(title, dir, parentID ?? null, metadata)
if (!session) return null
get().closeNewSessionDraft()
if (targetFolderId) {
const scopeKey = dir || get().lastLoadedDirectory || session.directory
if (scopeKey) {
useSessionFoldersStore.getState().addSessionToFolder(scopeKey, targetFolderId, session.id)
}
}
return session
} catch (e) {
console.error("[session-ui-store] createSession failed", e)
return null
}
},
createSession: (title, directoryOverride, parentID, metadata) =>
createSessionWithDraftLifecycle(title, directoryOverride, parentID, metadata),
// ---------------------------------------------------------------------------
// deleteSession — calls SDK, SSE event updates child store
@@ -0,0 +1,67 @@
import { describe, expect, test } from 'bun:test'
import React, { act } from 'react'
import { createRoot } from 'react-dom/client'
import { createOpencodeClient } from '@opencode-ai/sdk/v2'
import { SyncProvider, useSyncDirectory } from './sync-context'
import { usePrefetchSessionMessages } from './use-sync'
import { installHookTestDom } from '../components/session/sidebar/test-utils/testDom'
const createSdk = () => createOpencodeClient({
baseUrl: 'https://sync.test',
fetch: async (request) => {
const path = new URL(request instanceof Request ? request.url : request.toString()).pathname
if (path.endsWith('/global/event')) {
return new Response(new ReadableStream(), { headers: { 'content-type': 'text/event-stream' } })
}
const body = path.endsWith('/path')
? { state: '', config: '', worktree: '/workspace', directory: '/workspace', home: '/home' }
: path.endsWith('/project') ? []
: path.endsWith('/project/current') ? { id: 'project' }
: path.endsWith('/session/status') ? {}
: []
return new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } })
},
})
describe('SyncProvider selection boundary', () => {
test('does not rerender a stable prefetch consumer when only current directory changes', async () => {
const dom = installHookTestDom()
const root = createRoot(dom.container)
let runtimeRenders = 0
let directoryRenders = 0
let callback: ReturnType<typeof usePrefetchSessionMessages> | undefined
const RuntimeConsumer = React.memo(() => {
callback = usePrefetchSessionMessages()
runtimeRenders += 1
return null
})
const DirectoryConsumer = () => {
useSyncDirectory()
directoryRenders += 1
return null
}
const sdk = createSdk()
try {
await act(async () => root.render(
<SyncProvider sdk={sdk} directory="/workspace/a">
<RuntimeConsumer />
<DirectoryConsumer />
</SyncProvider>,
))
const initialCallback = callback
await act(async () => root.render(
<SyncProvider sdk={sdk} directory="/workspace/b">
<RuntimeConsumer />
<DirectoryConsumer />
</SyncProvider>,
))
expect(runtimeRenders).toBe(1)
expect(callback).toBe(initialCallback)
expect(directoryRenders).toBe(2)
} finally {
await act(async () => root.unmount())
dom.restore()
}
})
})
+164 -54
View File
@@ -37,7 +37,8 @@ import { setActionRefs } from "./session-actions"
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { useSessionUIStore } from "./session-ui-store"
import { stripSessionDiffSnapshots } from "./sanitize"
import { applySessionEventToGlobalSessions } from "./session-event-router"
import { upsertSessionRecord } from "./session-records"
import { applySessionEventToGlobalSessions, applySessionEventsToGlobalSessions } from "./session-event-router"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
import { messagesBefore } from "./message-ordering"
@@ -52,7 +53,12 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
import { applyGlobalSessionStatusEvent, applyGlobalSessionStatusSnapshot, useGlobalSessionStatusStore } from "./global-session-status"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
applyGlobalSessionStatusSnapshot,
useGlobalSessionStatusStore,
} from "./global-session-status"
import type { State } from "./types"
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest } from "@/types/permission"
@@ -86,22 +92,29 @@ import {
// Context
// ---------------------------------------------------------------------------
type SyncSystem = {
type SyncRuntime = {
childStores: ChildStoreManager
messageLoader: SessionMessageLoader
runtimeKey: string
sdk: OpencodeClient
}
type SyncSystem = SyncRuntime & {
directory: string
}
const SYNC_CONTEXT_GLOBAL_KEY = "__openchamber_sync_context__"
const SYNC_RUNTIME_CONTEXT_GLOBAL_KEY = "__openchamber_sync_runtime_context__"
type SyncGlobal = typeof globalThis & {
[SYNC_CONTEXT_GLOBAL_KEY]?: React.Context<SyncSystem | null>
[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY]?: React.Context<SyncRuntime | null>
}
const syncGlobal = globalThis as SyncGlobal
const SyncContext = syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] ?? createContext<SyncSystem | null>(null)
syncGlobal[SYNC_CONTEXT_GLOBAL_KEY] = SyncContext
const SyncRuntimeContext = syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] ?? createContext<SyncRuntime | null>(null)
syncGlobal[SYNC_RUNTIME_CONTEXT_GLOBAL_KEY] = SyncRuntimeContext
type SdkResult<T> = {
data?: T
@@ -137,6 +150,12 @@ function useSyncSystem() {
return ctx
}
export function useSyncRuntime() {
const ctx = useContext(SyncRuntimeContext)
if (!ctx) throw new Error("useSyncRuntime must be used within <SyncProvider>")
return ctx
}
function getLiveStates(childStores: ChildStoreManager): State[] {
return Array.from(childStores.children.values(), (store) => store.getState())
}
@@ -147,25 +166,42 @@ function useLiveSyncSelector<T>(
subscribe?: (childStores: ChildStoreManager, notify: () => void) => () => void,
): T {
const { childStores } = useSyncSystem()
const cacheRef = useRef<T | undefined>(undefined)
const initializedRef = useRef(false)
const sourceRevisionRef = useRef(0)
const cacheRef = useRef<{
childStores: ChildStoreManager
selector: (states: State[]) => T
revision: number
value: T
} | null>(null)
const getSnapshot = useCallback(() => {
const next = selector(getLiveStates(childStores))
if (initializedRef.current && isEqual(cacheRef.current as T, next)) {
return cacheRef.current as T
const cached = cacheRef.current
if (
cached
&& cached.childStores === childStores
&& cached.selector === selector
&& cached.revision === sourceRevisionRef.current
) {
return cached.value
}
cacheRef.current = next
initializedRef.current = true
return next
const next = selector(getLiveStates(childStores))
const value = cached && isEqual(cached.value, next) ? cached.value : next
cacheRef.current = { childStores, selector, revision: sourceRevisionRef.current, value }
return value
}, [childStores, isEqual, selector])
const subscribeToSource = useCallback((notify: () => void) => {
const invalidate = () => {
sourceRevisionRef.current += 1
notify()
}
// Force the post-subscribe snapshot to close the read-before-subscribe gap.
sourceRevisionRef.current += 1
return subscribe ? subscribe(childStores, invalidate) : childStores.subscribeAll(invalidate)
}, [childStores, subscribe])
return React.useSyncExternalStore(
useCallback(
(notify) => subscribe ? subscribe(childStores, notify) : childStores.subscribeAll(notify),
[childStores, subscribe],
),
subscribeToSource,
getSnapshot,
getSnapshot,
)
@@ -181,12 +217,16 @@ type DirectoryEventBatch = {
states: Map<StoreApi<DirectoryStore>, DirectoryStore>
clonedFields: Map<StoreApi<DirectoryStore>, Set<keyof State>>
changedStores: Set<StoreApi<DirectoryStore>>
globalSessionEvents: Event[]
globalStatusEventsByDirectory: Map<string, Event[]>
}
const createDirectoryEventBatch = (): DirectoryEventBatch => ({
states: new Map(),
clonedFields: new Map(),
changedStores: new Set(),
globalSessionEvents: [],
globalStatusEventsByDirectory: new Map(),
})
const getDirectoryEventState = (
@@ -195,6 +235,10 @@ const getDirectoryEventState = (
): DirectoryStore => batch?.states.get(store) ?? store.getState()
const publishDirectoryEventBatch = (batch: DirectoryEventBatch): void => {
applySessionEventsToGlobalSessions(batch.globalSessionEvents)
for (const [directory, events] of batch.globalStatusEventsByDirectory) {
applyGlobalSessionStatusEvents(directory, events)
}
for (const store of batch.changedStores) {
const state = batch.states.get(store)
if (!state) continue
@@ -227,7 +271,10 @@ export function useAllSessionStatuses(): Record<string, SessionStatus> {
export function useAllLiveSessions(): Session[] {
return useLiveSyncSelector(
useCallback((states) => aggregateLiveSessions(states), []),
useCallback((states) => {
countSyncPerformance("liveSessionAggregateRuns")
return aggregateLiveSessions(states)
}, []),
areSessionListsEquivalent,
useCallback(
(childStores: ChildStoreManager, notify: () => void) => childStores.subscribeAllSelected(
@@ -1408,28 +1455,13 @@ async function resyncDirectoryAfterReconnect(
const nextSession = stripSessionDiffSnapshots(session)
store.setState((state: DirectoryStore) => {
const sessionIndex = state.session.findIndex((item) => item.id === nextSession.id)
let sessions = state.session
let sessionChanged = false
const sessions = upsertSessionRecord(state.session, nextSession)
let sessionTotal = state.sessionTotal
if (sessionIndex >= 0) {
if (!haveEquivalentSyncSnapshots(sessions[sessionIndex], nextSession)) {
sessions = [...state.session]
sessions[sessionIndex] = nextSession
sessionChanged = true
}
} else {
sessions = [...state.session]
sessions.push(nextSession)
sessions.sort((a, b) => cmp(a.id, b.id))
if (!nextSession.parentID) sessionTotal += 1
sessionChanged = true
}
if (!sessionChanged) {
if (sessions === state.session) {
return state
}
if (!state.session.some((item) => item.id === nextSession.id) && !nextSession.parentID) sessionTotal += 1
return {
session: sessions,
@@ -1455,6 +1487,7 @@ export function handleEvent(
skipVSCodeAutoAccept = false,
streamingDirectory?: string,
batch?: DirectoryEventBatch,
globalEffectsAlreadyApplied = false,
) {
if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") {
const properties = (payload as unknown as { properties?: unknown }).properties
@@ -1483,12 +1516,19 @@ export function handleEvent(
return
}
applySessionEventToGlobalSessions(payload)
// Keep the cross-project status map current for ALL directories (mirrors the
// global-session handling above). Child stores remain the primary source for
// synced directories; this map covers sessions a child store doesn't list
// (unopened directories, or list/status races for just-created sessions).
applyGlobalSessionStatusEvent(directory, payload)
if (!globalEffectsAlreadyApplied) {
if (batch) {
batch.globalSessionEvents.push(payload)
const statusEvents = batch.globalStatusEventsByDirectory.get(directory)
if (statusEvents) statusEvents.push(payload)
else batch.globalStatusEventsByDirectory.set(directory, [payload])
} else {
applySessionEventToGlobalSessions(payload)
// Child stores remain the primary source for synced directories; this
// index covers unopened directories and list/status races.
applyGlobalSessionStatusEvent(directory, payload)
}
}
// Global events
if (directory === "global" || !directory) {
@@ -1571,7 +1611,17 @@ export function handleEvent(
if (eventKey && pendingVSCodePermissionEvents.get(eventKey) !== eventToken) return
if (eventKey) pendingVSCodePermissionEvents.delete(eventKey)
if (expectedRuntimeKey !== getRuntimeKey()) return
if (!accepted) handleEvent(rawDirectory, payload, childStores, routingIndex, expectedRuntimeKey, true, streamingDirectory)
if (!accepted) handleEvent(
rawDirectory,
payload,
childStores,
routingIndex,
expectedRuntimeKey,
true,
streamingDirectory,
undefined,
true,
)
}
void processVSCodePermissionAutoAccept(permission, resolvedDirectory).then(
completePermissionCheck,
@@ -2024,15 +2074,13 @@ export function SyncProvider(props: {
const pipelineHasConnectedRef = useRef(false)
const pipelineDisconnectedBeforeFirstConnectRef = useRef(false)
const runtime = useMemo<SyncRuntime>(
() => ({ childStores, messageLoader, runtimeKey, sdk: props.sdk }),
[childStores, messageLoader, props.sdk, runtimeKey],
)
const system = useMemo<SyncSystem>(
() => ({
childStores,
messageLoader,
runtimeKey,
sdk: props.sdk,
directory: props.directory,
}),
[childStores, messageLoader, props.sdk, props.directory, runtimeKey],
() => ({ ...runtime, directory: props.directory }),
[props.directory, runtime],
)
const triggerDirectoryResync = useCallback((directory: string, reason: SessionMaterializationReason) => {
@@ -2547,7 +2595,14 @@ export function SyncProvider(props: {
return unsubscribe
}, [props.directory, childStores])
return <SyncContext.Provider value={system}>{props.children}</SyncContext.Provider>
// Directory navigation must not republish stable runtime dependencies.
return (
<SyncContext.Provider value={system}>
<SyncRuntimeContext.Provider value={runtime}>
{props.children}
</SyncRuntimeContext.Provider>
</SyncContext.Provider>
)
}
// ---------------------------------------------------------------------------
@@ -2647,6 +2702,48 @@ export function useSessionParts(messageID: string, directory?: string) {
)
}
const EMPTY_PARTS_BY_MESSAGE: Record<string, Part[]> = {}
/**
* Get parts for several messages at once, keyed by message id. The snapshot
* keeps its identity until one of the requested part arrays changes, so a
* streaming turn can overlay every one of its step messages not only the
* currently streaming one without tearing between them when the stream
* moves to the next message.
*/
export function useSessionPartsForMessages(messageIDs: readonly string[], directory?: string): Record<string, Part[]> {
const store = useDirectoryStore(directory)
const cacheRef = React.useRef<{ ids: readonly string[]; parts: Record<string, Part[]> } | null>(null)
const getSnapshot = useCallback(() => {
if (messageIDs.length === 0) return EMPTY_PARTS_BY_MESSAGE
const state = store.getState()
const cached = cacheRef.current
if (
cached
&& cached.ids === messageIDs
&& messageIDs.every((id) => (state.part[id] ?? EMPTY_PARTS) === (cached.parts[id] ?? EMPTY_PARTS))
) {
return cached.parts
}
const parts: Record<string, Part[]> = {}
for (const id of messageIDs) parts[id] = state.part[id] ?? EMPTY_PARTS
cacheRef.current = { ids: messageIDs, parts }
return parts
}, [messageIDs, store])
const subscribe = useCallback((notify: () => void) => {
if (messageIDs.length === 0) return () => undefined
return store.subscribe((state, previous) => {
for (const id of messageIDs) {
if (state.part[id] !== previous.part[id]) {
notify()
return
}
}
})
}, [messageIDs, store])
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
/** Get status for a specific session */
export function useSessionStatus(sessionID: string, directory?: string) {
const store = useDirectoryStore(directory)
@@ -2790,13 +2887,25 @@ export function useScopedBlockingQuestions(sessionID: string | null, directory?:
return useScopedBlockingRequests(sessionID, directory, selectQuestionRequestsBySession, EMPTY_QUESTION_REQUESTS)
}
const sessionsByIdCache = new WeakMap<State["session"], Map<string, Session>>()
const getSessionById = (sessions: State["session"], sessionID?: string | null): Session | undefined => {
if (!sessionID) return undefined
let sessionsById = sessionsByIdCache.get(sessions)
if (!sessionsById) {
sessionsById = new Map(sessions.map((session) => [session.id, session]))
sessionsByIdCache.set(sessions, sessionsById)
}
return sessionsById.get(sessionID)
}
export function useParentSession(sessionID: string | null, directory?: string): Session | null {
return useDirectorySync(
useCallback((state: State) => {
if (!sessionID) return null
const current = state.session.find((s) => s.id === sessionID)
const current = getSessionById(state.session, sessionID)
if (!current?.parentID) return null
return state.session.find((s) => s.id === current.parentID)
return getSessionById(state.session, current.parentID)
?? getAllSyncSessions().find((s) => s.id === current.parentID)
?? null
}, [sessionID]),
@@ -2809,7 +2918,8 @@ export function useSession(sessionID?: string | null, directory?: string) {
const { childStores } = useSyncSystem()
const getSnapshot = useCallback(() => {
if (directory) {
return childStores.getChild(directory)?.getState().session.find((session) => session.id === sessionID)
const sessions = childStores.getChild(directory)?.getState().session
return sessions ? getSessionById(sessions, sessionID) : undefined
}
return findLiveSession(getLiveStates(childStores), sessionID)
}, [childStores, directory, sessionID])
+107 -116
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo } from "react"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { upsertSessionRecord } from "./session-records"
import { retry } from "./retry"
import { SESSION_CACHE_LIMIT, type State } from "./types"
import { pickSessionCacheEvictions } from "./session-cache"
import { dropSessionCaches, getProtectedSessionCacheIds, pickSessionCacheEvictions } from "./session-cache"
import {
dropCachedSessionMessageRecordsSnapshots,
useChildStoreManager,
@@ -11,9 +12,10 @@ import {
useSessionMessageLoader,
useSyncDirectory,
useSyncSDK,
useSyncRuntime,
resyncBlockingRequestsForDirectory,
buildSessionMessageRecordsSnapshot,
} from "./sync-context"
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
import { stripSessionDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
@@ -46,7 +48,6 @@ const syncSessionInflightByKey = new Map<string, Promise<void>>()
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
// sidebar) from having each completed fetch fight for focus.
const syncSessionGenerationByKey = new Map<string, number>()
type SdkResult<T> = {
data?: T
error?: unknown
@@ -111,10 +112,80 @@ export function shouldFetchSessionForRenderableSync(input: {
return Boolean(input.force) || !input.hasSession || input.shouldLoadMessages
}
// ---------------------------------------------------------------------------
// useSync — message loading, pagination, optimistic updates
// Message loading, pagination, optimistic updates
// ---------------------------------------------------------------------------
function useSessionCacheTouch() {
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
const evict = useCallback(
(directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
const store = childStores.getChild(directory)
if (!store) return
const current = store.getState()
const draft = {
message: { ...current.message },
part: { ...current.part },
session_status: { ...current.session_status },
session_diff: { ...current.session_diff },
todo: { ...current.todo },
permission: { ...current.permission },
question: { ...current.question },
}
dropSessionCaches(draft, sessionIDs)
dropCachedSessionMessageRecordsSnapshots(store, sessionIDs)
store.setState(draft)
for (const sessionID of sessionIDs) messageLoader.invalidateSession({ directory, sessionID })
clearSessionPrefetch(directory, sessionIDs)
},
[childStores, messageLoader, runtimeKey],
)
const seenFor = useCallback((directory: string) => {
const cacheKey = `${runtimeKey}\n${directory}`
const existing = seenByDirectory.get(cacheKey)
if (existing) {
seenByDirectory.delete(cacheKey)
seenByDirectory.set(cacheKey, existing)
return existing.sessions
}
const created: SeenDirectoryEntry = { runtimeKey, directory, sessions: new Set() }
seenByDirectory.set(cacheKey, created)
while (seenByDirectory.size > MAX_SEEN_DIRS) {
const oldestKey = seenByDirectory.keys().next().value
if (!oldestKey) break
const oldest = seenByDirectory.get(oldestKey)
seenByDirectory.delete(oldestKey)
if (oldest?.runtimeKey === runtimeKey) evict(oldest.directory, [...oldest.sessions])
}
return created.sessions
}, [evict, runtimeKey])
return useCallback((sessionID: string, directory: string) => {
if (getRuntimeKey() !== runtimeKey) return
const seen = seenFor(directory)
const store = childStores.ensureChild(directory, { bootstrap: false })
const protectedIds = getProtectedSessionCacheIds(store.getState())
const stale = pickSessionCacheEvictions({
seen,
keep: sessionID,
limit: getEffectiveSessionCacheLimit(),
preserve: protectedIds,
})
evict(directory, stale)
if (!isConstrainedSessionRuntime()) return
const state = store.getState()
const keep = new Set([sessionID, ...seen, ...protectedIds])
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
evict(directory, prefetched)
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => (
id !== sessionID && !protectedIds.has(id) && isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
))
for (const id of heavyInactive) seen.delete(id)
evict(directory, heavyInactive)
}, [childStores, evict, runtimeKey, seenFor])
}
export function useSync() {
const sdk = useSyncSDK()
@@ -123,6 +194,7 @@ export function useSync() {
const childStores = useChildStoreManager()
const messageLoader = useSessionMessageLoader()
const runtimeKey = getRuntimeKey()
const touch = useSessionCacheTouch()
const recoverPendingQuestions = useCallback(
async (sessionID: string, directoryOverride?: string): Promise<boolean> => {
@@ -146,107 +218,6 @@ export function useSync() {
[directory, runtimeKey],
)
// Session cache eviction — two levels of LRU:
// (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT).
// Evict all cached session data for given IDs from a directory's store
const evict = useCallback(
(dir: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
const dirStore = childStores.getChild(dir)
if (!dirStore) return
const current = dirStore.getState()
const draft = {
message: { ...current.message },
part: { ...current.part },
session_status: { ...current.session_status },
session_diff: { ...current.session_diff },
todo: { ...current.todo },
permission: { ...current.permission },
question: { ...current.question },
}
dropSessionCaches(draft, sessionIDs)
dropCachedSessionMessageRecordsSnapshots(dirStore, sessionIDs)
dirStore.setState(draft)
// Clear meta + optimistic + prefetch cache for evicted sessions
for (const id of sessionIDs) {
messageLoader.invalidateSession({ directory: dir, sessionID: id })
}
clearSessionPrefetch(dir, sessionIDs)
},
[childStores, messageLoader, runtimeKey],
)
// Get or create the seen-set for a directory. LRU reorder on access.
// When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches.
// LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS.
const seenFor = useCallback((targetDirectory: string) => {
const cacheKey = `${runtimeKey}\n${targetDirectory}`
const existing = seenByDirectory.get(cacheKey)
if (existing) {
// LRU reorder: delete + re-insert moves to end (most recent)
seenByDirectory.delete(cacheKey)
seenByDirectory.set(cacheKey, existing)
return existing.sessions
}
const created: SeenDirectoryEntry = { runtimeKey, directory: targetDirectory, sessions: new Set() }
seenByDirectory.set(cacheKey, created)
// Evict oldest directories if over limit
while (seenByDirectory.size > MAX_SEEN_DIRS) {
const first = seenByDirectory.keys().next().value
if (!first) break
const stale = seenByDirectory.get(first)
seenByDirectory.delete(first)
if (stale?.runtimeKey === runtimeKey) evict(stale.directory, [...stale.sessions])
}
return created.sessions
}, [evict, runtimeKey])
// Touch a session — triggers both directory-level and session-level eviction
const touch = useCallback(
(sessionID: string, targetDirectory = directory) => {
if (getRuntimeKey() !== runtimeKey) return
const s = seenFor(targetDirectory)
const targetStore = targetDirectory === directory
? store
: childStores.ensureChild(targetDirectory, { bootstrap: false })
const protectedIds = getProtectedSessionCacheIds(targetStore.getState())
const cacheLimit = getEffectiveSessionCacheLimit()
const stale = pickSessionCacheEvictions({
seen: s,
keep: sessionID,
limit: cacheLimit,
preserve: protectedIds,
})
evict(targetDirectory, stale)
if (isConstrainedSessionRuntime()) {
const state = targetStore.getState()
const keep = new Set([sessionID, ...s, ...protectedIds])
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
evict(targetDirectory, prefetched)
// One very large inactive session can create memory/GC pressure that
// makes later small-session switches feel slow. Keep it while active,
// but do not retain it as a warm cache in constrained shells.
const afterPrefetchEviction = prefetched.length > 0 ? targetStore.getState() : state
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
if (id === sessionID || protectedIds.has(id)) return false
return isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
})
if (heavyInactive.length > 0) {
for (const id of heavyInactive) s.delete(id)
evict(targetDirectory, heavyInactive)
}
}
},
[childStores, directory, seenFor, evict, runtimeKey, store],
)
// Sync a session (load if not cached)
const syncSession = useCallback(
async (sessionID: string, force?: boolean, directoryOverride?: string) => {
@@ -289,14 +260,8 @@ export function useSync() {
if (result.data && !isStale()) {
const nextSession = stripSessionDiffSnapshots(result.data)
const s = targetStore.getState()
const sessions = [...s.session]
const idx = Binary.search(sessions, sessionID, (s) => s.id)
if (idx.found) {
sessions[idx.index] = nextSession
} else {
sessions.splice(idx.index, 0, nextSession)
}
if (!isStale()) {
const sessions = upsertSessionRecord(s.session, nextSession)
if (sessions !== s.session && !isStale()) {
targetStore.setState({ session: sessions })
}
}
@@ -436,3 +401,29 @@ export function useSync() {
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, recoverPendingQuestions, optimisticAdd, optimisticRemove, optimisticConfirm],
)
}
export function usePrefetchSessionMessages() {
const { messageLoader, runtimeKey } = useSyncRuntime()
const touch = useSessionCacheTouch()
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
if (getRuntimeKey() !== runtimeKey) return
await messageLoader.prefetch({ directory, sessionID })
if (messageLoader.getSnapshot({ directory, sessionID }).status !== "ready") return
touch(sessionID, directory)
}, [messageLoader, runtimeKey, touch])
}
export function useSessionMessageRecordsForExport() {
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
const touch = useSessionCacheTouch()
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
if (getRuntimeKey() !== runtimeKey) return null
const store = childStores.ensureChild(directory, { bootstrap: false })
touch(sessionID, directory)
await messageLoader.loadComplete({ directory, sessionID })
if (getRuntimeKey() !== runtimeKey) return null
return buildSessionMessageRecordsSnapshot(store.getState(), sessionID).list
}, [childStores, messageLoader, runtimeKey, touch])
}