diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index d49b1ae5..b64a3f4f 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -111,7 +111,15 @@ const EMPTY_QUEUE: QueuedMessage[] = []; export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: QueuedMessageChipsProps) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + // Must use the same resolution the composer used to build the queue key — + // reading currentSessionDirectory raw can key the chips to a different + // directory than the one the messages were queued under. + const currentSessionDirectory = useSessionUIStore( + React.useCallback( + (state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), + [currentSessionId], + ), + ); const target = currentSessionId ? createMessageQueueTarget(currentSessionId, currentSessionDirectory) : null; const queueKey = target ? getMessageQueueKey(target) : null; const queuedMessages = useMessageQueueStore( diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts index 6675b2a5..70c5eb43 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -29,12 +29,61 @@ mock.module('@/sync/session-ui-store', () => ({ import { buildQueuedAutoSendPayload, + createQueuedAutoSendRetryScheduler, getQueuedAutoSendRetryDelayMs, isQueuedAutoSendBackedOff, sendQueuedAutoSendPayload, shouldDispatchQueuedAutoSend, } from './useQueuedMessageAutoSend'; +describe('queued auto-send retry scheduler', () => { + test('wakes the queue when backoff expires', () => { + const callbacks = new Map void>(); + let nextTimer = 0; + let wakeups = 0; + const scheduler = createQueuedAutoSendRetryScheduler( + () => { wakeups += 1; }, + () => 1_000, + (callback, delay) => { + callbacks.set(++nextTimer, callback); + expect(delay).toBe(500); + return nextTimer as unknown as ReturnType; + }, + (timer) => { callbacks.delete(timer as unknown as number); }, + ); + + scheduler.schedule(1_500); + expect(callbacks.size).toBe(1); + callbacks.values().next().value?.(); + expect(wakeups).toBe(1); + }); + + test('keeps the earliest retry and cancels it on dispose', () => { + const callbacks = new Map void>(); + let nextTimer = 0; + const delays: number[] = []; + const scheduler = createQueuedAutoSendRetryScheduler( + () => undefined, + () => 1_000, + (callback, delay) => { + callbacks.set(++nextTimer, callback); + delays.push(delay); + return nextTimer as unknown as ReturnType; + }, + (timer) => { callbacks.delete(timer as unknown as number); }, + ); + + scheduler.schedule(3_000); + scheduler.schedule(4_000); + scheduler.schedule(2_000); + + expect(delays).toEqual([2_000, 1_000]); + expect(callbacks.size).toBe(1); + scheduler.dispose(); + expect(callbacks.size).toBe(0); + }); +}); + describe('shouldDispatchQueuedAutoSend', () => { test('dispatches only after an active session becomes idle', () => { expect(shouldDispatchQueuedAutoSend('busy', 'idle', false)).toBe(true); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index ecb53e6b..bc327ebe 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -33,12 +33,47 @@ export const isQueuedAutoSendBackedOff = ( now: number, ): boolean => failure !== undefined && failure.messageId === messageId && now < failure.nextAttemptAt; -const hasRecentAbort = (sessionId: string): boolean => { +export const createQueuedAutoSendRetryScheduler = ( + onWake: () => void, + now: () => number = Date.now, + scheduleTimeout: (callback: () => void, delay: number) => ReturnType = setTimeout, + cancelTimeout: (timer: ReturnType) => void = clearTimeout, +) => { + let timer: ReturnType | null = null; + let scheduledAt: number | null = null; + + return { + schedule(retryAt: number) { + if (scheduledAt !== null && scheduledAt <= retryAt) return; + if (timer !== null) cancelTimeout(timer); + scheduledAt = retryAt; + timer = scheduleTimeout(() => { + timer = null; + scheduledAt = null; + onWake(); + }, Math.max(0, retryAt - now())); + }, + dispose() { + if (timer !== null) cancelTimeout(timer); + timer = null; + scheduledAt = null; + }, + }; +}; + +/** + * When the abort window is still open, returns the time it expires so the + * caller can wake the queue then. Returns `null` once sending is allowed + * again — a queued item must not wait for an unrelated state change to be + * retried after the window closes. + */ +const getAbortHoldUntil = (sessionId: string): number | null => { const abortRecord = useSessionUIStore.getState().sessionAbortFlags.get(sessionId); if (!abortRecord) { - return false; + return null; } - return Date.now() - abortRecord.timestamp < RECENT_ABORT_WINDOW_MS; + const holdUntil = abortRecord.timestamp + RECENT_ABORT_WINDOW_MS; + return Date.now() < holdUntil ? holdUntil : null; }; export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => { @@ -149,6 +184,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? const sendFailuresRef = React.useRef>(new Map()); const previousStatusRef = React.useRef>(new Map()); const autoReviewBlockedSessionsRef = React.useRef>(new Set()); + const [retryTick, setRetryTick] = React.useState(0); + const retryScheduler = React.useMemo( + () => createQueuedAutoSendRetryScheduler(() => setRetryTick((value) => value + 1)), + [], + ); + + React.useEffect(() => () => retryScheduler.dispose(), [retryScheduler]); React.useEffect(() => { if (!enabled) { @@ -164,7 +206,9 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? if (inFlightSessionsRef.current.has(targetKey)) { return; } - if (hasRecentAbort(sessionId)) { + const abortHoldUntil = getAbortHoldUntil(sessionId); + if (abortHoldUntil !== null) { + retryScheduler.schedule(abortHoldUntil); return; } if (useAutoReviewStore.getState().isRunningForSession(sessionId)) { @@ -185,7 +229,8 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? const failure = sendFailuresRef.current.get(targetKey); if (failure && failure.messageId !== payload.queuedMessageId) { sendFailuresRef.current.delete(targetKey); - } else if (isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) { + } else if (failure && isQueuedAutoSendBackedOff(failure, payload.queuedMessageId, Date.now())) { + retryScheduler.schedule(failure.nextAttemptAt); return; } @@ -195,6 +240,10 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? ? captured : resolveSessionSendConfig(sessionId); if (!resolved.providerID || !resolved.modelID) { + // Legacy queues may predate captured send configuration. Config + // hydration is asynchronous, so retry instead of stranding the item + // until an unrelated status or directory update happens. + retryScheduler.schedule(Date.now() + AUTO_SEND_RETRY_BASE_DELAY_MS); return; } @@ -213,11 +262,13 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? console.warn('[queue] queued auto-send failed:', error); const priorFailures = failure?.messageId === payload.queuedMessageId ? failure.failures : 0; const failures = priorFailures + 1; + const nextAttemptAt = Date.now() + getQueuedAutoSendRetryDelayMs(failures); sendFailuresRef.current.set(targetKey, { messageId: payload.queuedMessageId, failures, - nextAttemptAt: Date.now() + getQueuedAutoSendRetryDelayMs(failures), + nextAttemptAt, }); + retryScheduler.schedule(nextAttemptAt); } finally { inFlightSessionsRef.current.delete(targetKey); } @@ -257,5 +308,5 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? }); previousStatusRef.current = nextStatusMap; - }, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory]); + }, [enabled, queuedMessages, sessionStatusRecord, autoReviewRuns, currentDirectory, retryTick, retryScheduler]); } diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index bafb8272..cece345d 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -1,12 +1,19 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useSessionUIStore, getRememberedSessionDirectory } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { opencodeClient } from '@/lib/opencode/client'; import { checkIsGitRepository } from '@/lib/gitApi'; import { streamDebugEnabled } from '@/stores/utils/streamDebug'; import { copyTextToClipboard as copyPlainTextToClipboard } from '@/lib/clipboard'; -import { getSyncSessions, getSyncMessages, getSyncParts } from '@/sync/sync-refs'; +import { getSyncSessions, getSyncMessages, getSyncParts, getAllSyncSessions, getSyncSessionDirectory } from '@/sync/sync-refs'; +import { + describeSessionDirectorySources, + resolveSessionDirectoryFromSources, +} from '@/sync/session-directory-resolution'; +import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; +import { getRecentSendFailures } from '@/sync/send-failure-log'; +import { getAttachedSessionDirectory } from '@/sync/session-worktree-contract'; import { useStreamingStore } from '@/sync/streaming'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; @@ -375,12 +382,107 @@ export const debugUtils = { openchamber: { settingsInfo, }, + // Empty is a meaningful answer here: it means no prompt was rejected in + // this session, so a "my message disappeared" report is not a rejected + // send and needs a different explanation. + recentSendFailures: getRecentSendFailures(), + currentSessionDirectoryResolution: sessionState.currentSessionId + ? this.diagnoseSessionDirectory(sessionState.currentSessionId) + : null, }; console.log('[DEBUG] App status snapshot:', report); return report; }, + /** + * Prompt sends that were rejected and rolled back in this app session. + * Newest first; empty means no send was rejected. + */ + getRecentSendFailures() { + const failures = getRecentSendFailures(); + if (failures.length === 0) { + console.log('[OK] No prompt sends were rejected in this session.'); + } else { + console.warn(`[ALERT] ${failures.length} rejected prompt send(s):`); + console.table(failures); + } + return failures; + }, + + /** + * Report how a session's directory is resolved, from every source, in + * precedence order. A send is routed by the winning value, so a disagreement + * here explains a prompt that vanishes without an error: it was posted + * against a directory that does not own the session. + */ + diagnoseSessionDirectory(sessionId?: string) { + const sessionState = useSessionUIStore.getState(); + const targetSessionId = sessionId ?? sessionState.currentSessionId; + + if (!targetSessionId) { + console.log('[ERROR] No session selected and no session id passed'); + return null; + } + + const attachment = getAttachedSessionDirectory( + useSessionWorktreeStore.getState().getAttachment(targetSessionId), + ); + const worktreeMetadata = sessionState.worktreeMetadata.get(targetSessionId)?.path ?? null; + const owningStoreDirectory = getSyncSessionDirectory(targetSessionId); + const sessionRecord = getAllSyncSessions().find((session) => session.id === targetSessionId); + const recordDirectory = (sessionRecord as { directory?: string | null } | undefined)?.directory ?? null; + const selected = targetSessionId === sessionState.currentSessionId + ? sessionState.currentSessionDirectory + : null; + + const remembered = getRememberedSessionDirectory(targetSessionId); + + const sources = { + attachment, + worktreeMetadata, + authoritative: owningStoreDirectory ?? recordDirectory, + selected, + remembered: remembered.runtime, + }; + + const resolution = resolveSessionDirectoryFromSources(sources); + const routedDirectory = sessionState.getDirectoryForSession(targetSessionId); + + const report = { + sessionId: targetSessionId, + isCurrentSession: targetSessionId === sessionState.currentSessionId, + routedDirectory, + resolvedFrom: resolution.source, + conflict: resolution.conflict, + sources: describeSessionDirectorySources(sources), + details: { + owningChildStore: owningStoreDirectory, + sessionRecordDirectory: recordDirectory, + sessionIndexed: Boolean(sessionRecord), + currentSessionDirectory: sessionState.currentSessionDirectory, + rememberedForRuntime: remembered.runtime, + persistedAcrossRestarts: remembered.persisted, + activeDirectory: useDirectoryStore.getState().currentDirectory ?? null, + opencodeClientDirectory: opencodeClient.getDirectory() ?? null, + }, + }; + + console.log('[DEBUG] Session directory resolution:', report); + if (resolution.conflict) { + console.warn( + `[ALERT] Directory sources disagree: using "${resolution.directory}" (${resolution.source}) ` + + `while "${resolution.conflict.directory}" came from ${resolution.conflict.source}.`, + ); + } else if (!routedDirectory) { + console.warn('[ALERT] No directory resolved for this session — sends fall back to the active directory.'); + } else { + console.log('[OK] All known sources agree on the session directory.'); + } + + return report; + }, + async buildDiagnosticsReport() { const report = await this.getAppStatus(); return JSON.stringify(report, null, 2); @@ -697,6 +799,8 @@ if (typeof window !== 'undefined') { console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)'); console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array'); console.log(' __opencodeDebug.getAppStatus() - Show app status snapshot'); + console.log(' __opencodeDebug.diagnoseSessionDirectory(sessionId?) - Show how the session directory is resolved'); + console.log(' __opencodeDebug.getRecentSendFailures() - List prompt sends that were rejected and rolled back'); console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic'); console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages'); console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses'); diff --git a/packages/ui/src/stores/useMultiRunStore.test.ts b/packages/ui/src/stores/useMultiRunStore.test.ts index b2a1bbb6..c01295f1 100644 --- a/packages/ui/src/stores/useMultiRunStore.test.ts +++ b/packages/ui/src/stores/useMultiRunStore.test.ts @@ -127,6 +127,7 @@ mock.module('./useGlobalSessionsStore', () => ({ })); mock.module('@/sync/sync-refs', () => ({ + getSyncSessionDirectory: () => null, registerSessionDirectory: (sessionID: string, directory: string) => { registeredDirectories.push({ sessionID, directory }); }, diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 93a475ff..78152f32 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -197,6 +197,30 @@ Directory stores also own session-keyed sidecar notification channels for permis Message sidecar consumers also filter targeted updates by purpose before notifying React. Suspended live-tail text/reasoning changes do not rebuild visible message records, but structural Task session identity changes bypass suspension so a parent can link a newly created subagent immediately. Assistant-only part changes do not rebuild user input history, and targeted updates that preserve authoritative part buckets do not recheck a session that is already renderable. Message replacements, removed final part buckets, and conservative resets always notify. +## Session directory resolution + +`session-directory-resolution.ts` owns the precedence used to answer "which directory does this session belong to". Every send, message fetch, message-queue key, and send-confirmation lookup is routed by that answer, so a wrong value is not a display problem: the prompt is posted against a directory that does not own the session, the request is rejected, and the optimistic message is rolled back with no visible error. + +Precedence, highest authority first: + +The discriminator is whether the server confirmed the path, not whether the value is local or synced. + +| Source | Meaning | +|---|---| +| `authoritative` | The child store that actually holds the session, then its own record | +| `selected` | Server-confirmed directory captured at selection; a guessed one is never passed | +| `attachment` | Worktree attachment recorded by this client; the *requested* path | +| `worktree-metadata` | Worktree captured when the session was created in one; the *requested* path | +| `remembered` | Per-runtime directory persisted across restarts | + +Rules: + +1. `getSyncSessionDirectory()` is the authoritative session→directory mapping: a session lives in exactly the child store for its directory, whether or not the server populated `session.directory`. `null` means "not indexed yet", never "no directory". +2. `attachment` and `worktreeMetadata` hold the worktree path this client asked for, before the server canonicalized it. They are a hint for a session sync has not indexed yet, never a correction of a confirmed directory — otherwise a stale local path re-creates the very mismatch this precedence exists to prevent. +3. Never persist or rank a guessed directory. `selectSession` may fall back to the active directory to keep routing usable, but that value is not written to runtime memory, not written to the last-active snapshot, and not passed as `selected` — a persisted guess outlives the race that produced it and survives reloads and restarts. +4. Components must not read `currentSessionDirectory` to build request or queue keys; use `getDirectoryForSession()` so every consumer resolves identically. +5. A disagreement between sources is logged once per session, and `__opencodeDebug.diagnoseSessionDirectory()` reports every source in precedence order. + ## Session action rules Session actions live in `session-actions.ts` and are the canonical place for SDK-calling session mutations that affect global session lists. @@ -207,6 +231,7 @@ Rules: 2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct. 3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls. 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. +5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. Examples of global-store updates performed in `session-actions.ts`: diff --git a/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts b/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts index 5773f57e..5cd7f3b1 100644 --- a/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts +++ b/packages/ui/src/sync/__tests__/issue-1637-2270.test.ts @@ -42,6 +42,7 @@ mock.module("../session-ui-store", () => ({ })) mock.module("../sync-refs", () => ({ + getSyncSessionDirectory: () => null, registerSessionDirectory: (sessionID: string, directory: string) => { registerSessionDirectoryCalls.push({ sessionID, directory }) }, diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 8197823c..ef9f5439 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -4,11 +4,16 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto const storage = new Map() const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = [] const permissionAutoAcceptCalls: Array<[string, boolean]> = [] +let createdSessionDirectory: string | undefined const getMockCalls = (fn: unknown): unknown[][] => ((fn as { mock?: { calls: unknown[][] } }).mock?.calls ?? []) mock.module("zustand", () => ({ - create: () => (initializer: (set: (patch: unknown | ((state: unknown) => unknown)) => void, get: () => unknown) => Record) => { + create: () => (initializer: ( + set: (patch: unknown | ((state: unknown) => unknown)) => void, + get: () => unknown, + api?: unknown, + ) => Record) => { let state: Record const get = () => state const set = (patch: unknown | ((current: Record) => unknown)) => { @@ -16,7 +21,12 @@ mock.module("zustand", () => ({ state = next && typeof next === "object" ? { ...state, ...(next as Record) } : state } - state = initializer(set, get) + state = initializer(set, get, { + setState: set, + getState: get, + getInitialState: get, + subscribe: () => () => undefined, + } as never) const store = ((selector?: (current: Record) => unknown) => ( typeof selector === "function" ? selector(state) : state @@ -53,6 +63,11 @@ const deferredStorage: Storage = { mock.module("@/stores/utils/safeStorage", () => ({ getDeferredSafeStorage: () => deferredStorage, + createDeferredSafeJSONStorage: () => ({ + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined, + }), })) mock.module("@/lib/opencode/client", () => ({ @@ -224,15 +239,18 @@ mock.module("../sync-refs", () => ({ getSyncMessages: () => [], getSyncParts: () => [], getAllSyncSessions: () => [], + getSyncSessionDirectory: () => null, })) mock.module("../session-actions", () => ({ createSession: mock(async (title: string | undefined, directory: string | null, parentID: string | null, metadata?: unknown) => { createSessionCalls.push({ title, directory, parentID, metadata }) - return { id: "ses_issue_2039", directory } + return { id: "ses_issue_2039", directory: createdSessionDirectory ?? directory } }), deleteSession: mock(async () => true), + deleteSessions: mock(async () => ({ deletedIds: [], failedIds: [] })), archiveSession: mock(async () => true), + archiveSessions: mock(async () => ({ archivedIds: [], failedIds: [] })), updateSessionTitle: mock(async () => undefined), shareSession: mock(async () => undefined), unshareSession: mock(async () => undefined), @@ -242,6 +260,9 @@ mock.module("../session-actions", () => ({ unrevertSession: mock(async () => undefined), forkFromMessage: mock(async () => undefined), fetchMessagesForSession: mock(async () => undefined), + getSessionLastAssistantModel: () => null, + patchSessionMetadata: mock(async () => undefined), + abortCurrentOperation: mock(async () => undefined), })) const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store") @@ -298,6 +319,7 @@ describe("issue 2039 draft auto-accept", () => { storage.clear() createSessionCalls.length = 0 permissionAutoAcceptCalls.length = 0 + createdSessionDirectory = undefined useSessionUIStore.setState({ currentSessionId: null, @@ -348,4 +370,45 @@ describe("issue 2039 draft auto-accept", () => { expect(createSessionCalls).toHaveLength(0) expect(permissionAutoAcceptCalls).toHaveLength(0) }) + + test("uses the server-authoritative directory after worktree session creation", async () => { + createdSessionDirectory = "/canonical/worktree" + useSessionUIStore.getState().openNewSessionDraft({ + directoryOverride: "/requested/worktree", + }) + + const result = await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + }) + + expect(createSessionCalls[0]?.directory).toBe("/requested/worktree") + expect(result?.directory).toBe("/canonical/worktree") + expect(useSessionUIStore.getState().currentSessionDirectory).toBe("/canonical/worktree") + }) + + test("routes the session by the canonical directory, not the requested worktree path", async () => { + createdSessionDirectory = "/canonical/worktree" + useSessionUIStore.getState().openNewSessionDraft({ + directoryOverride: "/requested/worktree", + }) + + const created = await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + }) + const sessionId = created?.sessionId ?? "" + + // The worktree attachment still holds the path this client asked for. The + // directory every send, queue key, and confirmation lookup is routed by + // must be the canonical one the server returned. + useSessionUIStore.getState().setWorktreeMetadata(sessionId, { + path: "/requested/worktree", + projectDirectory: "/repo", + branch: "feature", + label: "feature", + }) + + expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree") + }) }) diff --git a/packages/ui/src/sync/send-failure-log.ts b/packages/ui/src/sync/send-failure-log.ts new file mode 100644 index 00000000..5bbac589 --- /dev/null +++ b/packages/ui/src/sync/send-failure-log.ts @@ -0,0 +1,49 @@ +/** + * Recent prompt-send failures, kept in memory for diagnostics. + * + * A rejected send rolls the optimistic message back and, for transport-level + * failures, the composer stays silent by design. That makes a misrouted or + * refused prompt indistinguishable from "nothing happened" — the user has + * nothing to report beyond "it disappeared". + * + * This buffer gives the failure somewhere to live until someone asks for it, + * via the About dialog's diagnostics report or `__opencodeDebug`. It is + * in-memory only: never persisted, never sent anywhere, and dropped on reload. + */ + +const MAX_RECORDED_SEND_FAILURES = 20 +const MAX_REASON_LENGTH = 200 + +export type SendFailureRecord = { + at: number + sessionId: string + messageId: string + /** Directory the prompt was routed to — the value under suspicion. */ + directory: string | null + /** HTTP status, or null for a transport failure with no response. */ + status: number | null + /** Whether the send may still have been accepted server-side. */ + ambiguous: boolean + /** Whether a confirmation refetch ran and failed to find the message. */ + confirmationChecked: boolean + reason: string +} + +const records: SendFailureRecord[] = [] + +export function recordSendFailure(record: Omit & { reason: string }): void { + records.push({ + ...record, + reason: record.reason.slice(0, MAX_REASON_LENGTH), + at: Date.now(), + }) + if (records.length > MAX_RECORDED_SEND_FAILURES) { + records.splice(0, records.length - MAX_RECORDED_SEND_FAILURES) + } +} + +/** Newest first. */ +export function getRecentSendFailures(): SendFailureRecord[] { + return [...records].reverse() +} + diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 85da9ffc..c9c4325a 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -245,6 +245,7 @@ mock.module("./session-deletion-cleanup", () => ({ })) mock.module("./sync-refs", () => ({ + getSyncSessionDirectory: () => null, registerSessionDirectory: (sessionID: string, directory: string) => { registeredSessionDirectories.push({ sessionID, directory }) }, diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index e310f3a0..5a6eab26 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -13,6 +13,7 @@ import { opencodeClient } from "@/lib/opencode/client" import { mergeSessionDirectoryMetadata, resolveGlobalSessionDirectory, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore" import { useConfigStore } from "@/stores/useConfigStore" import { registerSessionDirectory } from "./sync-refs" +import { recordSendFailure } from "./send-failure-log" import { isSyntheticPart } from "@/lib/messages/synthetic" import { materializeSessionSnapshots } from "./materialization" import { stripMessageDiffSnapshots, stripSessionDiffSnapshots } from "./sanitize" @@ -1176,7 +1177,9 @@ export async function optimisticSend(input: { try { await input.send(messageID) } catch (error) { - const acceptedRecords = isAmbiguousSendFailure(error) + const status = getErrorStatus(error) + const ambiguousFailure = isAmbiguousSendFailure(error) + const acceptedRecords = ambiguousFailure ? await fetchRecentSendConfirmationRecords(input.sessionId, messageID, targetDirectory) : null @@ -1190,6 +1193,24 @@ export async function optimisticSend(input: { return } + // The rollback below makes the user's message disappear with no other + // trace, and the composer intentionally stays silent for transport-level + // failures. Record the failure so the About dialog's diagnostics report can + // answer "it disappeared and nothing happened" with an actual cause. + // `reason` is truncated by the recorder: a rejected send echoes the + // provider/OpenCode response body, which this log has no reason to keep. + const failureRecord = { + sessionId: input.sessionId, + messageId: messageID, + directory: targetDirectory ?? null, + status, + ambiguous: ambiguousFailure, + confirmationChecked: ambiguousFailure, + reason: error instanceof Error ? error.message : String(error), + } + recordSendFailure(failureRecord) + console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord) + // Rollback via optimistic infrastructure _optimisticRemove({ sessionID: input.sessionId, diff --git a/packages/ui/src/sync/session-directory-resolution.test.ts b/packages/ui/src/sync/session-directory-resolution.test.ts new file mode 100644 index 00000000..c901ca30 --- /dev/null +++ b/packages/ui/src/sync/session-directory-resolution.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, test } from 'bun:test'; + +import { + describeSessionDirectorySources, + resolveSessionDirectoryFromSources, +} from './session-directory-resolution'; + +const WORKTREE = '/repo/.worktrees/feature'; +const MAIN = '/repo'; + +describe('resolveSessionDirectoryFromSources', () => { + test('authoritative directory beats a selection-time fallback', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + selected: MAIN, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('authoritative'); + expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN }); + }); + + test('authoritative directory beats a directory persisted across restarts', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + remembered: MAIN, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN }); + }); + + test('the indexed directory outranks a locally requested worktree path', () => { + // attachment/worktreeMetadata hold the path this client asked for, before + // the server canonicalized it. Letting them win would route prompts to a + // directory that no child store owns. + const resolution = resolveSessionDirectoryFromSources({ + attachment: '/requested/worktree', + worktreeMetadata: '/requested/worktree', + authoritative: WORKTREE, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('authoritative'); + expect(resolution.conflict).toEqual({ source: 'attachment', directory: '/requested/worktree' }); + }); + + test('a worktree attachment is used while the session is not indexed yet', () => { + // A guessed selection is not passed as `selected` at all, so the worktree + // assignment is the best available value during the bootstrap race. + const resolution = resolveSessionDirectoryFromSources({ + authoritative: null, + selected: null, + attachment: WORKTREE, + remembered: MAIN, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('attachment'); + expect(resolution.conflict).toEqual({ source: 'remembered', directory: MAIN }); + }); + + test('a server-confirmed selection outranks the requested worktree path', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: null, + selected: '/canonical/worktree', + attachment: '/requested/worktree', + worktreeMetadata: '/requested/worktree', + }); + + expect(resolution.directory).toBe('/canonical/worktree'); + expect(resolution.source).toBe('selected'); + }); + + test('falls back to the selection hint while the session is not indexed yet', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: null, + selected: WORKTREE, + }); + + expect(resolution.directory).toBe(WORKTREE); + expect(resolution.source).toBe('selected'); + expect(resolution.conflict).toBeNull(); + }); + + test('agreeing sources report no conflict', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + selected: WORKTREE, + remembered: WORKTREE, + }); + + expect(resolution.conflict).toBeNull(); + }); + + test('reports the first disagreeing source, not the last', () => { + const resolution = resolveSessionDirectoryFromSources({ + authoritative: WORKTREE, + selected: MAIN, + remembered: '/somewhere/else', + }); + + expect(resolution.conflict).toEqual({ source: 'selected', directory: MAIN }); + }); + + test('treats missing and blank values as unknown, never as a directory', () => { + const resolution = resolveSessionDirectoryFromSources({ + attachment: null, + worktreeMetadata: ' ', + authoritative: undefined, + selected: '', + }); + + expect(resolution.directory).toBeNull(); + expect(resolution.source).toBe('none'); + expect(resolution.conflict).toBeNull(); + }); +}); + +describe('describeSessionDirectorySources', () => { + test('lists populated sources in precedence order', () => { + expect(describeSessionDirectorySources({ + remembered: MAIN, + authoritative: WORKTREE, + selected: '', + })).toEqual([ + { source: 'authoritative', directory: WORKTREE }, + { source: 'remembered', directory: MAIN }, + ]); + }); +}); diff --git a/packages/ui/src/sync/session-directory-resolution.ts b/packages/ui/src/sync/session-directory-resolution.ts new file mode 100644 index 00000000..d8069176 --- /dev/null +++ b/packages/ui/src/sync/session-directory-resolution.ts @@ -0,0 +1,137 @@ +/** + * Session → directory resolution precedence. + * + * A session's directory decides which OpenCode project every send, message + * fetch, queue key, and confirmation lookup is routed to. Getting it wrong is + * not a cosmetic problem: the prompt is posted against a directory that does + * not own the session, the send is rejected, and the optimistic message is + * rolled back with no visible error. + * + * The precedence below is deliberate and ordered by authority, not by + * convenience: + * + * The ordering discriminator is **whether the server confirmed the path**, not + * whether the value is local or synced: + * + * 1. `authoritative` — the child store that actually holds the session, then + * the session's own record. Server-backed truth for an indexed session. + * 2. `selected` — the directory captured when the session was selected, but + * only when it came from a server response (the directory `createSession` + * returned, which may be a canonicalized form of what was requested). A + * selection that fell back to the active directory is a guess and is not + * passed here at all. + * 3. `attachment` / `worktreeMetadata` — the worktree this client assigned to + * the session. Both hold the *requested* path, before the server had a + * chance to canonicalize it, so they are a hint for a session sync has not + * indexed yet, never a correction of a confirmed one. + * 4. `remembered` — the per-runtime directory persisted across restarts. Last + * resort: it survives reloads, so a value written from a startup fallback + * would otherwise outlive the race that produced it. + * + * Routing a prompt by an unconfirmed path posts it against a directory that + * does not own the session, and the send is rejected. Moves need no exception: + * a session move updates the owning child store before any client-side value. + */ + +export type SessionDirectorySource = + | 'authoritative' + | 'selected' + | 'attachment' + | 'worktree-metadata' + | 'remembered' + | 'none' + +export type SessionDirectorySources = { + /** Directory of the child store that holds the session, or its own record. */ + authoritative?: string | null + /** Server-confirmed directory captured at selection. Never a guessed one. */ + selected?: string | null + /** Worktree attachment recorded for this session; the requested path. */ + attachment?: string | null + /** Worktree metadata captured when the session was created in a worktree. */ + worktreeMetadata?: string | null + /** Directory persisted for this runtime; may outlive the race that wrote it. */ + remembered?: string | null +} + +export type SessionDirectoryResolution = { + directory: string | null + source: SessionDirectorySource + /** + * Set when a lower-priority source disagrees with the winning one. This is + * the signature of the stale-directory bug: a persisted or selection-time + * fallback pointing at the parent repository while the session lives in a + * worktree. + */ + conflict: { source: SessionDirectorySource; directory: string } | null +} + +const RESOLUTION_ORDER: ReadonlyArray> = [ + 'authoritative', + 'selected', + 'attachment', + 'worktree-metadata', + 'remembered', +] + +const readSource = ( + sources: SessionDirectorySources, + source: Exclude, +): string | null => { + const value = source === 'attachment' + ? sources.attachment + : source === 'worktree-metadata' + ? sources.worktreeMetadata + : source === 'authoritative' + ? sources.authoritative + : source === 'selected' + ? sources.selected + : sources.remembered + if (typeof value !== 'string') return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} + +/** + * Resolve a session directory from every known source, reporting which source + * won and whether a weaker source disagreed. + * + * Callers normalize paths before passing them in; this module only orders + * authority and never rewrites a path. + */ +export const resolveSessionDirectoryFromSources = ( + sources: SessionDirectorySources, +): SessionDirectoryResolution => { + let winner: { source: SessionDirectorySource; directory: string } | null = null + let conflict: { source: SessionDirectorySource; directory: string } | null = null + + for (const source of RESOLUTION_ORDER) { + const directory = readSource(sources, source) + if (!directory) continue + if (!winner) { + winner = { source, directory } + continue + } + if (!conflict && directory !== winner.directory) { + conflict = { source, directory } + } + } + + if (!winner) { + return { directory: null, source: 'none', conflict: null } + } + + return { directory: winner.directory, source: winner.source, conflict } +} + +/** Every source that carries a value, in precedence order. For diagnostics. */ +export const describeSessionDirectorySources = ( + sources: SessionDirectorySources, +): Array<{ source: SessionDirectorySource; directory: string }> => { + const described: Array<{ source: SessionDirectorySource; directory: string }> = [] + for (const source of RESOLUTION_ORDER) { + const directory = readSource(sources, source) + if (directory) described.push({ source, directory }) + } + return described +} diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 33618f71..4f65e98b 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -40,7 +40,13 @@ import { getSyncMessages, getSyncParts, getDirectoryState, + getSyncSessionDirectory, } from "./sync-refs" +import { + resolveSessionDirectoryFromSources, + type SessionDirectoryResolution, + type SessionDirectorySources, +} from "./session-directory-resolution" import { markSessionViewed } from "./notification-store" import { setActiveSession } from "./sync-context" import { @@ -73,7 +79,7 @@ import { useSessionWorktreeStore } from "./session-worktree-store" import { getAttachedSessionDirectory } from "./session-worktree-contract" import { setSessionOpener } from "./session-navigation" import { getRuntimeKey } from "@/lib/runtime-switch" -import { clearLastActiveSession, persistLastActiveSession } from "./last-session-cache" +import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache" import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache" import { rememberRuntimeLiveStatus } from "./runtime-live-memory" @@ -394,23 +400,108 @@ const getAttachmentForSession = (sessionId: string | null | undefined): SessionW return useSessionWorktreeStore.getState().getAttachment(sessionId) } +/** + * Authoritative directory for a session: the child store that holds it, and + * only then the session record's own fields. `null` means "not indexed yet", + * never "no directory" — callers must fall back rather than treat it as empty. + */ +const getAuthoritativeSessionDirectory = (sessionId: string): string | null => { + const owningDirectory = getSyncSessionDirectory(sessionId) + if (owningDirectory) return normalizePath(owningDirectory) + const target = getAllSyncSessions().find((s) => s.id === sessionId) + return target ? resolveDirectoryKey(target) : null +} + +/** + * Directory remembered for a session in this runtime, plus the one persisted + * across restarts. Exported for diagnostics: a stale persisted directory is the + * hardest source to observe and the one that survives reloads, so a report that + * cannot show it cannot rule it out. + */ +export const getRememberedSessionDirectory = (sessionId: string): { + runtime: string | null + persisted: string | null +} => { + const key = runtimeMemoryKey() + const runtimeMemory = runtimeSessionMemory.get(key) + const persisted = readLastActiveSession(key) + return { + runtime: runtimeMemory?.sessionId === sessionId ? normalizePath(runtimeMemory.directory) : null, + persisted: persisted?.sessionId === sessionId ? normalizePath(persisted.directory) : null, + } +} + +/** + * Session whose `currentSessionDirectory` is only the active directory, used + * because the session's own directory was not known at selection time. Such a + * value must never outrank a worktree assignment or reach persistence — it is + * a guess, not a selection. + */ +let guessedSelectionSessionId: string | null = null + +const collectSessionDirectorySources = ( + sessionId: string, + getWtMeta: (id: string) => WorktreeMetadata | undefined, + selected: string | null, +): SessionDirectorySources => ({ + authoritative: getAuthoritativeSessionDirectory(sessionId), + selected: sessionId === guessedSelectionSessionId ? null : normalizePath(selected), + attachment: getAttachedSessionDirectory(getAttachmentForSession(sessionId)), + worktreeMetadata: normalizePath(getWtMeta(sessionId)?.path ?? null), + remembered: getRememberedSessionDirectory(sessionId).runtime, +}) + +/** + * Conflicts already warned about, so a stale directory logs once instead of on + * every keystroke. Keyed by runtime *and* the exact pair of directories: the + * same session ID means a different thing in another runtime, and a conflict + * that reappears after being resolved is news worth logging again. Bounded so + * a long-lived session cannot grow it without limit. + */ +const reportedDirectoryConflicts = new Set() +const MAX_REPORTED_DIRECTORY_CONFLICTS = 200 + +const reportSessionDirectoryConflict = ( + sessionId: string, + resolution: SessionDirectoryResolution, +): void => { + if (!resolution.conflict) return + const conflictKey = JSON.stringify([ + runtimeMemoryKey(), + sessionId, + resolution.directory, + resolution.conflict.source, + resolution.conflict.directory, + ]) + if (reportedDirectoryConflicts.has(conflictKey)) return + if (reportedDirectoryConflicts.size >= MAX_REPORTED_DIRECTORY_CONFLICTS) { + reportedDirectoryConflicts.clear() + } + reportedDirectoryConflicts.add(conflictKey) + console.warn( + "[session-directory] session directory sources disagree; using the higher-authority one. " + + "Run __opencodeDebug.diagnoseSessionDirectory() for the full picture.", + { + sessionId, + using: resolution.source, + directory: resolution.directory, + conflictingSource: resolution.conflict.source, + conflictingDirectory: resolution.conflict.directory, + }, + ) +} + const resolveSessionDirectory = ( sessionId: string | null | undefined, getWtMeta: (id: string) => WorktreeMetadata | undefined, + selected: string | null = null, ): string | null => { if (!sessionId) return null - const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId)) - if (attachmentDirectory) return attachmentDirectory - const metaPath = getWtMeta(sessionId)?.path - if (typeof metaPath === "string" && metaPath.trim().length > 0) return normalizePath(metaPath) - const runtimeMemory = runtimeSessionMemory.get(runtimeMemoryKey()) - if (runtimeMemory?.sessionId === sessionId && runtimeMemory.directory) { - return normalizePath(runtimeMemory.directory) - } - const sessions = getAllSyncSessions() - const target = sessions.find((s) => s.id === sessionId) - if (!target) return null - return resolveDirectoryKey(target) + const resolution = resolveSessionDirectoryFromSources( + collectSessionDirectorySources(sessionId, getWtMeta, selected), + ) + reportSessionDirectoryConflict(sessionId, resolution) + return resolution.directory } const activateConfigForDirectory = async (directory: string | null | undefined): Promise => { @@ -504,13 +595,18 @@ export async function materializeOpenDraftSession(selection: { const created = await store.createSession(draft.title, draftDirectoryOverride, draft.parentID ?? null) if (!created?.id) 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). + // Sending with the pre-canonical draft path can target a different + // directory scope than the session that was just created. + const createdDirectory = normalizePath(created.directory ?? draftDirectoryOverride ?? null) + persistDraftTarget({ projectId: draftProjectId, - directory: normalizePath(draftDirectoryOverride ?? created.directory ?? null), + directory: createdDirectory, }) const draftSyntheticParts = draft.syntheticParts - const createdDirectory = normalizePath(draftDirectoryOverride ?? created.directory ?? null) const configState = useConfigStore.getState() void activateConfigForDirectory(createdDirectory).catch((error) => { console.warn("Failed to activate directory after creating session:", error) @@ -604,7 +700,13 @@ export const useSessionUIStore = create()((set, get) => ({ (sid) => get().worktreeMetadata.get(sid), ) const fallbackDir = opencodeClient.getDirectory() ?? directoryState.currentDirectory ?? null - const resolvedDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir ?? fallbackDir + const knownDir = (directoryHint ? normalizePath(directoryHint) : null) ?? sessionDir + const resolvedDir = knownDir ?? fallbackDir + // `fallbackDir` is the active directory, not this session's directory. It + // keeps routing usable while the owning directory store bootstraps, but it + // must never be remembered: a persisted guess outlives the race that + // produced it and survives reloads and restarts. + const isGuessedDir = knownDir === null const projectsState = useProjectsStore.getState() const sessionProject = resolvedDir ? resolveProjectForSessionDirectory( @@ -617,12 +719,14 @@ export const useSessionUIStore = create()((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 }) - writeRuntimeSessionMemory(key, { sessionId: id, directory: resolvedDir ?? null }) + guessedSelectionSessionId = isGuessedDir && id ? id : null + const rememberedDir = isGuessedDir ? null : resolvedDir ?? null + writeRuntimeSessionMemory(key, { sessionId: id, directory: rememberedDir }) // Keep the last NON-null session per runtime across app restarts (cold // mobile launches reopen it after the instance reconnects). Going back to // a draft intentionally does not erase it. if (id) { - persistLastActiveSession(key, { sessionId: id, directory: resolvedDir ?? null }) + persistLastActiveSession(key, { sessionId: id, directory: rememberedDir }) } // Kick off the message fetch on the same tick, before React commits the @@ -1560,16 +1664,19 @@ export const useSessionUIStore = create()((set, get) => ({ }, getDirectoryForSession: (sessionId) => { - if (sessionId === get().currentSessionId && get().currentSessionDirectory) { - return get().currentSessionDirectory - } - const resolved = resolveSessionDirectory(sessionId, (sid) => get().worktreeMetadata.get(sid)) + // The selection-time directory participates in resolution, it does not + // short-circuit it. For a worktree session selected before its directory + // store finished bootstrapping, that value is a startup fallback pointing + // at the parent repository; letting it win would route every send, queue + // key, and send-confirmation lookup to a directory that does not own the + // session. + const selected = sessionId === get().currentSessionId ? get().currentSessionDirectory : null + const resolved = resolveSessionDirectory( + sessionId, + (sid) => get().worktreeMetadata.get(sid), + selected, + ) if (resolved) return resolved - const attachmentDirectory = getAttachedSessionDirectory(getAttachmentForSession(sessionId)) - if (attachmentDirectory) return attachmentDirectory - const sessions = getAllSyncSessions() - const session = sessions.find((s) => s.id === sessionId) - if (session) return resolveDirectoryKey(session) const globalStore = useGlobalSessionsStore.getState() const globalSession = [...globalStore.activeSessions, ...globalStore.archivedSessions] .find((s) => s.id === sessionId) @@ -1634,6 +1741,11 @@ export const useSessionUIStore = create()((set, get) => ({ setSessionDirectory: (sessionId, directory) => { const normalized = normalizePath(directory) + // Callers set this from a confirmed destination (a completed move, a + // created worktree), so the selection is no longer a guess. + if (sessionId === guessedSelectionSessionId) { + guessedSelectionSessionId = null + } if (sessionId === get().currentSessionId) { set({ currentSessionDirectory: normalized }) writeRuntimeSessionMemory(runtimeMemoryKey(), { sessionId, directory: normalized }) diff --git a/packages/ui/src/sync/sync-refs.ts b/packages/ui/src/sync/sync-refs.ts index 9b15f0d3..0d96911c 100644 --- a/packages/ui/src/sync/sync-refs.ts +++ b/packages/ui/src/sync/sync-refs.ts @@ -17,6 +17,7 @@ const configListeners = new Set<(directory: string, config: Config) => void>() let cachedSessionManager: ChildStoreManager | null = null let cachedSessionSlices = new Map() let cachedSessionsById = new Map() +let cachedSessionDirectoryById = new Map() export function setSyncRefs( _sdk: OpencodeClient, @@ -29,6 +30,7 @@ export function setSyncRefs( cachedSessionManager = null cachedSessionSlices = new Map() cachedSessionsById = new Map() + cachedSessionDirectoryById = new Map() } _directory = directory if (registerSessionDirectory) { @@ -103,20 +105,38 @@ export function getAllSyncSessionMap(): ReadonlyMap() const nextSessionsById = new Map() + const nextDirectoriesById = new Map() for (const [directory, store] of stores.children) { const sessions = store.getState().session nextSlices.set(directory, sessions) for (const session of sessions) { if (!session?.id) continue nextSessionsById.set(session.id, session) + nextDirectoriesById.set(session.id, directory) } } cachedSessionManager = stores cachedSessionSlices = nextSlices cachedSessionsById = nextSessionsById + cachedSessionDirectoryById = nextDirectoriesById return cachedSessionsById } +/** + * Directory of the child store that actually holds this session. + * + * This is the authoritative session→directory mapping: a session is present in + * exactly the store for the directory it belongs to, regardless of whether the + * server populated `session.directory` on the record itself. Returns `null` + * when no initialized child store contains the session, which means "unknown", + * never "no directory". + */ +export function getSyncSessionDirectory(sessionId: string): string | null { + if (!sessionId) return null + getAllSyncSessionMap() + return cachedSessionDirectoryById.get(sessionId) ?? null +} + /** Read messages for a session from current directory's child store */ export function getSyncMessages(sessionId: string, directory?: string) { return getDirectoryState(directory)?.message[sessionId] ?? []