Merge branch 'main' into pr2969-integration

This commit is contained in:
Bohdan Triapitsyn
2026-08-26 02:22:29 +03:00
667 changed files with 46545 additions and 14452 deletions
+32 -7
View File
@@ -18,7 +18,8 @@ There are **two distinct session data scopes** in the UI:
- Holds:
- global active sessions
- global archived sessions
- active sessions indexed by directory
- active and archived entities indexed by ID
- active root, parent/child, and directory indexes
These two scopes are intentionally different, but they are no longer equal peers for live UI truth.
@@ -43,11 +44,11 @@ So:
|---|---|---|
| `ChildStoreManager` and child directory stores | Priority-scheduled directory bootstrap plus `session`, `message`, `part`, `permission`, `question`, etc. | One runtime and one store per directory |
| `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID |
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime |
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots, plus a reference-stable active-ID membership collection maintained from the same mutations | All known directories in the active runtime |
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
| `session-ui-store.ts` | Session selection, draft lifecycle, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active sessions, global archived sessions, `sessionsByDirectory` | All opened project/worktree session lists |
| `session-ui-store.ts` | Session selection, draft lifecycle, one-shot draft-materialization transition identity, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
| `useGlobalSessionsStore.ts` | Global active/archived entities plus root, parent/child, and directory indexes | All opened project/worktree session lists |
| `viewport-store.ts` | Scroll anchors, session memory, loading indicators | App UI state |
| `attachment-files.ts` | Attachment picker allowlists, MIME/content validation, structured-text sanitization, and HEIC conversion | Local chat attachments across shared UI runtimes |
| `document-attachments.ts` | Bounded Office/OpenDocument extraction, document text serialization, embedded-image extraction, and positional citations | DOCX, PPTX, XLSX, ODT, ODP, and ODS chat attachments |
@@ -57,12 +58,16 @@ So:
Local chat attachments are normalized by `attachment-files.ts` before entering `input-store.ts`. PNG, JPEG, GIF, WebP, and PDF retain their media type; HEIC/HEIF is converted to JPEG; recognized text/code formats and unknown files whose first 4 KB are text are sent as `text/plain`; binary files outside the supported media types are rejected. Jupyter notebooks become readable markdown with non-text outputs omitted. HAR credentials, cookies, and sensitive URL parameters are redacted, while request/response body text is omitted. SVG and Draw.io files are attached as source text, not executable/rendered content. Browser and VS Code pickers expose the same allowlist, while drag-and-drop may still accept an unknown extension after content inspection.
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 2,000,000 characters. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
Office and OpenDocument packages are metadata-validated before asynchronous extraction, with limits of 20 MB compressed input, 5,000 archive entries, 25 MB per entry, 8 MB per XML part, and 100 MB total uncompressed content. Unsafe or non-canonical archive paths reject the whole attachment, and only XML, relationship, and supported image entries are decompressed and retained. Extracted text, including its explicit truncation notice, is bounded to 500,000 characters so compact but dense Office files cannot consume an entire model context window. XLSX dense rows are serialized as quoted TSV under a single source range instead of repeating every cell address; highly sparse rows retain explicit cell coordinates so distant cells do not generate vast empty TSV spans. Confirmed Office/OpenDocument `@file` mentions are loaded through the runtime filesystem route before submit and use this same extraction pipeline instead of being forwarded as `text/plain` `file://` parts that OpenCode rejects as binary. A failed mention load or extraction leaves the composer intact, and a runtime switch discards preparation from the previous runtime. At most 50 signature-validated PNG, JPEG, GIF, or WebP images and 40 MB of image bytes are retained, with a 20 MB per-image limit; unsupported, invalid, omitted, and truncated content remains explicit in the extracted text. Images whose citations fall beyond text truncation are not attached. Extracted document content remains a `text/plain` file attachment with the original document filename, rather than becoming visible user-message text. Supported embedded images become separate image file parts; the extracted text contains `[filename]` citations at the source paragraph, slide object, spreadsheet cell anchor, or OpenDocument text position. Generated image filenames are re-evaluated if the composer changes during asynchronous preparation, avoiding collisions. The store publishes all generated parts atomically only after every data URL is ready.
The composer compares normalized attachment MIME types with the selected model's declared input modalities. It warns when a newly attached file or an existing attachment after a model change requires an unsupported modality, but does not block sending. Missing modality metadata remains unknown and does not produce a warning.
## 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.
@@ -209,7 +224,7 @@ Incomplete-session materialization is deduplicated by runtime, directory, and se
When `session.idle` or `session.error` settles a session but the trailing assistant message still contains a `pending` or `running` tool, sync refreshes that session tail. This narrowly reconciles a missed terminal tool-part event without refetching normally completed turns or stale tools from older turns. A stale refresh or delayed part event cannot regress a locally observed terminal tool to an active status.
When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with active tool parts and no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the parts, see openchamber#2577 / anomalyco/opencode#19023). The active parts are finalized locally as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event or refresh supersedes it while a stale `running` refresh cannot regress it.
When a session is authoritatively settled — `session.idle`/`session.error` event, or an authoritative status snapshot that lowers a previously busy session — and the trailing assistant message is still *unfinished* (`time.completed` missing) with no pending question/permission, the turn is treated as interrupted (managed OpenCode process died mid-turn; the server never finalizes the message or parts, see openchamber#2577 / anomalyco/opencode#19023). The unfinished assistant message is completed locally with `MessageAbortedError`, including text-only turns and turns whose tools had already finished, so the chat shows a visible interrupted state. Any active parts are also finalized as `error`/`Interrupted` with an end time, so tool timers stop and cards render the error state. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while the session is busy (including question/permission waits), and a later terminal event can supersede it while a stale unfinished refresh cannot regress the locally finalized message or parts.
Directory stores also own session-keyed sidecar notification channels for permissions, questions, and message materialization. High-frequency realtime part events annotate the exact session/message before committing, so visible records, user history, renderability, and sidebar permission and question rows are not notified by unrelated sessions. Structural message replacements notify only changed subscribed session buckets; unannotated bulk part replacement conservatively resets active message subscribers so bootstrap, pagination, rollback, and legacy writers cannot leave stale projections.
@@ -324,6 +339,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())
@@ -3,7 +3,7 @@
* process dies mid-turn, the persisted turn never settles the trailing
* assistant message has no time.completed and its tool parts stay running.
* Once the session is authoritatively settled, `interruptedTurnToolParts`
* finalizes the orphaned parts locally.
* completes the assistant message as aborted and finalizes orphaned parts.
*/
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
@@ -74,10 +74,15 @@ describe("interruptedTurnToolParts (#2577)", () => {
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const part = result!.parts[0] as { state: { status: string; error: string; time: { end: number } } }
const part = result!.parts![0] as { state: { status: string; error: string; time: { end: number } } }
expect(part.state.status).toBe("error")
expect(part.state.error).toBe("Interrupted")
expect(part.state.time.end).toBe(5000)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
})
test("busy session is never marked (live work)", () => {
@@ -137,16 +142,43 @@ describe("interruptedTurnToolParts (#2577)", () => {
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
const statuses = result!.parts.map((part) => (part as { state: { status: string } }).state.status)
const statuses = result!.parts!.map((part) => (part as { state: { status: string } }).state.status)
expect(statuses).toEqual(["error", "completed", "error"])
})
test("no active parts → no change", () => {
test("unfinished assistant with no tools is completed as aborted", () => {
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [completedTool("tool_2", "msg_1")] },
part: {},
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
expect(result!.parts).toBe(undefined)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
})
test("completed tools are untouched while the unfinished assistant is aborted", () => {
const completed = completedTool("tool_2", "msg_1")
const store = state({
session_status: { ses_1: { type: "idle" } },
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
part: { msg_1: [completed] },
})
const result = interruptedTurnToolParts(store, "ses_1", 5000)
expect(result).not.toBeNull()
expect(result!.parts).toBe(undefined)
expect(store.part.msg_1[0]).toBe(completed)
expect(result!.messages[0]).toEqual({
...unfinishedAssistantMessage("msg_1"),
time: { created: 10, completed: 5000 },
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
})
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
})
})
@@ -4,6 +4,9 @@ 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]> = []
// 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 +76,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),
},
}))
@@ -239,13 +244,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,6 +346,7 @@ describe("issue 2039 draft auto-accept", () => {
beforeEach(() => {
storage.clear()
createSessionCalls.length = 0
sessionDirectoryRegistry.clear()
permissionAutoAcceptCalls.length = 0
createdSessionDirectory = undefined
@@ -327,9 +354,11 @@ describe("issue 2039 draft auto-accept", () => {
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: {
draftId: 0,
open: false,
directoryOverride: null,
parentID: null,
target: "chat",
},
})
})
@@ -373,6 +402,27 @@ describe("issue 2039 draft auto-accept", () => {
expect(permissionAutoAcceptCalls).toHaveLength(0)
})
test("transfers draft project context pins only to the session it creates", async () => {
useSessionUIStore.getState().openNewSessionDraft({
projectContextPins: { notes: ["note-a"], plans: [] },
})
useSessionUIStore.getState().setDraftProjectContextPin("plan", "plan-a", true)
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
expect(createSessionCalls[0]?.metadata).toEqual({
openchamber: {
project_context_pins: { notes: ["note-a"], plans: ["plan-a"] },
},
})
expect(useSessionUIStore.getState().newSessionDraft.projectContextPins).toBe(undefined)
useSessionUIStore.getState().openNewSessionDraft()
await materializeOpenDraftSession({ providerID: "provider", modelID: "model" })
expect(createSessionCalls[1]?.metadata).toBe(undefined)
})
test("uses the server-authoritative directory after worktree session creation", async () => {
createdSessionDirectory = "/canonical/worktree"
useSessionUIStore.getState().openNewSessionDraft({
@@ -119,6 +119,62 @@ describe("materializeSessionSnapshots", () => {
expect(result.part.msg_1[0]).toBe(livePart)
})
test("preserves a locally aborted assistant message when a stale unfinished snapshot arrives", () => {
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 staleMessage = message("msg_1")
const state = {
message: { ses_1: [abortedMessage] },
part: { msg_1: [] },
}
const result = materializeSessionSnapshots(
state,
"ses_1",
[{ info: staleMessage, parts: [] }],
)
expect(result.message).toBe(state.message)
expect(result.message.ses_1[0]).toBe(abortedMessage)
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)
})
})
@@ -4,6 +4,7 @@ import {
ATTACHMENT_ACCEPT,
getAttachmentInputModality,
getUnsupportedAttachmentInputs,
isDocumentAttachmentFilename,
prepareAttachmentFile,
} from "./attachment-files"
@@ -47,6 +48,11 @@ describe("attachment file preparation", () => {
}
})
test("identifies Office and OpenDocument filenames for shared mention preparation", () => {
expect(isDocumentAttachmentFilename("reports/BUDGET.XLSX")).toBe(true)
expect(isDocumentAttachmentFilename("notes.txt")).toBe(false)
})
test("renders notebooks as readable markdown without binary outputs", async () => {
const notebook = {
metadata: { kernelspec: { language: "python" } },
+3 -1
View File
@@ -217,6 +217,8 @@ const extensionOf = (name: string): string => {
return index === -1 ? "" : name.slice(index + 1).toLowerCase()
}
export const isDocumentAttachmentFilename = (name: string): boolean => DOCUMENT_EXTENSIONS.has(extensionOf(name))
const declaredMimeOf = (file: File): string => file.type.split(";", 1)[0]?.trim().toLowerCase() ?? ""
const inspectTextContent = async (file: File): Promise<"text/plain" | undefined> => {
@@ -392,7 +394,7 @@ export const prepareAttachmentFiles = (
file: File,
reservedFilenames: Iterable<string> = [],
): PreparedAttachmentFile[] | Promise<PreparedAttachmentFile[] | undefined> | undefined => {
if (!DOCUMENT_EXTENSIONS.has(extensionOf(file.name))) {
if (!isDocumentAttachmentFilename(file.name)) {
const prepared = prepareAttachmentFile(file)
if (prepared instanceof Promise) return prepared.then((output) => output ? [output] : undefined)
return prepared ? [prepared] : undefined
@@ -82,7 +82,10 @@ describe("document attachment extraction", () => {
"xl/_rels/workbook.xml.rels": relationships([{ id: "rIdSheet", target: "worksheets/sheet1.xml" }]),
"xl/sharedStrings.xml": `<sst><si><t>Revenue</t></si></sst>`,
"xl/worksheets/sheet1.xml": `
<worksheet xmlns:r="r"><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row></sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
<worksheet xmlns:r="r"><sheetData>
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1"><v>42</v></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>North</t></is></c><c r="B2"><v>17</v></c></row>
</sheetData><drawing r:id="rIdDrawing"/></worksheet>`,
"xl/worksheets/_rels/sheet1.xml.rels": relationships([{ id: "rIdDrawing", target: "../drawings/drawing1.xml" }]),
"xl/drawings/drawing1.xml": `
<xdr:wsDr xmlns:xdr="xdr" xmlns:a="a" xmlns:r="r"><xdr:oneCellAnchor><xdr:from><xdr:col>1</xdr:col><xdr:row>2</xdr:row></xdr:from><a:blip r:embed="rIdImage"/></xdr:oneCellAnchor></xdr:wsDr>`,
@@ -94,11 +97,27 @@ describe("document attachment extraction", () => {
const text = await result?.textFile.text() ?? ""
expect(text.includes("## Sheet: Summary")).toBe(true)
expect(text.includes("A1: Revenue | B1: 42")).toBe(true)
expect(text.includes("Range: A1:B2\nRevenue\t42\nNorth\t17")).toBe(true)
expect(text.includes("Image at B3: [budget-image-1.webp]")).toBe(true)
expect(result?.images[0]?.name).toBe("budget-image-1.webp")
})
test("quotes TSV values and keeps sparse XLSX rows coordinate-based", async () => {
const file = zippedFile("sparse.xlsx", {
"xl/workbook.xml": `<workbook xmlns:r="r"><sheets><sheet name="Data" r:id="sheet"/></sheets></workbook>`,
"xl/_rels/workbook.xml.rels": relationships([{ id: "sheet", target: "worksheets/sheet1.xml" }]),
"xl/worksheets/sheet1.xml": `<worksheet><sheetData>
<row r="1"><c r="A1" t="inlineStr"><is><t>line 1&#10;line 2</t></is></c><c r="B1" t="inlineStr"><is><t>say &quot;hi&quot;</t></is></c></row>
<row r="2"><c r="A2" t="inlineStr"><is><t>first</t></is></c><c r="XFD2" t="inlineStr"><is><t>last</t></is></c></row>
</sheetData></worksheet>`,
})
const text = await (await extractDocumentAttachments(file))?.textFile.text() ?? ""
expect(text.includes('Range: A1:B1\n"line 1\nline 2"\t"say ""hi"""')).toBe(true)
expect(text.includes("Cells: A2\tfirst | XFD2\tlast")).toBe(true)
})
test("extracts OpenDocument text, presentations, spreadsheets, and image positions", async () => {
const image = pngBytes()
const odt = zippedFile("notes.odt", {
@@ -196,7 +215,7 @@ describe("document attachment extraction", () => {
test("does not retain images whose citations fall beyond the text limit", async () => {
const file = zippedFile("long.docx", {
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(2_000_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
"word/document.xml": `<w:document xmlns:w="w" xmlns:a="a" xmlns:r="r"><w:body><w:p><w:t>${"x".repeat(500_100)}</w:t></w:p><w:p><a:blip r:embed="image"/></w:p></w:body></w:document>`,
"word/_rels/document.xml.rels": relationships([{ id: "image", target: "media/image.png" }]),
"word/media/image.png": pngBytes(),
})
@@ -204,7 +223,7 @@ describe("document attachment extraction", () => {
const result = await extractDocumentAttachments(file)
const text = await result?.textFile.text() ?? ""
expect(text.length <= 2_000_000).toBe(true)
expect(text.length <= 500_000).toBe(true)
expect(text.endsWith("[Document text truncated by OpenChamber]\n")).toBe(true)
expect(text.includes("[long-image-1.png]")).toBe(false)
expect(result?.images).toEqual([])
+102 -10
View File
@@ -8,7 +8,7 @@ const MAX_ARCHIVE_ENTRIES = 5_000
const MAX_EMBEDDED_IMAGES = 50
const MAX_EMBEDDED_IMAGE_BYTES = 20 * 1024 * 1024
const MAX_EMBEDDED_IMAGES_BYTES = 40 * 1024 * 1024
const MAX_EXTRACTED_TEXT_CHARS = 2_000_000
const MAX_EXTRACTED_TEXT_CHARS = 500_000
const MAX_ODF_SPACES_PER_ELEMENT = 100
const TEXT_TRUNCATION_NOTICE = "\n\n[Document text truncated by OpenChamber]\n"
@@ -310,6 +310,17 @@ const columnName = (index: number): string => {
return result
}
const columnIndex = (name: string): number => {
let result = 0
for (const character of name.toUpperCase()) result = result * 26 + character.charCodeAt(0) - 64
return result - 1
}
const tsvValue = (value: string): string => {
if (!/[\t\r\n"]/.test(value)) return value
return `"${value.replace(/"/g, '""')}"`
}
const cellValue = (cell: string, sharedStrings: string[]): string => {
const type = attribute(cell.match(/^<c\b[^>]*>/i)?.[0] ?? "", "t")
if (type === "inlineStr") {
@@ -321,6 +332,95 @@ const cellValue = (cell: string, sharedStrings: string[]): string => {
return decodeXml(value)
}
type SpreadsheetCell = {
reference: string
column: number
row: number
value: string
}
type SpreadsheetRow = {
cells: SpreadsheetCell[]
firstColumn: number
lastColumn: number
row: number
}
const isDenseSpreadsheetRow = (row: SpreadsheetRow): boolean => {
const width = row.lastColumn - row.firstColumn + 1
return width <= Math.max(32, row.cells.length * 4)
}
const serializeDenseSpreadsheetRow = (row: SpreadsheetRow): string => {
const valuesByColumn = new Map(row.cells.map((cell) => [cell.column, cell.value]))
return Array.from(
{ length: row.lastColumn - row.firstColumn + 1 },
(_, offset) => tsvValue(valuesByColumn.get(row.firstColumn + offset) ?? ""),
).join("\t")
}
const spreadsheetRows = (worksheet: string, sharedStrings: string[]): SpreadsheetRow[] => {
const rows: SpreadsheetRow[] = []
for (const rowXml of tagBlocks(worksheet, "row")) {
const cells: SpreadsheetCell[] = []
for (const match of rowXml.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi)) {
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
const reference = attribute(tag, "r")
const coordinates = reference?.match(/^([a-z]+)([1-9]\d*)$/i)
const value = cellValue(match[0], sharedStrings)
if (!reference || !coordinates || !value) continue
cells.push({
reference,
column: columnIndex(coordinates[1]),
row: Number(coordinates[2]),
value,
})
}
cells.sort((left, right) => left.column - right.column)
const first = cells[0]
const last = cells.at(-1)
if (!first || !last) continue
rows.push({ cells, firstColumn: first.column, lastColumn: last.column, row: first.row })
}
return rows
}
const serializeSpreadsheetRows = (rows: SpreadsheetRow[]): string[] => {
const sections: string[] = []
let denseBlock: SpreadsheetRow[] = []
const flushDenseBlock = () => {
const first = denseBlock[0]
const last = denseBlock.at(-1)
if (!first || !last) return
sections.push([
`Range: ${columnName(first.firstColumn)}${first.row}:${columnName(first.lastColumn)}${last.row}`,
...denseBlock.map(serializeDenseSpreadsheetRow),
].join("\n"))
denseBlock = []
}
for (const row of rows) {
const previous = denseBlock.at(-1)
if (!isDenseSpreadsheetRow(row)) {
flushDenseBlock()
sections.push(`Cells: ${row.cells.map((cell) => `${cell.reference}\t${tsvValue(cell.value)}`).join(" | ")}`)
continue
}
if (
previous
&& (row.row !== previous.row + 1
|| row.firstColumn !== previous.firstColumn
|| row.lastColumn !== previous.lastColumn)
) {
flushDenseBlock()
}
denseBlock.push(row)
}
flushDenseBlock()
return sections
}
const drawingCitations = (
archive: Unzipped,
worksheetPath: string,
@@ -362,15 +462,7 @@ const extractXlsx = (archive: Unzipped, images: EmbeddedImages): string | undefi
if (!worksheetPath) continue
sections.push(`## Sheet: ${name}`)
const rows: string[] = []
for (const row of tagBlocks(xml(archive, worksheetPath), "row")) {
const cells = Array.from(row.matchAll(/<c\b[^>]*>[\s\S]*?<\/c>/gi), (match) => {
const tag = match[0].match(/^<c\b[^>]*>/i)?.[0] ?? ""
const reference = attribute(tag, "r") ?? "?"
return `${reference}: ${cellValue(match[0], sharedStrings)}`
}).filter((value) => !value.endsWith(": "))
if (cells.length > 0) rows.push(cells.join(" | "))
}
const rows = serializeSpreadsheetRows(spreadsheetRows(xml(archive, worksheetPath), sharedStrings))
sections.push(...(rows.length > 0 ? rows : ["[Empty sheet]"]), ...drawingCitations(archive, worksheetPath, images))
}
return `${sections.join("\n\n")}\n`
+5 -2
View File
@@ -441,9 +441,12 @@ export function applyDirectoryEvent(
? next.findIndex((p) => p.type === part.type && !(p as { sessionID?: string }).sessionID)
: -1
if (optimisticIndex >= 0) {
next.splice(optimisticIndex, 1)
// Replace in place: pushing to the end reorders text/file parts of a
// just-sent message and remounts its rendered subtree.
next[optimisticIndex] = part
} else {
next.push(part)
}
next.push(part)
}
draft.part[messageID] = next
return missingOwningMessage
@@ -2,17 +2,22 @@ import { beforeEach, describe, expect, test } from "bun:test"
import type { Event } from "@opencode-ai/sdk/v2/client"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
applyGlobalSessionStatusSnapshot,
useGlobalSessionStatusStore,
} from "./global-session-status"
import { resetSessionOrdering, useSessionOrderingStore } from "./session-ordering"
import { resetSessionActivityTiming, useSessionActivityTimingStore } from "./session-activity-timing"
beforeEach(() => {
useGlobalSessionStatusStore.setState({ statusById: 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 +34,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 +125,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)
useGlobalSessionStatusStore.setState({ statusById: 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 +174,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)
})
})
+160 -54
View File
@@ -2,15 +2,16 @@ import { create } from 'zustand';
import type { Event, SessionStatus } from '@opencode-ai/sdk/v2/client';
import { normalizeProjectPath } from '@/lib/projectResolution';
import {
observeSessionActivityEvent,
applySessionOrderingMutations,
reconcileSessionActivitySnapshot,
removeSessionOrdering,
type SessionOrderingMutation,
} from './session-ordering';
import {
observeSessionActivityTiming,
applySessionActivityTimingMutations,
reconcileSessionActivityTiming,
removeSessionActivityTiming,
type SessionActivityTimingMutation,
} from './session-activity-timing';
import { countSyncPerformance } from './performance-diagnostics';
// Shared live busy/retry index for every directory. Global events update it
// incrementally and authoritative directory snapshots reconcile it, so each
@@ -26,13 +27,76 @@ 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'));
// Runtime switching currently replaces statusById directly. Keep that boundary
// synchronized without making normal status mutations derive membership again.
const storeSetState = useGlobalSessionStatusStore.setState;
type GlobalSessionStatusStateUpdate = GlobalSessionStatusState
| Partial<GlobalSessionStatusState>
| ((state: GlobalSessionStatusState) => GlobalSessionStatusState | Partial<GlobalSessionStatusState>);
function setSynchronizedState(
partial: GlobalSessionStatusStateUpdate,
replace?: false,
): void;
function setSynchronizedState(
partial: GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState),
replace: true,
): void;
function setSynchronizedState(partial: GlobalSessionStatusStateUpdate, replace?: boolean): void {
if (partial instanceof Function) {
if (replace === true) {
// SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater.
storeSetState(partial as GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState), true);
} else {
storeSetState(partial, replace);
}
return;
}
if (partial.statusById === undefined || partial.activeSessionIds) {
if (replace === true) {
// SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater.
storeSetState(partial as GlobalSessionStatusState, true);
} else {
storeSetState(partial, replace);
}
return;
}
const nextStatusById = partial.statusById;
const current = useGlobalSessionStatusStore.getState();
const nextActiveSessionIds = new Set<string>();
for (const [sessionId, entry] of nextStatusById) {
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));
const nextState = {
...current,
...partial,
activeSessionIds: sameMembership ? current.activeSessionIds : nextActiveSessionIds,
};
if (replace === true) storeSetState(nextState, true);
else storeSetState(nextState, replace);
}
useGlobalSessionStatusStore.setState = setSynchronizedState;
const normalizeStatusType = (type: string | undefined): ActiveStatusType | 'idle' => {
if (type === 'busy') return 'busy';
if (type === 'retry') return 'retry';
return 'idle';
@@ -48,63 +112,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 +222,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 +251,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;
});
};
+39 -28
View File
@@ -4,6 +4,7 @@
*/
import { create } from "zustand"
import type { ContextPartMetadata } from '@/lib/messages/contextParts'
import type { AttachedFile } from "@/stores/types/sessionTypes"
import { prepareAttachmentFiles } from "./attachment-files"
@@ -54,6 +55,35 @@ const readFileAsDataUrl = (file: File, mime: string): Promise<string> => new Pro
reader.readAsDataURL(file)
})
export const prepareLocalAttachments = async (
file: File,
reservedFilenames: Iterable<string> = [],
): Promise<AttachedFile[] | undefined> => {
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!preparedFiles || preparedFiles.length === 0) return
const sourceDocumentId = preparedFiles.length > 1
? `${Date.now()}-${Math.random().toString(36).slice(2)}`
: undefined
const attachedFiles: AttachedFile[] = []
for (const prepared of preparedFiles) {
const dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
if (!dataUrl) return
attachedFiles.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file: prepared.file,
dataUrl,
mimeType: prepared.mimeType,
filename: prepared.file.name,
size: prepared.file.size,
source: "local",
sourceDocumentId,
})
}
return attachedFiles
}
const getDataUrlByteSize = (url: string): number => {
if (!url.startsWith("data:")) return 0
const commaIndex = url.indexOf(",")
@@ -86,6 +116,7 @@ export type SyntheticContextPart = {
text: string
attachments?: AttachedFile[]
synthetic?: boolean
metadata?: ContextPartMetadata
}
export type VSCodeActiveEditorFile = {
@@ -167,37 +198,17 @@ export const useInputStore = create<InputState>()((set, get) => ({
const generation = attachmentReadGeneration
for (let attempt = 0; attempt < MAX_ATTACHMENT_PREPARATION_ATTEMPTS; attempt += 1) {
const reservedFilenames = get().attachedFiles.map((attachment) => attachment.filename)
const preparedOrPending = prepareAttachmentFiles(file, reservedFilenames)
const preparedFiles = preparedOrPending instanceof Promise ? await preparedOrPending : preparedOrPending
if (!preparedFiles || preparedFiles.length === 0 || generation !== attachmentReadGeneration) return false
const generatedFilenames = preparedFiles.slice(1).map((prepared) => prepared.file.name)
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
const attachedFiles: AttachedFile[] = []
const isDocumentExtraction = preparedFiles.length > 1
const sourceDocumentId = isDocumentExtraction ? `${Date.now()}-${Math.random().toString(36).slice(2)}` : undefined
for (const prepared of preparedFiles) {
let dataUrl: string
try {
dataUrl = await readFileAsDataUrl(prepared.file, prepared.mimeType)
} catch {
return false
}
if (!dataUrl || generation !== attachmentReadGeneration) return false
attachedFiles.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file: prepared.file,
dataUrl,
mimeType: prepared.mimeType,
filename: prepared.file.name,
size: prepared.file.size,
source: "local",
sourceDocumentId,
})
let attachedFiles: AttachedFile[] | undefined
try {
attachedFiles = await prepareLocalAttachments(file, reservedFilenames)
} catch {
return false
}
if (!attachedFiles || generation !== attachmentReadGeneration) return false
const generatedFilenames = attachedFiles.slice(1).map((attachment) => attachment.filename)
if (hasGeneratedFilenameCollision(generatedFilenames, get().attachedFiles)) continue
set((state) => ({ attachedFiles: [...state.attachedFiles, ...attachedFiles] }))
return true
}
+15 -1
View File
@@ -270,7 +270,21 @@ export function materializeSessionSnapshots(
const snapshots = nextMessages.map((message) => recordsByMessageID.get(message.id)!)
const existingMessages = state.message[sessionID]
const currentMessages = existingMessages ?? []
const messages = mergeMessages(currentMessages, nextMessages)
const incomingByID = new Map(nextMessages.map((message) => [message.id, message] as const))
let reconciledCurrentMessages = currentMessages
for (let index = 0; index < currentMessages.length; index += 1) {
const existing = currentMessages[index]
const incoming = incomingByID.get(existing.id)
if (
existing.role !== "assistant"
|| existing.error?.name !== "MessageAbortedError"
|| incoming?.role !== "assistant"
|| incoming.time.completed === undefined
) continue
if (reconciledCurrentMessages === currentMessages) reconciledCurrentMessages = [...currentMessages]
reconciledCurrentMessages[index] = incoming
}
const messages = mergeMessages(reconciledCurrentMessages, nextMessages)
const messagesChanged = messages !== currentMessages || (existingMessages === undefined && snapshots.length === 0)
let partsChanged = false
@@ -16,6 +16,15 @@ export type SyncPerformanceCounters = {
reducerEvents: number
reducerChangedEvents: number
directoryStorePublications: number
globalSessionPublications: number
globalStatusPublications: number
orderingPublications: number
timingPublications: number
liveSessionAggregateRuns: number
sidebarStructureBuilds: number
sidebarOrderBuilds: number
sidebarOrderMetadataEntries: number
recentCandidatesVisited: number
streamingFullReconciliations: number
streamingIncrementalReconciliations: number
streamingStatusEntriesVisited: number
@@ -50,6 +59,15 @@ const createCounters = (): SyncPerformanceCounters => ({
reducerEvents: 0,
reducerChangedEvents: 0,
directoryStorePublications: 0,
globalSessionPublications: 0,
globalStatusPublications: 0,
orderingPublications: 0,
timingPublications: 0,
liveSessionAggregateRuns: 0,
sidebarStructureBuilds: 0,
sidebarOrderBuilds: 0,
sidebarOrderMetadataEntries: 0,
recentCandidatesVisited: 0,
streamingFullReconciliations: 0,
streamingIncrementalReconciliations: 0,
streamingStatusEntriesVisited: 0,
+12 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { switchRuntimeEndpoint } from "@/lib/runtime-switch"
import { persistSessions, readDirCache } from "./persist-cache"
import { persistManagedChatSessions, persistSessions, readDirCache, readManagedChatSessions } from "./persist-cache"
import { getSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from "./performance-diagnostics"
class TestStorage implements Storage {
@@ -81,6 +81,17 @@ afterEach(() => {
})
describe("persisted directory sessions", () => {
test("keeps one runtime-scoped startup snapshot for managed chats", async () => {
const chat = session(1, 2, "Chat", "/home/user/.config/openchamber/chats/2026-08-21/session-a")
persistManagedChatSessions([session(2, 3), chat])
await waitForPersistence()
expect(readManagedChatSessions().map((item) => item.id)).toEqual([chat.id])
switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-other.test", runtimeKey: "runtime-other" })
expect(readManagedChatSessions()).toEqual([])
})
test("keeps the 50 most recently updated sessions across restart reads", async () => {
const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated))
+18
View File
@@ -10,11 +10,14 @@ import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client"
import type { ProjectMeta } from "./types"
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics"
import { isChatDirectoryPath } from "@/lib/chatDirectories"
import { isVSCodeRuntime } from "@/lib/desktop"
/** Cap persisted session lists so localStorage stays bounded per directory. */
const PERSISTED_SESSION_LIMIT = 50
const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const
const SESSION_PERSIST_DEBOUNCE_MS = 50
const MANAGED_CHATS_CACHE_SCOPE = "openchamber:managed-chats"
type PendingSessionWrite = {
runtimeKey: string
@@ -241,6 +244,21 @@ export function persistSessions(directory: string, sessions: Session[] | undefin
scheduleSessionCacheWrite(directory, sessions)
}
export function readManagedChatSessions(expectedRuntimeKey = getRuntimeKey()): Session[] {
if (isVSCodeRuntime()) return []
if (expectedRuntimeKey !== getRuntimeKey()) return []
return readDirCache(MANAGED_CHATS_CACHE_SCOPE).sessions?.filter((session) => (
isChatDirectoryPath(session.directory)
)) ?? []
}
export function persistManagedChatSessions(sessions: Session[]): void {
if (isVSCodeRuntime()) return
persistSessions(MANAGED_CHATS_CACHE_SCOPE, sessions.filter((session) => (
isChatDirectoryPath(session.directory)
)))
}
/** Write vcs info to cache */
export function persistVcs(directory: string, vcs: VcsInfo | undefined): void {
writeCache(directory, "vcs", vcs)
@@ -125,6 +125,7 @@ mock.module("@/lib/opencode/client", () => ({
return mockScopedClient
},
getDirectory: () => "/test/project",
getFilesystemHome: mock(async () => "/home/test"),
getSdkClient: () => mockSdk,
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
+59 -21
View File
@@ -26,6 +26,7 @@ import {
type SessionMetadataRecord,
} from "@/lib/sessionReviewMetadata"
import { withContextObligatoryMessage, type ContextObligatoryMessage } from "@/lib/contextObligatoryMessages"
import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessionLink } from "@/lib/sessionBtwMetadata"
import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues"
import { getImperativeSessionMessageLoader } from "./session-message-loader"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
@@ -35,6 +36,7 @@ import { getStaleRunningToolMessageID } from "./materialization"
import { normalizePath } from "@/lib/pathNormalization"
import { mergeMessages } from "./optimistic"
import { messagesBefore, messagesFrom } from "./message-ordering"
import { deleteChatDirectory } from "@/lib/chatDirectories"
const MESSAGE_REFETCH_LIMIT = 100
const SEND_CONFIRMATION_REFETCH_LIMIT = 30
@@ -726,6 +728,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 +749,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 +795,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 +805,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 +815,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 +832,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 +940,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 +977,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 +988,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 +998,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 +1012,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 +1023,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
+70 -31
View File
@@ -1,6 +1,7 @@
import { useCallback } from 'react';
import { create } from 'zustand';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { countSyncPerformance } from './performance-diagnostics';
// Per-session turn timing behind the sidebar activity readout.
//
@@ -50,6 +51,14 @@ import { getSafeStorage } from '@/stores/utils/safeStorage';
type SessionActivityPhase = 'active' | 'settled';
export type SessionActivityTimingMutation =
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
| { type: 'remove'; sessionId: string };
type ActivityTimingDraft = {
startedAt: Map<string, number> | null;
settledMs: Map<string, number> | null;
};
type SessionActivityTimingState = {
startedAt: ReadonlyMap<string, number>;
@@ -87,13 +96,13 @@ const RESTORE_ADOPTION_WINDOW_MS = 90_000;
// page did not write to looks exactly like "no turn was running".
const STORAGE_KEY = 'oc.session-activity.v1';
const EMPTY_ACTIVE: ReadonlySet<string> = new Set();
const EMPTY_RESTORED: ReadonlyMap<string, PersistedStart> = new Map();
export const useSessionActivityTimingStore = create<SessionActivityTimingState>(() => ({
startedAt: new Map(),
settledMs: new Map(),
}));
useSessionActivityTimingStore.subscribe(() => countSyncPerformance('timingPublications'));
/** Last moment each live start was observed active, for the liveness stamp. */
const liveSeen = new Map<string, number>();
@@ -355,11 +364,66 @@ export const observeSessionActivityTiming = (
sessionId: string,
phase: SessionActivityPhase,
): void => {
if (phase === 'active') {
applyTransitions(new Set([sessionId]), null);
return;
applySessionActivityTimingMutations([{ type: 'observe', sessionId, phase }]);
};
export const applySessionActivityTimingMutations = (
mutations: readonly SessionActivityTimingMutation[],
): void => {
if (mutations.length === 0) return;
const now = Date.now();
const restored = getAdoptableStarts(now);
const state = useSessionActivityTimingStore.getState();
const next: ActivityTimingDraft = { startedAt: null, settledMs: null };
let restoredChanged = false;
let sawActive = false;
const currentStarted = (): ReadonlyMap<string, number> => next.startedAt ?? state.startedAt;
const currentSettled = (): ReadonlyMap<string, number> => next.settledMs ?? state.settledMs;
const draftStarted = (): Map<string, number> => (next.startedAt ??= new Map(state.startedAt));
const draftSettled = (): Map<string, number> => (next.settledMs ??= new Map(state.settledMs));
for (const mutation of mutations) {
if (mutation.type === 'remove') {
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
liveSeen.delete(mutation.sessionId);
if (currentStarted().has(mutation.sessionId)) draftStarted().delete(mutation.sessionId);
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
continue;
}
if (mutation.phase === 'active') {
sawActive = true;
liveSeen.set(mutation.sessionId, now);
if (!currentStarted().has(mutation.sessionId)) {
draftStarted().set(mutation.sessionId, restored.get(mutation.sessionId)?.start ?? now);
}
if (currentSettled().has(mutation.sessionId)) draftSettled().delete(mutation.sessionId);
continue;
}
if (getRestoredStarts().delete(mutation.sessionId)) restoredChanged = true;
const start = currentStarted().get(mutation.sessionId);
if (start === undefined) continue;
draftStarted().delete(mutation.sessionId);
liveSeen.delete(mutation.sessionId);
draftSettled().set(mutation.sessionId, Math.max(0, now - start));
}
if (next.settledMs) trimSettled(next.settledMs);
if (next.startedAt || next.settledMs) {
useSessionActivityTimingStore.setState({
startedAt: next.startedAt ?? state.startedAt,
settledMs: next.settledMs ?? state.settledMs,
});
}
if (next.startedAt) {
if (next.startedAt.size > 0) ensureLivenessStampOnHide();
persistStarts(next.startedAt, now);
} else if (restoredChanged) {
persistStarts(state.startedAt, now);
} else if (sawActive && state.startedAt.size > 0 && now - lastPersistAt >= LIVENESS_PERSIST_INTERVAL_MS) {
persistStarts(state.startedAt, now);
}
applyTransitions(EMPTY_ACTIVE, { source: 'event', sessionId });
};
/**
@@ -377,32 +441,7 @@ export const reconcileSessionActivityTiming = (
};
export const removeSessionActivityTiming = (sessionId: string): void => {
const restoredChanged = getRestoredStarts().delete(sessionId);
const state = useSessionActivityTimingStore.getState();
const hadStart = state.startedAt.has(sessionId);
const hadSettled = state.settledMs.has(sessionId);
liveSeen.delete(sessionId);
if (!hadStart && !hadSettled) {
if (restoredChanged) persistStarts(state.startedAt, Date.now());
return;
}
let startedAt = state.startedAt;
if (hadStart) {
const draft = new Map(state.startedAt);
draft.delete(sessionId);
startedAt = draft;
}
let settledMs = state.settledMs;
if (hadSettled) {
const draft = new Map(state.settledMs);
draft.delete(sessionId);
settledMs = draft;
}
useSessionActivityTimingStore.setState({ startedAt, settledMs });
if (hadStart || restoredChanged) persistStarts(startedAt, Date.now());
applySessionActivityTimingMutations([{ type: 'remove', sessionId }]);
};
/**
+81 -62
View File
@@ -1,5 +1,10 @@
import type { Event, Session } from "@opencode-ai/sdk/v2/client"
import { isGlobalSessionRecencyOnlyUpdate, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import {
isGlobalSessionRecencyOnlyUpdate,
mergeSessionDirectoryMetadata,
useGlobalSessionsStore,
type GlobalSessionMutation,
} from "@/stores/useGlobalSessionsStore"
import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch"
import { streamPerfCount, streamPerfMark } from "@/stores/utils/streamDebug"
import { stripSessionDiffSnapshots } from "./sanitize"
@@ -11,23 +16,6 @@ const clearPendingGlobalSessionUpdates = (): void => {
pendingGlobalSessionUpdates.clear()
}
const flushPendingGlobalSessionUpdate = (sessionID: string): void => {
const update = pendingGlobalSessionUpdates.get(sessionID)
pendingGlobalSessionUpdates.delete(sessionID)
if (!update) return
const runtimeKey = getRuntimeKey()
if (update.runtimeKey !== runtimeKey) return
const currentSession = getGlobalSessionSnapshot(update.session.id)
if (
!currentSession
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
) return
streamPerfMark("global_sessions.event_update_flush")
useGlobalSessionsStore.getState().upsertSession(update.session)
streamPerfCount("ui.global_sessions.event_update_publication")
}
const scheduleGlobalSessionUpdate = (session: Session): void => {
pendingGlobalSessionUpdates.set(session.id, { runtimeKey: getRuntimeKey(), session })
streamPerfCount("ui.global_sessions.event_update_deferred")
@@ -58,51 +46,82 @@ const getSessionInfoFromPayload = (event: Event): Session | null => {
return stripSessionDiffSnapshots(session as Session)
}
const getGlobalSessionSnapshot = (sessionId: string): Session | null => {
const global = useGlobalSessionsStore.getState()
return [...global.activeSessions, ...global.archivedSessions].find((session) => session.id === sessionId) ?? null
export const applySessionEventsToGlobalSessions = (payloads: readonly Event[]): void => {
if (payloads.length === 0) return
const runtimeKey = getRuntimeKey()
const store = useGlobalSessionsStore.getState()
const overlay = new Map(store.entityById)
const mutations: GlobalSessionMutation[] = []
let flushedRecency = false
const appendUpsert = (session: Session): void => {
const existing = overlay.get(session.id) ?? null
const merged = mergeSessionDirectoryMetadata(session, existing)
overlay.set(session.id, merged)
mutations.push({ type: "upsert", session: merged })
}
for (const payload of payloads) {
if (payload.type === "session.idle" || payload.type === "session.error") {
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
if (typeof sessionID !== "string") continue
const update = pendingGlobalSessionUpdates.get(sessionID)
pendingGlobalSessionUpdates.delete(sessionID)
if (!update || update.runtimeKey !== runtimeKey) continue
const currentSession = overlay.get(sessionID) ?? null
if (
!currentSession
|| shouldSkipStaleSessionEvent(currentSession, update.session)
|| !isGlobalSessionRecencyOnlyUpdate(currentSession, update.session)
) continue
appendUpsert(update.session)
flushedRecency = true
continue
}
if (payload.type === "session.created") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = overlay.get(session.id) ?? null
if (!shouldSkipStaleSessionEvent(currentSession, session)) appendUpsert(session)
}
continue
}
if (payload.type === "session.updated") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = overlay.get(session.id) ?? null
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
scheduleGlobalSessionUpdate(session)
} else {
pendingGlobalSessionUpdates.delete(session.id)
appendUpsert(session)
streamPerfCount("ui.global_sessions.event_update_immediate")
}
}
}
continue
}
if (payload.type === "session.deleted") {
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID
?? getSessionInfoFromPayload(payload)?.id
if (sessionID) {
pendingGlobalSessionUpdates.delete(sessionID)
overlay.delete(sessionID)
mutations.push({ type: "remove", sessionId: sessionID })
}
}
}
if (mutations.length === 0 || runtimeKey !== getRuntimeKey()) return
if (flushedRecency) streamPerfMark("global_sessions.event_update_flush")
store.applySessionMutations(mutations)
streamPerfCount("ui.global_sessions.event_update_publication")
}
export const applySessionEventToGlobalSessions = (payload: Event): void => {
if (payload.type === "session.idle" || payload.type === "session.error") {
const sessionID = (payload as { properties?: { sessionID?: unknown } }).properties?.sessionID
if (typeof sessionID === "string") flushPendingGlobalSessionUpdate(sessionID)
return
}
if (payload.type === "session.created") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = getGlobalSessionSnapshot(session.id)
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
useGlobalSessionsStore.getState().upsertSession(session)
}
}
return
}
if (payload.type === "session.updated") {
const session = getSessionInfoFromPayload(payload)
if (session) {
const currentSession = getGlobalSessionSnapshot(session.id)
if (!shouldSkipStaleSessionEvent(currentSession, session)) {
if (currentSession && isGlobalSessionRecencyOnlyUpdate(currentSession, session)) {
scheduleGlobalSessionUpdate(session)
} else {
pendingGlobalSessionUpdates.delete(session.id)
useGlobalSessionsStore.getState().upsertSession(session)
streamPerfCount("ui.global_sessions.event_update_immediate")
}
}
}
return
}
if (payload.type === "session.deleted") {
const sessionID = (payload as { properties?: { sessionID?: string } }).properties?.sessionID ?? getSessionInfoFromPayload(payload)?.id
if (sessionID) {
pendingGlobalSessionUpdates.delete(sessionID)
useGlobalSessionsStore.getState().removeSessions([sessionID])
}
}
applySessionEventsToGlobalSessions([payload])
}
@@ -119,6 +119,32 @@ describe('session lifecycle ordering', () => {
]);
});
test('orders roots, siblings, orphan parents, and cyclic parent scopes deterministically', () => {
const rootOlder = session('root-older', 10);
const rootNewer = session('root-newer', 20);
const childOlder = session('child-older', 5, 'root-older');
const childNewer = session('child-newer', 6, 'root-older');
const orphanOlder = session('orphan-older', 10, 'missing-parent');
const orphanNewer = session('orphan-newer', 20, 'missing-parent');
const cycleOlder = session('cycle-older', 10, 'cycle-newer');
const cycleNewer = session('cycle-newer', 20, 'cycle-older');
expect(orderSessionsByLifecycleScopes(
[cycleOlder, rootOlder, childOlder, orphanOlder, cycleNewer, rootNewer, childNewer, orphanNewer],
new Set(),
new Map(),
).map((item) => item.id)).toEqual([
'orphan-newer',
'root-newer',
'orphan-older',
'root-older',
'child-newer',
'child-older',
'cycle-newer',
'cycle-older',
]);
});
test('does not promote a root when only its child has lifecycle activity', () => {
const rootOlder = session('root-older', 10);
const rootNewer = session('root-newer', 20);
+95 -20
View File
@@ -1,9 +1,13 @@
import { create } from 'zustand';
import type { Session } from '@opencode-ai/sdk/v2';
import { isSessionPinned } from '@/stores/useSessionPinnedStore';
import { normalizePath } from '@/lib/pathNormalization';
import { countSyncPerformance } from './performance-diagnostics';
type SessionActivityPhase = 'active' | 'settled';
export type SessionActivityPhase = 'active' | 'settled';
export type SessionOrderingMutation =
| { type: 'observe'; sessionId: string; phase: SessionActivityPhase }
| { type: 'remove'; sessionId: string };
type SessionOrderingState = {
rankById: Map<string, number>;
@@ -18,6 +22,7 @@ let lastRank = 0;
export const useSessionOrderingStore = create<SessionOrderingState>(() => ({
rankById: new Map(),
}));
useSessionOrderingStore.subscribe(() => countSyncPerformance('orderingPublications'));
const nextRank = (): number => {
lastRank = Math.max(lastRank + 1, Date.now());
@@ -42,12 +47,36 @@ export const observeSessionActivityEvent = (
sessionId: string,
phase: SessionActivityPhase,
): void => {
const previous = phaseById.get(sessionId);
phaseById.set(sessionId, phase);
applySessionOrderingMutations([{ type: 'observe', sessionId, phase }]);
};
if (previous === phase) return;
if (previous === undefined && phase === 'settled') return;
promoteSessions([sessionId]);
export const applySessionOrderingMutations = (
mutations: readonly SessionOrderingMutation[],
): void => {
if (mutations.length === 0) return;
const currentRanks = useSessionOrderingStore.getState().rankById;
let rankById: Map<string, number> | null = null;
for (const mutation of mutations) {
if (mutation.type === 'remove') {
phaseById.delete(mutation.sessionId);
baselineRankById.delete(mutation.sessionId);
if ((rankById ?? currentRanks).has(mutation.sessionId)) {
rankById ??= new Map(currentRanks);
rankById.delete(mutation.sessionId);
}
continue;
}
const previous = phaseById.get(mutation.sessionId);
phaseById.set(mutation.sessionId, mutation.phase);
if (previous === mutation.phase) continue;
if (previous === undefined && mutation.phase === 'settled') continue;
rankById ??= new Map(currentRanks);
rankById.set(mutation.sessionId, nextRank());
}
if (rankById) useSessionOrderingStore.setState({ rankById });
};
export const reconcileSessionActivitySnapshot = (
@@ -71,14 +100,7 @@ export const reconcileSessionActivitySnapshot = (
};
export const removeSessionOrdering = (sessionId: string): void => {
phaseById.delete(sessionId);
baselineRankById.delete(sessionId);
useSessionOrderingStore.setState((state) => {
if (!state.rankById.has(sessionId)) return state;
const rankById = new Map(state.rankById);
rankById.delete(sessionId);
return { rankById };
});
applySessionOrderingMutations([{ type: 'remove', sessionId }]);
};
export const resetSessionOrdering = (): void => {
@@ -107,7 +129,7 @@ const sessionDirectory = (session: Session): string | null => {
directory?: string | null;
project?: { worktree?: string | null } | null;
};
return normalizePath(record.directory ?? null) ?? normalizePath(record.project?.worktree ?? null);
return record.directory ?? record.project?.worktree ?? null;
};
const baselineRank = (session: Session, pinned: boolean): number => {
@@ -197,12 +219,39 @@ export const orderSessionsByLifecycleScopes = (
sessions: Session[],
pinnedSessionIds: Set<string>,
rankById: ReadonlyMap<string, number>,
hierarchy?: {
rootIds: readonly string[];
childrenByParentId: ReadonlyMap<string, readonly string[]>;
},
): Session[] => {
countSyncPerformance('sidebarOrderBuilds');
const sessionIds = new Set(sessions.map((session) => session.id));
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const roots: Session[] = [];
const childrenByParent = new Map<string, Session[]>();
const indexedIds = new Set<string>();
if (hierarchy) {
for (const sessionId of hierarchy.rootIds) {
const session = sessionById.get(sessionId);
if (!session) continue;
indexedIds.add(sessionId);
roots.push(session);
}
for (const [parentId, childIds] of hierarchy.childrenByParentId) {
if (!sessionIds.has(parentId)) continue;
const children = childIds.flatMap((sessionId) => {
const session = sessionById.get(sessionId);
if (!session) return [];
indexedIds.add(sessionId);
return [session];
});
if (children.length > 0) childrenByParent.set(parentId, children);
}
}
for (const session of sessions) {
if (indexedIds.has(session.id)) continue;
const parentId = parentIdOf(session);
if (!parentId || !sessionIds.has(parentId)) {
roots.push(session);
@@ -217,9 +266,34 @@ export const orderSessionsByLifecycleScopes = (
}
}
const compare = (left: Session, right: Session) => (
compareSessionsByLifecycleOrder(left, right, pinnedSessionIds, rankById)
);
const metadataById = new Map(sessions.map((session) => {
const parentId = parentIdOf(session);
const pinned = isSessionPinned(pinnedSessionIds, sessionDirectory(session), session.id);
const fallback = baselineRank(session, pinned);
return [session.id, {
parentId,
pinned,
fallback,
lifecycle: rankById.get(session.id) ?? fallback,
created: baselineRank(session, true),
}] as const;
}));
countSyncPerformance('sidebarOrderMetadataEntries', metadataById.size);
const compare = (left: Session, right: Session): number => {
const leftMetadata = metadataById.get(left.id);
const rightMetadata = metadataById.get(right.id);
if (!leftMetadata || !rightMetadata) return left.id.localeCompare(right.id);
if (leftMetadata.pinned !== rightMetadata.pinned) return leftMetadata.pinned ? -1 : 1;
if (leftMetadata.parentId === rightMetadata.parentId) {
const rankDelta = rightMetadata.lifecycle - leftMetadata.lifecycle;
if (rankDelta !== 0) return rankDelta;
}
const baselineDelta = rightMetadata.fallback - leftMetadata.fallback;
if (baselineDelta !== 0) return baselineDelta;
const createdDelta = rightMetadata.created - leftMetadata.created;
if (createdDelta !== 0) return createdDelta;
return left.id.localeCompare(right.id);
};
roots.sort(compare);
for (const siblings of childrenByParent.values()) {
siblings.sort(compare);
@@ -238,7 +312,8 @@ export const orderSessionsByLifecycleScopes = (
for (const root of roots) {
append(root);
}
for (const session of sessions) {
const remaining = sessions.filter((session) => !visited.has(session.id)).sort(compare);
for (const session of remaining) {
append(session);
}
return ordered;
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import type { Session } from "@opencode-ai/sdk/v2"
import { upsertSessionRecord } from "./session-records"
const session = (id: string, overrides: Partial<Session> = {}): Session => ({
id, slug: id, projectID: "project", directory: "/workspace", title: id, version: "1",
time: { created: 1, updated: 1 }, ...overrides,
})
describe("upsertSessionRecord", () => {
test("inserts missing IDs in binary order", () => {
expect(upsertSessionRecord([session("a"), session("c")], session("b")).map((item) => item.id)).toEqual(["a", "b", "c"])
})
test("preserves references for separately allocated equivalent metadata", () => {
const current = [session("a", {
metadata: { nested: ["value", { count: 1 }] },
summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
}), session("b")]
const incoming = session("a", {
metadata: { nested: ["value", { count: 1 }] },
summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] },
})
const result = upsertSessionRecord(current, incoming)
expect(result).toBe(current)
expect(result[0]).toBe(current[0])
expect(result[1]).toBe(current[1])
})
test("replaces a same-ID record when an unlisted semantic field changes", () => {
const current = [session("a")]
// SAFETY: The runtime SDK payload may contain an additive field before the local Session type is updated.
const incoming = {
...session("a"),
// SDK records can gain fields independently of this synchronization boundary.
customField: "changed",
} as Session
expect(upsertSessionRecord(current, incoming)).not.toBe(current)
})
const changes: Array<[string, Partial<Session>, Partial<Session>]> = [
["scalars", { workspaceID: "one", path: "a", parentID: "p", cost: 1, agent: "a" }, { workspaceID: "two", path: "b", parentID: "q", cost: 2, agent: "b" }],
["tokens", { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } } }, { tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 6 } } }],
["share and model", { share: { url: "a" }, model: { id: "m", providerID: "p", variant: "a" } }, { share: { url: "b" }, model: { id: "m", providerID: "p", variant: "b" } }],
["metadata", { metadata: { key: "a" } }, { metadata: { key: "b" } }],
["permission", { permission: [{ permission: "bash", pattern: "*", action: "ask" }] }, { permission: [{ permission: "bash", pattern: "*", action: "allow" }] }],
["revert", { revert: { messageID: "m", partID: "a", snapshot: "s", diff: "d" } }, { revert: { messageID: "m", partID: "b", snapshot: "s", diff: "d" } }],
["summary diffs", { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 1, deletions: 0 }] } }, { summary: { additions: 1, deletions: 2, files: 3, diffs: [{ file: "a", additions: 2, deletions: 0 }] } }],
["time", { time: { created: 1, updated: 1, compacting: 2, archived: 3 } }, { time: { created: 1, updated: 2, compacting: 2, archived: 3 } }],
]
for (const [field, current, incoming] of changes) {
test(`replaces only target when ${field} changes`, () => {
const first = session("a")
const target = session("b", current)
const last = session("c")
const list = [first, target, last]
const result = upsertSessionRecord(list, session("b", incoming))
expect(result).not.toBe(list)
expect(result[0]).toBe(first)
expect(result[1]).not.toBe(target)
expect(result[2]).toBe(last)
})
}
})
+16
View File
@@ -0,0 +1,16 @@
import type { Session } from "@opencode-ai/sdk/v2"
import { Binary } from "./binary"
function areSessionsEqual(left: Session, right: Session): boolean {
return JSON.stringify(left) === JSON.stringify(right)
}
export function upsertSessionRecord(current: Session[], incoming: Session): Session[] {
const result = Binary.search(current, incoming.id, (session) => session.id)
if (!result.found) return [...current.slice(0, result.index), incoming, ...current.slice(result.index)]
// Equivalent authoritative detail must retain sidebar session-list references.
if (areSessionsEqual(current[result.index], incoming)) return current
const next = [...current]
next[result.index] = incoming
return next
}
+65 -9
View File
@@ -10,6 +10,7 @@ import { useCommandsStore } from '@/stores/useCommandsStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
/**
* Unit tests for session worktree routing through the authoritative store.
@@ -199,6 +200,47 @@ describe('session-worktree-store worktree routing', () => {
});
});
describe('draft materialization transition identity', () => {
beforeEach(() => {
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
materializedDraftSessionId: null,
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
});
});
test('marks and consumes only the submitted draft session', () => {
useSessionUIStore.getState().setCurrentSession(
'session-created',
'/projects/alpha',
'submitted-draft',
);
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
useSessionUIStore.getState().clearMaterializedDraftSession('another-session');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBe('session-created');
useSessionUIStore.getState().clearMaterializedDraftSession('session-created');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
});
test('clears the marker when navigating from a draft to an existing session', () => {
useSessionUIStore.getState().setCurrentSession(
'session-created',
'/projects/alpha',
'submitted-draft',
);
useSessionUIStore.setState({
newSessionDraft: { open: true, target: 'project', directoryOverride: '/projects/alpha' },
});
useSessionUIStore.getState().setCurrentSession('session-existing', '/projects/alpha');
expect(useSessionUIStore.getState().materializedDraftSessionId).toBeNull();
});
});
describe('routeMessage directory scoping', () => {
test('runs sends in the provided session directory', async () => {
// The session directory travels as an explicit request param (not via
@@ -370,16 +412,17 @@ describe('openNewSessionDraft project binding', () => {
useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false });
});
test('keeps implicit draft on current directory when active project differs', () => {
test('defaults an implicit draft to Chat when active project differs', () => {
useSessionUIStore.getState().openNewSessionDraft();
const draft = useSessionUIStore.getState().newSessionDraft;
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBe(projectB.id);
expect(draft.directoryOverride).toBe(projectB.path);
expect(draft.target).toBe('chat');
expect(draft.selectedProjectId).toBeNull();
expect(draft.directoryOverride).toBeNull();
});
test('does not attach active project when current directory is unmatched', () => {
test('defaults an implicit draft to Chat when current directory is unmatched', () => {
useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
@@ -387,7 +430,8 @@ describe('openNewSessionDraft project binding', () => {
expect(draft.open).toBe(true);
expect(draft.selectedProjectId).toBeNull();
expect(draft.directoryOverride).toBe('/external/worktree');
expect(draft.target).toBe('chat');
expect(draft.directoryOverride).toBeNull();
});
test('respects explicit directoryOverride over active project', () => {
@@ -464,7 +508,7 @@ describe('createSession draft lifecycle', () => {
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
opencodeClient.getDirectoryAvailability = async () => 'missing';
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
await Bun.sleep(0);
expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main');
@@ -482,7 +526,7 @@ describe('createSession draft lifecycle', () => {
activeProjectId: 'project-active',
});
useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
opencodeClient.getDirectoryAvailability = async () => 'missing';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
@@ -542,7 +586,7 @@ describe('createSession draft lifecycle', () => {
activeProjectId: 'project-main',
});
useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false });
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/unavailable-worktree' });
opencodeClient.getDirectoryAvailability = async () => 'unknown';
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
@@ -571,7 +615,7 @@ describe('createSession draft lifecycle', () => {
return { id: 'session-race', directory };
};
useSessionUIStore.getState().openNewSessionDraft();
useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' });
const createPromise = useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree');
expect(availabilityResolvers.length).toBe(2);
@@ -654,9 +698,19 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
currentSessionDirectory: null,
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
});
useProjectsStore.setState({ projects: [], activeProjectId: null });
useSessionDisplayStore.setState({ singleProjectId: null });
});
test('draft send snapshots the draft; switching to another project mid-flight still targets the materialized session', async () => {
useProjectsStore.setState({
projects: [
{ id: 'project-alpha', path: '/projects/alpha', label: 'Alpha' },
{ id: 'project-beta', path: '/projects/beta', label: 'Beta' },
],
activeProjectId: 'project-alpha',
});
useSessionDisplayStore.setState({ singleProjectId: 'project-alpha' });
const draftSnapshot = {
open: true,
directoryOverride: '/projects/alpha',
@@ -684,6 +738,7 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
// A sidebar switch while the send is still in flight must not reroute it.
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-beta');
await sendPromise;
@@ -692,6 +747,7 @@ describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
expect(sendMessageCalls).toHaveLength(1);
expect(sendMessageCalls[0].id).toBe('session-materialized');
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
expect(useSessionDisplayStore.getState().singleProjectId).toBe('project-alpha');
});
test('existing-session send keeps the submit-time target even when selection changes', async () => {
+268 -63
View File
@@ -12,6 +12,7 @@
* SDK-calling actions that need domain data read it from sync-refs.
*/
import type { ContextPartMetadata } from "@/lib/messages/contextParts"
import { create } from "zustand"
import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client"
import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes"
@@ -20,6 +21,8 @@ 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"
import { useSessionFoldersStore } from "@/stores/useSessionFoldersStore"
@@ -28,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"
@@ -86,6 +91,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch"
import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache"
import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache"
import { rememberRuntimeLiveStatus } from "./runtime-live-memory"
import { contextTokensFromBreakdown } from "@/stores/utils/tokenUtils"
export type { AttachedFile }
@@ -133,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
@@ -256,6 +262,7 @@ function notifyMessageSent(sessionId: string): void {
// ---------------------------------------------------------------------------
export type NewSessionDraftState = {
draftId: number
open: boolean
selectedProjectId?: string | null
directoryOverride: string | null
@@ -268,6 +275,9 @@ export type NewSessionDraftState = {
initialPrompt?: string
syntheticParts?: SyntheticContextPart[]
targetFolderId?: string
projectContextPins?: { notes: string[]; plans: string[] }
target: "chat" | "project"
preparedChatDirectory?: string | null
}
export type ViewportAnchor = {
@@ -287,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
@@ -309,14 +320,21 @@ 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
setDraftPermissionAutoAcceptEnabled: (enabled: boolean) => void
setDraftProjectContextPin: (kind: "note" | "plan", id: string, pinned: boolean) => void
acknowledgeSessionAbort: (sessionId: string) => void
clearAbortPrompt: () => void
armAbortPrompt: (durationMs?: number) => number | null
@@ -340,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>
@@ -544,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 = {
@@ -700,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
@@ -722,10 +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 created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null)
if (!created?.id) throw new Error("Failed to create session")
const draftPins = draft.projectContextPins ?? { notes: [], plans: [] }
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) {
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).
@@ -756,8 +851,6 @@ export async function materializeOpenDraftSession(selection: {
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))
@@ -798,6 +891,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,
@@ -816,7 +910,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()
}
@@ -850,7 +951,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 })
@@ -875,6 +979,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)
@@ -902,6 +1009,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
@@ -977,7 +1089,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
@@ -994,14 +1115,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
@@ -1009,10 +1130,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,
@@ -1024,12 +1152,11 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
initialPrompt: options?.initialPrompt,
syntheticParts: options?.syntheticParts,
targetFolderId: options?.targetFolderId,
projectContextPins: options?.projectContextPins,
}
set({
newSessionDraft: {
...nextDraft,
},
newSessionDraft: nextDraft,
currentSessionId: null,
currentSessionDirectory: null,
error: null,
@@ -1055,6 +1182,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
void activateConfigForDirectory(configDirectory).then(() => {
useConfigStore.getState().applyDefaultModelAgentSelection({
projectDefaultModel: selectedProject?.defaultModel,
projectDefaultVariant: selectedProject?.defaultVariant,
})
})
@@ -1065,11 +1193,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
@@ -1087,18 +1248,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,
})
@@ -1106,14 +1270,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,
},
}
})
@@ -1136,6 +1307,22 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
return { newSessionDraft: { ...s.newSessionDraft, permissionAutoAcceptEnabled: enabled } }
}),
setDraftProjectContextPin: (kind, id, pinned) =>
set((s) => {
if (!s.newSessionDraft?.open) return s
const pins = s.newSessionDraft.projectContextPins ?? { notes: [], plans: [] }
const key = kind === "note" ? "notes" : "plans"
const next = new Set(pins[key])
if (pinned) next.add(id)
else next.delete(id)
return {
newSessionDraft: {
...s.newSessionDraft,
projectContextPins: { ...pins, [key]: [...next] },
},
}
}),
acknowledgeSessionAbort: (sessionId) =>
set((s) => {
const flags = new Map(s.sessionAbortFlags)
@@ -1173,7 +1360,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
const messages = getSyncMessages(sessionId)
if (messages.length === 0) return null
type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } }
type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } }
let lastTokens: AssistantTokens | undefined
let lastMessageId: string | undefined
for (let i = messages.length - 1; i >= 0; i--) {
@@ -1181,7 +1368,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (msg.role !== "assistant") continue
const tokens = (msg as { tokens?: AssistantTokens }).tokens
if (!tokens) continue
const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0)
const total = contextTokensFromBreakdown(tokens)
if (total > 0) {
lastTokens = tokens
lastMessageId = msg.id
@@ -1191,7 +1378,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (!lastTokens) return null
const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0)
const totalTokens = contextTokensFromBreakdown(lastTokens)
const thresholdLimit = contextLimit > 0 ? contextLimit : 200000
const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0
const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined
@@ -1304,7 +1491,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,
@@ -1383,9 +1570,22 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
}, options?.draftSnapshot)
if (!createdDraftSession) throw new Error("Failed to create session")
const mergedAdditionalParts = createdDraftSession.syntheticParts?.length
const draftParts = createdDraftSession.syntheticParts?.length
? [...(additionalParts || []), ...createdDraftSession.syntheticParts]
: additionalParts
// The server decides what this session still owes and assembles it; the
// client only carries it and reports it delivered.
const draftKnowledge = await fetchSessionKnowledge(
createdDraftSession.directory,
createdDraftSession.sessionId,
)
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.
const mergedAdditionalParts = draftPrefixParts.length > 0
? [...draftPrefixParts, ...(draftParts || [])]
: draftParts
notifyMessageSent(createdDraftSession.sessionId)
@@ -1414,6 +1614,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,
@@ -1422,6 +1623,15 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
})),
})),
})
// Recorded only after the send resolves: a failed send must carry the
// pinned context again rather than assume the agent already saw it.
if (draftKnowledge.text) {
void reportSessionKnowledgeDelivered(
createdDraftSession.directory,
createdDraftSession.sessionId,
draftKnowledge.signature,
)
}
return
}
@@ -1481,6 +1691,17 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
if (targetSessionId) {
await applyArmedGoal(targetSessionId, currentSessionDirectory)
}
// Standing project context — pinned notes and plans, and the memory index.
// 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; metadata?: ContextPartMetadata }> =
knowledge.text ? [{ text: knowledge.text, synthetic: true }] : []
const partsWithPinnedContext = prefixParts.length > 0
? [...prefixParts, ...(additionalParts || [])]
: additionalParts
await routeMessage({
runtimeKey: capturedTarget?.runtimeKey,
sessionId: targetSessionId || "",
@@ -1494,9 +1715,10 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
inputMode,
files,
delivery: options?.delivery,
additionalParts: additionalParts?.map((p) => ({
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,
@@ -1505,44 +1727,27 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
})),
})),
})
if (knowledge.text) {
void reportSessionKnowledgeDelivered(currentSessionDirectory, targetSessionId || "", knowledge.signature)
}
},
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
deleteSession: (id, options) => deleteSessionAction(id, options),
deleteSession: async (id, options) => deleteSessionAction(id, options),
deleteSessions: (ids, options) => deleteSessionsAction(ids, options),
deleteSessions: async (ids, options) => {
const result = await deleteSessionsAction(ids, options)
return result
},
archiveSession: (id) => archiveSessionAction(id),
@@ -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()
}
})
})
+284 -97
View File
@@ -37,7 +37,8 @@ import { setActionRefs } from "./session-actions"
import { setSyncRefs, getAllSyncSessions } from "./sync-refs"
import { useSessionUIStore } from "./session-ui-store"
import { stripSessionDiffSnapshots } from "./sanitize"
import { applySessionEventToGlobalSessions } from "./session-event-router"
import { upsertSessionRecord } from "./session-records"
import { applySessionEventToGlobalSessions, applySessionEventsToGlobalSessions } from "./session-event-router"
import { syncDebug } from "./debug"
import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery"
import { messagesBefore } from "./message-ordering"
@@ -52,7 +53,12 @@ import { useTodosPersistStore } from "@/stores/useTodosPersistStore"
import { cleanupPersistedSessionState } from "./session-deletion-cleanup"
import { toast } from "@/components/ui"
import { appendNotification } from "./notification-store"
import { applyGlobalSessionStatusEvent, applyGlobalSessionStatusSnapshot, useGlobalSessionStatusStore } from "./global-session-status"
import {
applyGlobalSessionStatusEvent,
applyGlobalSessionStatusEvents,
applyGlobalSessionStatusSnapshot,
useGlobalSessionStatusStore,
} from "./global-session-status"
import type { State } from "./types"
import type { SessionStatus } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest } from "@/types/permission"
@@ -70,6 +76,7 @@ import { getRuntimeLiveStatusSeed, LIVE_STATUS_TTL_MS } from "./runtime-live-mem
import { getRuntimeKey } from "@/lib/runtime-switch"
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"
import { isFilesystemError } from "@/lib/api/files-errors"
import { formatMessage, useI18nStore } from "@/lib/i18n"
import { listGlobalSessionPages } from "@/stores/globalSessions"
import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests"
import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history"
@@ -85,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
@@ -136,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())
}
@@ -146,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,
)
@@ -180,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 = (
@@ -194,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
@@ -226,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(
@@ -456,6 +504,32 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
}
const notification = properties as UiNotificationPayload
const kind = asOptionalString(notification.kind)
const sessionId = asOptionalString(notification.sessionId)
const directory = asOptionalString(notification.directory)
?? (fallbackDirectory !== "global" ? fallbackDirectory : "")
if (kind === "opencode-restart-interrupted") {
const dictionary = useI18nStore.getState().dictionary
const title = formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.title")
const options = {
id: "opencode-restart-interrupted",
description: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.description"),
duration: Infinity,
}
if (sessionId && directory) {
toast.info(title, {
...options,
action: {
label: formatMessage(dictionary, "chat.toast.opencodeRestartInterrupted.openSession"),
onClick: () => openSessionFromToast(sessionId, directory),
},
})
} else {
toast.info(title, options)
}
}
if ((notification.desktopNotificationDelivered === true || notification.desktopStdoutActive === true) && getRuntimeKey() === "local") {
return true
}
@@ -469,9 +543,9 @@ const handleUiNotificationEvent = (payload: Event, fallbackDirectory: string): b
title: asOptionalString(notification.title),
body: asOptionalString(notification.body),
tag: asOptionalString(notification.tag),
kind: asOptionalString(notification.kind),
sessionId: asOptionalString(notification.sessionId),
directory: asOptionalString(notification.directory) ?? (fallbackDirectory && fallbackDirectory !== "global" ? fallbackDirectory : undefined),
kind,
sessionId,
directory: directory || undefined,
requireHidden: notification.requireHidden === true,
}).catch((error) => {
console.warn("[notifications] failed to dispatch UI notification", error)
@@ -632,15 +706,24 @@ async function resyncDirectorySessionStatuses(
if (mode === "authoritative") {
applyGlobalSessionStatusSnapshot(directory, nextStatuses, candidateSessionIds)
// An authoritative snapshot that settles sessions previously observed
// busy/retry can orphan running tool parts (managed process died
// mid-turn, #2577): finalize them now. The snapshot write above already
// lowered their status to explicit idle, which is the gate the helper
// requires — a session the snapshot reports busy stays untouched.
// busy/retry can leave their trailing assistant message and tool parts
// unfinished (managed process died mid-turn, #2577): finalize them now.
// The snapshot write above already lowered their status to explicit idle,
// which is the gate the helper requires — a session the snapshot reports
// busy stays untouched.
for (const sessionId of candidateSessionIds) {
const interrupted = interruptedTurnToolParts(store.getState(), sessionId)
if (interrupted) {
if (!interrupted.parts) {
store.setState((state) => ({
message: { ...state.message, [sessionId]: interrupted.messages },
}))
continue
}
const interruptedParts = interrupted.parts
store.setState((state) => ({
part: { ...state.part, [interrupted.messageID]: interrupted.parts },
message: { ...state.message, [sessionId]: interrupted.messages },
part: { ...state.part, [interrupted.messageID]: interruptedParts },
}))
}
}
@@ -1372,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,
@@ -1419,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
@@ -1447,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) {
@@ -1535,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,
@@ -1804,18 +1890,32 @@ export function handleEvent(
messageID,
})
}
// The reducer already wrote the idle/error status into `draft`; mark the
// orphaned tools using the batched state and publish through the batch.
// The reducer already wrote the idle/error status into `draft`; finalize
// the interrupted message and orphaned tools through the same batch.
if (sessionID) {
const interrupted = interruptedTurnToolParts(state, sessionID)
if (interrupted) {
cloneField("part", (value) => ({ ...(value ?? {}) }))
;(draft as DirectoryStore).part[interrupted.messageID] = interrupted.parts
cloneField("message", (value) => ({ ...value }))
draft.message[sessionID] = interrupted.messages
if (interrupted.parts) {
cloneField("part", (value) => ({ ...(value ?? {}) }))
draft.part[interrupted.messageID] = interrupted.parts
}
if (batch) {
batch.states.set(store, draft as DirectoryStore)
batch.changedStores.add(store)
} else {
store.setState({ part: { ...(store.getState().part), [interrupted.messageID]: interrupted.parts } })
const currentState = store.getState()
if (interrupted.parts) {
store.setState({
message: { ...currentState.message, [sessionID]: interrupted.messages },
part: { ...currentState.part, [interrupted.messageID]: interrupted.parts },
})
} else {
store.setState({
message: { ...currentState.message, [sessionID]: interrupted.messages },
})
}
}
}
}
@@ -1829,29 +1929,31 @@ export function handleEvent(
//
// A managed OpenCode process can die mid-turn (crash, health-check restart).
// The persisted turn then never settles: the trailing assistant message has
// no `time.completed` and its tool parts stay `pending`/`running` forever —
// the server never finalizes them (anomalyco/opencode#19023). The
// no `time.completed`, and any tool parts can stay `pending`/`running`
// forever — the server never finalizes them (anomalyco/opencode#19023). The
// settle-triggered tail refresh above refetches the same stale records, so
// the UI would keep running tool timers and "working" styling indefinitely
// (#2577).
// the UI would keep the assistant message unfinished and any tool timers and
// "working" styling active indefinitely (#2577).
//
// OpenCode keeps a turn's session busy while it is genuinely alive —
// including while waiting for a question/permission reply — so once a
// session is AUTHORITATIVELY settled (a `session.idle`/`session.error`
// event, or an authoritative status snapshot that lowers a previously busy
// session) and the trailing assistant message is still unfinished with
// active tool parts and no pending question/permission, the turn is
// definitively interrupted. Finalize the orphaned parts locally as
// `error`/`Interrupted` with an end time — the same shape OpenCode itself
// writes for cancelled tools. A later terminal part event or a refresh that
// carries the true terminal state supersedes the mark; a stale refresh that
// still reports `running` is rejected by the reducer's and the materializer's
// final-status preservation.
// no pending question/permission, the turn is definitively interrupted.
// Complete the assistant message locally with MessageAbortedError and finalize
// any orphaned parts as `error`/`Interrupted` with an end time — the same shape
// OpenCode itself writes for cancelled tools. A later terminal event can
// supersede the mark; a stale refresh cannot regress the locally final state.
type AssistantMessage = Extract<Message, { role: "assistant" }>
type SdkMessageAbortedError = Extract<NonNullable<AssistantMessage["error"]>, { name: "MessageAbortedError" }>
type LocalMessageAbortedError = SdkMessageAbortedError & { message: string }
export function interruptedTurnToolParts(
state: DirectoryStore,
sessionID: string,
now = Date.now(),
): { messageID: string; parts: Part[] } | null {
): { messageID: string; messages: Message[]; parts?: Part[] } | null {
if ((state.question?.[sessionID] ?? []).length > 0) return null
if ((state.permission?.[sessionID] ?? []).length > 0) return null
@@ -1863,37 +1965,62 @@ export function interruptedTurnToolParts(
return null
}
const messageID = getStaleRunningToolMessageID(state, sessionID)
if (!messageID) return null
const message = (state.message[sessionID] ?? []).find((candidate) => candidate.id === messageID)
if (!message) return null
if (typeof (message as { time?: { completed?: unknown } }).time?.completed === "number") {
const messages = state.message[sessionID] ?? []
let messageIndex = -1
for (let index = messages.length - 1; index >= 0; index -= 1) {
const candidate = messages[index]
if (candidate.role === "user") return null
if (candidate.role !== "assistant") continue
messageIndex = index
break
}
if (messageIndex < 0) return null
const message = messages[messageIndex]
if (message.role !== "assistant") return null
if (message.time.completed !== undefined) {
// The turn finished; a missed terminal tool event is the tail refresh's
// job, not an interruption.
return null
}
const current = state.part[messageID]
if (!current) return null
const messageID = message.id
const nextMessages = [...messages]
const error = {
name: "MessageAbortedError",
data: { message: "aborted" },
message: "aborted",
} satisfies LocalMessageAbortedError
nextMessages[messageIndex] = {
...message,
time: { ...message.time, completed: now },
error,
}
let changed = false
const nextParts = current.map((part) => {
let partsChanged = false
const currentParts = state.part[messageID]
const nextParts = currentParts?.map((part) => {
if (part.type !== "tool") return part
const partState = (part as { state?: { status?: unknown; time?: { start?: number } } }).state
if (!partState) return part
if (partState.status !== "pending" && partState.status !== "running") return part
changed = true
if (part.state.status !== "pending" && part.state.status !== "running") return part
partsChanged = true
const partTime = "time" in part.state ? part.state.time : undefined
const start = typeof partTime?.start === "number" ? partTime.start : now
return {
...part,
state: {
...partState,
status: "error",
...part.state,
status: "error" as const,
error: "Interrupted",
time: { ...(partState.time ?? {}), end: now },
time: { start, end: now },
},
} as Part
}
})
return changed ? { messageID, parts: nextParts } : null
return {
messageID,
messages: nextMessages,
parts: partsChanged ? nextParts : undefined,
}
}
// ---------------------------------------------------------------------------
@@ -1947,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) => {
@@ -2470,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>
)
}
// ---------------------------------------------------------------------------
@@ -2570,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)
@@ -2713,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]),
@@ -2732,7 +2918,8 @@ export function useSession(sessionID?: string | null, directory?: string) {
const { childStores } = useSyncSystem()
const getSnapshot = useCallback(() => {
if (directory) {
return childStores.getChild(directory)?.getState().session.find((session) => session.id === sessionID)
const sessions = childStores.getChild(directory)?.getState().session
return sessions ? getSessionById(sessions, sessionID) : undefined
}
return findLiveSession(getLiveStates(childStores), sessionID)
}, [childStores, directory, sessionID])
+107 -116
View File
@@ -1,9 +1,10 @@
import { useCallback, useMemo } from "react"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "./binary"
import { upsertSessionRecord } from "./session-records"
import { retry } from "./retry"
import { SESSION_CACHE_LIMIT, type State } from "./types"
import { pickSessionCacheEvictions } from "./session-cache"
import { dropSessionCaches, getProtectedSessionCacheIds, pickSessionCacheEvictions } from "./session-cache"
import {
dropCachedSessionMessageRecordsSnapshots,
useChildStoreManager,
@@ -11,9 +12,10 @@ import {
useSessionMessageLoader,
useSyncDirectory,
useSyncSDK,
useSyncRuntime,
resyncBlockingRequestsForDirectory,
buildSessionMessageRecordsSnapshot,
} from "./sync-context"
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
import { stripSessionDiffSnapshots } from "./sanitize"
import { isVSCodeRuntime } from "@/lib/desktop"
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
@@ -46,7 +48,6 @@ const syncSessionInflightByKey = new Map<string, Promise<void>>()
// to the store. This prevents rapid session switches (e.g. 1→2→3 in the
// sidebar) from having each completed fetch fight for focus.
const syncSessionGenerationByKey = new Map<string, number>()
type SdkResult<T> = {
data?: T
error?: unknown
@@ -111,10 +112,80 @@ export function shouldFetchSessionForRenderableSync(input: {
return Boolean(input.force) || !input.hasSession || input.shouldLoadMessages
}
// ---------------------------------------------------------------------------
// useSync — message loading, pagination, optimistic updates
// Message loading, pagination, optimistic updates
// ---------------------------------------------------------------------------
function useSessionCacheTouch() {
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
const evict = useCallback(
(directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
const store = childStores.getChild(directory)
if (!store) return
const current = store.getState()
const draft = {
message: { ...current.message },
part: { ...current.part },
session_status: { ...current.session_status },
session_diff: { ...current.session_diff },
todo: { ...current.todo },
permission: { ...current.permission },
question: { ...current.question },
}
dropSessionCaches(draft, sessionIDs)
dropCachedSessionMessageRecordsSnapshots(store, sessionIDs)
store.setState(draft)
for (const sessionID of sessionIDs) messageLoader.invalidateSession({ directory, sessionID })
clearSessionPrefetch(directory, sessionIDs)
},
[childStores, messageLoader, runtimeKey],
)
const seenFor = useCallback((directory: string) => {
const cacheKey = `${runtimeKey}\n${directory}`
const existing = seenByDirectory.get(cacheKey)
if (existing) {
seenByDirectory.delete(cacheKey)
seenByDirectory.set(cacheKey, existing)
return existing.sessions
}
const created: SeenDirectoryEntry = { runtimeKey, directory, sessions: new Set() }
seenByDirectory.set(cacheKey, created)
while (seenByDirectory.size > MAX_SEEN_DIRS) {
const oldestKey = seenByDirectory.keys().next().value
if (!oldestKey) break
const oldest = seenByDirectory.get(oldestKey)
seenByDirectory.delete(oldestKey)
if (oldest?.runtimeKey === runtimeKey) evict(oldest.directory, [...oldest.sessions])
}
return created.sessions
}, [evict, runtimeKey])
return useCallback((sessionID: string, directory: string) => {
if (getRuntimeKey() !== runtimeKey) return
const seen = seenFor(directory)
const store = childStores.ensureChild(directory, { bootstrap: false })
const protectedIds = getProtectedSessionCacheIds(store.getState())
const stale = pickSessionCacheEvictions({
seen,
keep: sessionID,
limit: getEffectiveSessionCacheLimit(),
preserve: protectedIds,
})
evict(directory, stale)
if (!isConstrainedSessionRuntime()) return
const state = store.getState()
const keep = new Set([sessionID, ...seen, ...protectedIds])
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
evict(directory, prefetched)
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => (
id !== sessionID && !protectedIds.has(id) && isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
))
for (const id of heavyInactive) seen.delete(id)
evict(directory, heavyInactive)
}, [childStores, evict, runtimeKey, seenFor])
}
export function useSync() {
const sdk = useSyncSDK()
@@ -123,6 +194,7 @@ export function useSync() {
const childStores = useChildStoreManager()
const messageLoader = useSessionMessageLoader()
const runtimeKey = getRuntimeKey()
const touch = useSessionCacheTouch()
const recoverPendingQuestions = useCallback(
async (sessionID: string, directoryOverride?: string): Promise<boolean> => {
@@ -146,107 +218,6 @@ export function useSync() {
[directory, runtimeKey],
)
// Session cache eviction — two levels of LRU:
// (1) across directories (max 30), (2) within a directory (SESSION_CACHE_LIMIT).
// Evict all cached session data for given IDs from a directory's store
const evict = useCallback(
(dir: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0 || getRuntimeKey() !== runtimeKey) return
const dirStore = childStores.getChild(dir)
if (!dirStore) return
const current = dirStore.getState()
const draft = {
message: { ...current.message },
part: { ...current.part },
session_status: { ...current.session_status },
session_diff: { ...current.session_diff },
todo: { ...current.todo },
permission: { ...current.permission },
question: { ...current.question },
}
dropSessionCaches(draft, sessionIDs)
dropCachedSessionMessageRecordsSnapshots(dirStore, sessionIDs)
dirStore.setState(draft)
// Clear meta + optimistic + prefetch cache for evicted sessions
for (const id of sessionIDs) {
messageLoader.invalidateSession({ directory: dir, sessionID: id })
}
clearSessionPrefetch(dir, sessionIDs)
},
[childStores, messageLoader, runtimeKey],
)
// Get or create the seen-set for a directory. LRU reorder on access.
// When seen directories exceed MAX_SEEN_DIRS, evict the oldest directory's caches.
// LRU reorder on access. Evicts oldest directory when exceeding MAX_SEEN_DIRS.
const seenFor = useCallback((targetDirectory: string) => {
const cacheKey = `${runtimeKey}\n${targetDirectory}`
const existing = seenByDirectory.get(cacheKey)
if (existing) {
// LRU reorder: delete + re-insert moves to end (most recent)
seenByDirectory.delete(cacheKey)
seenByDirectory.set(cacheKey, existing)
return existing.sessions
}
const created: SeenDirectoryEntry = { runtimeKey, directory: targetDirectory, sessions: new Set() }
seenByDirectory.set(cacheKey, created)
// Evict oldest directories if over limit
while (seenByDirectory.size > MAX_SEEN_DIRS) {
const first = seenByDirectory.keys().next().value
if (!first) break
const stale = seenByDirectory.get(first)
seenByDirectory.delete(first)
if (stale?.runtimeKey === runtimeKey) evict(stale.directory, [...stale.sessions])
}
return created.sessions
}, [evict, runtimeKey])
// Touch a session — triggers both directory-level and session-level eviction
const touch = useCallback(
(sessionID: string, targetDirectory = directory) => {
if (getRuntimeKey() !== runtimeKey) return
const s = seenFor(targetDirectory)
const targetStore = targetDirectory === directory
? store
: childStores.ensureChild(targetDirectory, { bootstrap: false })
const protectedIds = getProtectedSessionCacheIds(targetStore.getState())
const cacheLimit = getEffectiveSessionCacheLimit()
const stale = pickSessionCacheEvictions({
seen: s,
keep: sessionID,
limit: cacheLimit,
preserve: protectedIds,
})
evict(targetDirectory, stale)
if (isConstrainedSessionRuntime()) {
const state = targetStore.getState()
const keep = new Set([sessionID, ...s, ...protectedIds])
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
evict(targetDirectory, prefetched)
// One very large inactive session can create memory/GC pressure that
// makes later small-session switches feel slow. Keep it while active,
// but do not retain it as a warm cache in constrained shells.
const afterPrefetchEviction = prefetched.length > 0 ? targetStore.getState() : state
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
if (id === sessionID || protectedIds.has(id)) return false
return isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
})
if (heavyInactive.length > 0) {
for (const id of heavyInactive) s.delete(id)
evict(targetDirectory, heavyInactive)
}
}
},
[childStores, directory, seenFor, evict, runtimeKey, store],
)
// Sync a session (load if not cached)
const syncSession = useCallback(
async (sessionID: string, force?: boolean, directoryOverride?: string) => {
@@ -289,14 +260,8 @@ export function useSync() {
if (result.data && !isStale()) {
const nextSession = stripSessionDiffSnapshots(result.data)
const s = targetStore.getState()
const sessions = [...s.session]
const idx = Binary.search(sessions, sessionID, (s) => s.id)
if (idx.found) {
sessions[idx.index] = nextSession
} else {
sessions.splice(idx.index, 0, nextSession)
}
if (!isStale()) {
const sessions = upsertSessionRecord(s.session, nextSession)
if (sessions !== s.session && !isStale()) {
targetStore.setState({ session: sessions })
}
}
@@ -436,3 +401,29 @@ export function useSync() {
[syncSession, prefetchSession, loadMore, loadCompleteHistory, hasMore, isLoading, isComplete, recoverPendingQuestions, optimisticAdd, optimisticRemove, optimisticConfirm],
)
}
export function usePrefetchSessionMessages() {
const { messageLoader, runtimeKey } = useSyncRuntime()
const touch = useSessionCacheTouch()
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
if (getRuntimeKey() !== runtimeKey) return
await messageLoader.prefetch({ directory, sessionID })
if (messageLoader.getSnapshot({ directory, sessionID }).status !== "ready") return
touch(sessionID, directory)
}, [messageLoader, runtimeKey, touch])
}
export function useSessionMessageRecordsForExport() {
const { childStores, messageLoader, runtimeKey } = useSyncRuntime()
const touch = useSessionCacheTouch()
return useCallback(async ({ directory, sessionID }: { directory: string; sessionID: string }) => {
if (getRuntimeKey() !== runtimeKey) return null
const store = childStores.ensureChild(directory, { bootstrap: false })
touch(sessionID, directory)
await messageLoader.loadComplete({ directory, sessionID })
if (getRuntimeKey() !== runtimeKey) return null
return buildSessionMessageRecordsSnapshot(store.getState(), sessionID).list
}, [childStores, messageLoader, runtimeKey, touch])
}