Merge main
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user