diff --git a/packages/ui/src/sync/__tests__/eviction.test.ts b/packages/ui/src/sync/__tests__/eviction.test.ts new file mode 100644 index 00000000..8dea1b6c --- /dev/null +++ b/packages/ui/src/sync/__tests__/eviction.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test" +import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" +import { + canDisposeDirectory, + hasPendingBlockingRequests, + pickDirectoriesToEvict, +} from "../eviction" +import { INITIAL_STATE, type DirState, type State } from "../types" + +const DAY_MS = 24 * 60 * 60 * 1000 + +function buildState(overrides: Partial = {}): State { + return { + ...INITIAL_STATE, + question: {}, + permission: {}, + ...overrides, + } +} + +function buildQuestion(overrides: Partial = {}): QuestionRequest { + return { + id: "que_1", + sessionID: "ses_1", + questions: [{ question: "Continue?", header: "Q", options: [{ label: "Yes", description: "" }] }], + ...overrides, + } as QuestionRequest +} + +function buildPermission(overrides: Partial = {}): PermissionRequest { + return { + id: "perm_1", + sessionID: "ses_1", + permission: "bash", + patterns: [], + metadata: {}, + always: [], + ...overrides, + } as PermissionRequest +} + +describe("hasPendingBlockingRequests", () => { + test("returns false on undefined or empty state", () => { + expect(hasPendingBlockingRequests(undefined)).toBe(false) + expect(hasPendingBlockingRequests(buildState())).toBe(false) + }) + + test("returns true when at least one session has a pending question", () => { + const state = buildState({ question: { ses_a: [buildQuestion()] } }) + expect(hasPendingBlockingRequests(state)).toBe(true) + }) + + test("returns true when at least one session has a pending permission", () => { + const state = buildState({ permission: { ses_a: [buildPermission()] } }) + expect(hasPendingBlockingRequests(state)).toBe(true) + }) + + test("treats empty arrays under a session key as no pending work", () => { + const state = buildState({ question: { ses_a: [] }, permission: { ses_b: [] } }) + expect(hasPendingBlockingRequests(state)).toBe(false) + }) +}) + +describe("pickDirectoriesToEvict", () => { + test("does not evict an idle directory that has a pending question", () => { + const stores = ["/idle-with-question", "/idle-empty"] + const state = new Map([ + ["/idle-with-question", { lastAccessAt: 0 }], + ["/idle-empty", { lastAccessAt: 0 }], + ]) + const list = pickDirectoriesToEvict({ + stores, + state, + pins: new Set(), + max: 30, + ttl: 1000, + now: DAY_MS, + hasPendingBlockingRequests: (dir) => dir === "/idle-with-question", + }) + expect(list).toEqual(["/idle-empty"]) + }) + + test("never includes a directory with pending blocking requests even under overflow pressure", () => { + const stores = ["/active", "/overflow-with-permission", "/old-empty"] + const state = new Map([ + ["/active", { lastAccessAt: DAY_MS }], + ["/overflow-with-permission", { lastAccessAt: DAY_MS - 100_000 }], + ["/old-empty", { lastAccessAt: 0 }], + ]) + const list = pickDirectoriesToEvict({ + stores, + state, + pins: new Set(), + max: 1, + ttl: 60_000, + now: DAY_MS, + hasPendingBlockingRequests: (dir) => dir === "/overflow-with-permission", + }) + expect(list).not.toContain("/overflow-with-permission") + expect(list).toContain("/old-empty") + }) + + test("falls back to legacy behavior when no predicate is provided", () => { + const stores = ["/idle"] + const state = new Map([["/idle", { lastAccessAt: 0 }]]) + const list = pickDirectoriesToEvict({ + stores, + state, + pins: new Set(), + max: 30, + ttl: 1000, + now: DAY_MS, + }) + expect(list).toEqual(["/idle"]) + }) +}) + +describe("canDisposeDirectory", () => { + const baseInput = { + directory: "/repo", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: false, + hasPendingBlockingRequests: false, + } + + test("refuses to dispose a directory holding pending blocking requests", () => { + expect(canDisposeDirectory({ ...baseInput, hasPendingBlockingRequests: true })).toBe(false) + }) + + test("permits disposal when no blocking requests are pending", () => { + expect(canDisposeDirectory(baseInput)).toBe(true) + }) +}) diff --git a/packages/ui/src/sync/__tests__/session-switch-resync.test.ts b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts new file mode 100644 index 00000000..2c2008de --- /dev/null +++ b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test, beforeEach, mock } from "bun:test" +import { create, type StoreApi } from "zustand" +import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" + +const listPendingQuestionsCalls: Array<{ directories?: Array }> = [] +const listPendingPermissionsCalls: Array<{ directories?: Array }> = [] +let pendingQuestionsResponse: QuestionRequest[] = [] +let pendingPermissionsResponse: PermissionRequest[] = [] + +mock.module("@/lib/opencode/client", () => ({ + opencodeClient: { + listPendingQuestions: mock(async (opts?: { directories?: Array }) => { + listPendingQuestionsCalls.push(opts ?? {}) + return pendingQuestionsResponse + }), + listPendingPermissions: mock(async (opts?: { directories?: Array }) => { + listPendingPermissionsCalls.push(opts ?? {}) + return pendingPermissionsResponse + }), + getDirectory: () => "/repo", + getScopedSdkClient: () => ({}), + setDirectory: () => undefined, + }, +})) + +mock.module("@/stores/permissionStore", () => ({ + usePermissionStore: { + getState: () => ({ isSessionAutoAccepting: () => false }), + }, +})) + +mock.module("@/stores/useConfigStore", () => ({ + useConfigStore: { + getState: () => ({ isConnected: true, hasEverConnected: true }), + setState: () => undefined, + }, +})) + +mock.module("@/stores/useTodosPersistStore", () => ({ + useTodosPersistStore: { getState: () => ({}) }, +})) + +mock.module("@/components/ui", () => ({ + toast: { info: () => undefined, error: () => undefined, success: () => undefined }, +})) + +import { INITIAL_STATE, type State } from "../types" +import type { DirectoryStore } from "../child-store" +import { resyncBlockingRequestsForDirectory } from "../sync-context" + +function buildQuestion(overrides: Partial = {}): QuestionRequest { + return { + id: "que_1", + sessionID: "ses_a", + questions: [{ question: "Continue?", header: "Q", options: [{ label: "Yes", description: "" }] }], + ...overrides, + } as QuestionRequest +} + +function buildPermission(overrides: Partial = {}): PermissionRequest { + return { + id: "perm_1", + sessionID: "ses_a", + permission: "bash", + patterns: [], + metadata: {}, + always: [], + ...overrides, + } as PermissionRequest +} + +function createDirectoryStore(initial: Partial): StoreApi { + return create()((set) => ({ + ...INITIAL_STATE, + ...initial, + session: initial.session ?? [{ id: "ses_a", title: "ses_a", time: { created: 1, updated: 1 }, version: "1" } as State["session"][number]], + patch: (partial) => set(partial), + replace: (next) => set(next), + })) +} + +describe("resyncBlockingRequestsForDirectory", () => { + beforeEach(() => { + listPendingQuestionsCalls.length = 0 + listPendingPermissionsCalls.length = 0 + pendingQuestionsResponse = [] + pendingPermissionsResponse = [] + }) + + test("calls listPendingQuestions and listPendingPermissions exactly once for the directory", async () => { + const store = createDirectoryStore({}) + pendingQuestionsResponse = [buildQuestion()] + pendingPermissionsResponse = [buildPermission()] + + await resyncBlockingRequestsForDirectory("/repo", store) + + expect(listPendingQuestionsCalls).toHaveLength(1) + expect(listPendingQuestionsCalls[0]).toEqual({ directories: ["/repo"] }) + expect(listPendingPermissionsCalls).toHaveLength(1) + expect(listPendingPermissionsCalls[0]).toEqual({ directories: ["/repo"] }) + }) + + test("merges newly fetched questions/permissions into the directory store", async () => { + const store = createDirectoryStore({}) + pendingQuestionsResponse = [buildQuestion()] + pendingPermissionsResponse = [buildPermission()] + + await resyncBlockingRequestsForDirectory("/repo", store) + + expect(store.getState().question["ses_a"]).toHaveLength(1) + expect(store.getState().question["ses_a"]?.[0]?.id).toBe("que_1") + expect(store.getState().permission["ses_a"]).toHaveLength(1) + expect(store.getState().permission["ses_a"]?.[0]?.id).toBe("perm_1") + }) + + test("preserves an in-flight SSE-delivered question whose signature changed during the fetch", async () => { + const store = createDirectoryStore({ + question: { ses_a: [{ ...buildQuestion(), id: "que_initial" }] }, + }) + pendingQuestionsResponse = [] + + const promise = resyncBlockingRequestsForDirectory("/repo", store) + store.setState({ + question: { ses_a: [{ ...buildQuestion(), id: "que_sse_arrived" }] }, + }) + await promise + + expect(store.getState().question["ses_a"]).toHaveLength(1) + expect(store.getState().question["ses_a"]?.[0]?.id).toBe("que_sse_arrived") + }) + + test("clears stale entries when API returns no pending requests and signature unchanged", async () => { + const store = createDirectoryStore({ + question: { ses_a: [{ ...buildQuestion(), id: "que_stale" }] }, + }) + pendingQuestionsResponse = [] + pendingPermissionsResponse = [] + + await resyncBlockingRequestsForDirectory("/repo", store) + + expect(store.getState().question["ses_a"]).toEqual(undefined) + }) + + test("ignores questions for sessions the directory does not know about", async () => { + const store = createDirectoryStore({}) + pendingQuestionsResponse = [{ ...buildQuestion(), sessionID: "ses_unknown" }] + + await resyncBlockingRequestsForDirectory("/repo", store) + + expect(store.getState().question["ses_unknown"]).toEqual(undefined) + }) + + test("returns early without fetching when no candidate sessions are known", async () => { + const store = createDirectoryStore({ session: [] }) + await resyncBlockingRequestsForDirectory("/repo", store) + expect(listPendingQuestionsCalls).toHaveLength(0) + expect(listPendingPermissionsCalls).toHaveLength(0) + }) +}) diff --git a/packages/ui/src/sync/child-store.ts b/packages/ui/src/sync/child-store.ts index 3d32d49b..cd4fa437 100644 --- a/packages/ui/src/sync/child-store.ts +++ b/packages/ui/src/sync/child-store.ts @@ -1,7 +1,7 @@ import { create, type StoreApi } from "zustand" import type { DirState, State } from "./types" import { INITIAL_STATE, MAX_DIR_STORES, DIR_IDLE_TTL_MS } from "./types" -import { pickDirectoriesToEvict, canDisposeDirectory } from "./eviction" +import { pickDirectoriesToEvict, canDisposeDirectory, hasPendingBlockingRequests } from "./eviction" import { readDirCache, persistVcs, persistProjectMeta, persistIcon } from "./persist-cache" export type DirectoryStore = State & { @@ -123,6 +123,7 @@ export class ChildStoreManager { pinned: this.pinned(directory), booting: this.isBooting?.(directory) ?? false, loadingSessions: this.isLoadingSessions?.(directory) ?? false, + hasPendingBlockingRequests: this.hasPendingBlockingRequestsForDirectory(directory), }) ) { return false @@ -150,12 +151,17 @@ export class ChildStoreManager { max: MAX_DIR_STORES, ttl: DIR_IDLE_TTL_MS, now: Date.now(), + hasPendingBlockingRequests: (dir) => this.hasPendingBlockingRequestsForDirectory(dir), }).filter((d) => d !== skip) for (const directory of list) { this.disposeDirectory(directory) } } + hasPendingBlockingRequestsForDirectory(directory: string): boolean { + return hasPendingBlockingRequests(this.children.get(directory)?.getState()) + } + /** Apply a state mutation to a directory's store */ update(directory: string, fn: (state: State) => Partial) { const store = this.children.get(directory) diff --git a/packages/ui/src/sync/eviction.ts b/packages/ui/src/sync/eviction.ts index 676a6ee1..723149a4 100644 --- a/packages/ui/src/sync/eviction.ts +++ b/packages/ui/src/sync/eviction.ts @@ -1,10 +1,34 @@ -import type { DisposeCheck, EvictPlan } from "./types" +import type { DisposeCheck, EvictPlan, State } from "./types" + +/** + * Returns true when the directory's child store holds at least one pending + * blocking request — a question awaiting an answer or a permission awaiting + * approval. Such directories must never be evicted, otherwise the SSE-routed + * request data is lost and the user can never satisfy the agent. + * + * Tracks the same `state.question` / `state.permission` shape that + * {@link bootstrapDirectory} re-hydrates on a fresh `ensureChild` call — + * an empty record key (e.g. after a `question.replied` event clears the + * array) is treated as "no pending requests" so a fully resolved directory + * remains a normal eviction candidate. + */ +export function hasPendingBlockingRequests(state: State | undefined): boolean { + if (!state) return false + for (const list of Object.values(state.question ?? {})) { + if (list && list.length > 0) return true + } + for (const list of Object.values(state.permission ?? {})) { + if (list && list.length > 0) return true + } + return false +} export function pickDirectoriesToEvict(input: EvictPlan) { const overflow = Math.max(0, input.stores.length - input.max) let pendingOverflow = overflow const sorted = input.stores .filter((dir) => !input.pins.has(dir)) + .filter((dir) => !input.hasPendingBlockingRequests?.(dir)) .slice() .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) const output: string[] = [] @@ -24,5 +48,6 @@ export function canDisposeDirectory(input: DisposeCheck) { if (input.pinned) return false if (input.booting) return false if (input.loadingSessions) return false + if (input.hasPendingBlockingRequests) return false return true } diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 22d34160..15fae35a 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -690,6 +690,169 @@ const updateRoutingIndexFromEvent = ( } } +/** + * Re-fetch pending questions and permissions for a directory and merge them + * into the directory's child store, preserving any in-flight SSE updates that + * arrived while the request was pending. Shared between reconnect resync and + * session-switch resync (the latter is a belt-and-suspenders backstop for any + * code path that drops a `question.asked` / `permission.requested` event — + * directory-eviction rehydration, cross-directory session switches, transport + * fallback gaps, etc.). When `candidateSessionIds` is omitted, every session + * known to the directory store is treated as a candidate. + */ +export async function resyncBlockingRequestsForDirectory( + directory: string, + store: StoreApi, + candidateSessionIds?: string[], +) { + const before = store.getState() + const knownSessionIds = new Set([ + ...before.session.map((session) => session.id), + ...Object.keys(before.message ?? {}), + ...Object.keys(before.session_status ?? {}), + ...Object.keys(before.question ?? {}), + ...Object.keys(before.permission ?? {}), + ]) + const candidates = candidateSessionIds ?? Array.from(knownSessionIds) + if (candidates.length === 0) return + + // Re-fetch pending questions — they may have been asked during an SSE gap, + // a directory-eviction window, or a session-switch that bypassed bootstrap. + try { + const beforeSignatures = new Map( + candidates.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]), + ) + const pendingQuestions = await opencodeClient.listPendingQuestions({ directories: [directory] }) + const grouped: Record = {} + for (const q of pendingQuestions) { + if (!q?.id || !q.sessionID) continue + if (!knownSessionIds.has(q.sessionID)) continue + const list = grouped[q.sessionID] + if (list) list.push(q) + else grouped[q.sessionID] = [q] + } + for (const sessionId of Object.keys(grouped)) { + grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + for (const [sessionId, questions] of Object.entries(grouped)) { + const knownIds = new Set((before.question[sessionId] ?? []).map((item) => item.id)) + const isViewed = isViewedInCurrentSession(directory, sessionId) + if (isViewed) continue + for (const question of questions) { + if (knownIds.has(question.id)) continue + const toastKey = getQuestionToastKey(sessionId, question.id) + if (!toastKey || pendingQuestionToastIds.has(toastKey)) continue + pendingQuestionToastIds.add(toastKey) + const firstQuestion = question.questions?.[0] + const title = firstQuestion?.header?.trim() || "Input needed" + const description = firstQuestion?.question?.trim() || "Agent is waiting for your response" + toast.info(title, { + id: `question-${toastKey}`, + description, + action: { + label: "Open session", + onClick: () => openSessionFromToast(sessionId, directory), + }, + }) + } + } + + store.setState((state: DirectoryStore) => { + const merged = { ...state.question } + for (const [sessionId, questions] of Object.entries(grouped)) { + merged[sessionId] = questions + } + for (const sessionId of candidates) { + if (grouped[sessionId]) continue + const beforeSignature = beforeSignatures.get(sessionId) ?? "" + const currentSignature = requestSignature(state.question[sessionId]) + if (currentSignature !== beforeSignature) continue + delete merged[sessionId] + } + return { question: merged } + }) + } catch { + // Non-fatal: question resync best-effort + } + + // Re-fetch pending permissions — same rationale as questions. + try { + const beforeSignatures = new Map( + candidates.map((sessionId) => [sessionId, requestSignature(before.permission[sessionId])]), + ) + const pendingPermissions = await opencodeClient.listPendingPermissions({ directories: [directory] }) + const grouped: Record = {} + for (const permission of pendingPermissions) { + if (!permission?.id || !permission.sessionID) continue + if (!knownSessionIds.has(permission.sessionID)) continue + const list = grouped[permission.sessionID] + if (list) list.push(permission) + else grouped[permission.sessionID] = [permission] + } + for (const sessionId of Object.keys(grouped)) { + grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + const permissionStore = usePermissionStore.getState() + const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId)) + + if (autoAcceptingSessionIds.length > 0) { + await Promise.all( + autoAcceptingSessionIds.flatMap((sessionId) => + (grouped[sessionId] ?? []).map((permission) => + sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined), + ), + ), + ) + + for (const sessionId of autoAcceptingSessionIds) { + delete grouped[sessionId] + } + } + + for (const [sessionId, permissions] of Object.entries(grouped)) { + const knownIds = new Set((before.permission[sessionId] ?? []).map((item) => item.id)) + const isViewed = isViewedInCurrentSession(directory, sessionId) + if (isViewed) continue + for (const permission of permissions) { + if (knownIds.has(permission.id)) continue + const toastKey = getPermissionToastKey(sessionId, permission.id) + if (!toastKey || pendingPermissionToastIds.has(toastKey)) continue + pendingPermissionToastIds.add(toastKey) + const description = typeof permission.permission === "string" && permission.permission.trim().length > 0 + ? permission.permission + : "Agent needs your approval" + toast.info("Permission needed", { + id: `permission-${toastKey}`, + description, + action: { + label: "Open session", + onClick: () => openSessionFromToast(sessionId, directory), + }, + }) + } + } + + store.setState((state: DirectoryStore) => { + const merged = { ...state.permission } + for (const [sessionId, permissions] of Object.entries(grouped)) { + merged[sessionId] = permissions + } + for (const sessionId of candidates) { + if (grouped[sessionId]) continue + const beforeSignature = beforeSignatures.get(sessionId) ?? "" + const currentSignature = requestSignature(state.permission[sessionId]) + if (currentSignature !== beforeSignature) continue + delete merged[sessionId] + } + return { permission: merged } + }) + } catch { + // Non-fatal: permission resync best-effort + } +} + async function resyncDirectoryAfterReconnect( directory: string, store: StoreApi, @@ -805,161 +968,7 @@ async function resyncDirectoryAfterReconnect( setIndexedSessionMessages(routingIndex, sessionId, directory, nextMessages) })) - // Re-fetch pending questions on reconnect — they may have been asked - // during the SSE disconnection window and will not arrive via SSE events. - // Overwrite sessions covered by API response, and clear reconnect candidates - // that remain unchanged during the request but are absent from the response. - // If SSE changed a session while the request was in-flight, keep that data. - try { - const before = store.getState() - const knownSessionIds = new Set([ - ...before.session.map((session) => session.id), - ...Object.keys(before.message ?? {}), - ...Object.keys(before.session_status ?? {}), - ...Object.keys(before.question ?? {}), - ...Object.keys(before.permission ?? {}), - ]) - const beforeSignatures = new Map( - candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.question[sessionId])]), - ) - const pendingQuestions = await opencodeClient.listPendingQuestions({ directories: [directory] }) - const grouped: Record = {} - for (const q of pendingQuestions) { - if (!q?.id || !q.sessionID) continue - if (!knownSessionIds.has(q.sessionID)) continue - const list = grouped[q.sessionID] - if (list) list.push(q) - else grouped[q.sessionID] = [q] - } - // Sort each group by id for binary-search compatibility - for (const sessionId of Object.keys(grouped)) { - grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - } - - for (const [sessionId, questions] of Object.entries(grouped)) { - const knownIds = new Set((before.question[sessionId] ?? []).map((item) => item.id)) - const isViewed = isViewedInCurrentSession(directory, sessionId) - if (isViewed) continue - for (const question of questions) { - if (knownIds.has(question.id)) continue - const toastKey = getQuestionToastKey(sessionId, question.id) - if (!toastKey || pendingQuestionToastIds.has(toastKey)) continue - pendingQuestionToastIds.add(toastKey) - const firstQuestion = question.questions?.[0] - const title = firstQuestion?.header?.trim() || "Input needed" - const description = firstQuestion?.question?.trim() || "Agent is waiting for your response" - toast.info(title, { - id: `question-${toastKey}`, - description, - action: { - label: "Open session", - onClick: () => openSessionFromToast(sessionId, directory), - }, - }) - } - } - - store.setState((state: DirectoryStore) => { - const merged = { ...state.question } - for (const [sessionId, questions] of Object.entries(grouped)) { - merged[sessionId] = questions - } - for (const sessionId of candidateSessionIds) { - if (grouped[sessionId]) continue - const beforeSignature = beforeSignatures.get(sessionId) ?? "" - const currentSignature = requestSignature(state.question[sessionId]) - if (currentSignature !== beforeSignature) continue - delete merged[sessionId] - } - return { question: merged } - }) - } catch { - // Non-fatal: question resync best-effort - } - - // Re-fetch pending permissions on reconnect — same rationale as questions. - try { - const before = store.getState() - const knownSessionIds = new Set([ - ...before.session.map((session) => session.id), - ...Object.keys(before.message ?? {}), - ...Object.keys(before.session_status ?? {}), - ...Object.keys(before.question ?? {}), - ...Object.keys(before.permission ?? {}), - ]) - const beforeSignatures = new Map( - candidateSessionIds.map((sessionId) => [sessionId, requestSignature(before.permission[sessionId])]), - ) - const pendingPermissions = await opencodeClient.listPendingPermissions({ directories: [directory] }) - const grouped: Record = {} - for (const permission of pendingPermissions) { - if (!permission?.id || !permission.sessionID) continue - if (!knownSessionIds.has(permission.sessionID)) continue - const list = grouped[permission.sessionID] - if (list) list.push(permission) - else grouped[permission.sessionID] = [permission] - } - for (const sessionId of Object.keys(grouped)) { - grouped[sessionId].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - } - - const permissionStore = usePermissionStore.getState() - const autoAcceptingSessionIds = Object.keys(grouped).filter((sessionId) => permissionStore.isSessionAutoAccepting(sessionId)) - - if (autoAcceptingSessionIds.length > 0) { - await Promise.all( - autoAcceptingSessionIds.flatMap((sessionId) => - (grouped[sessionId] ?? []).map((permission) => - sessionActions.respondToPermission(permission.sessionID, permission.id, "once").catch(() => undefined), - ), - ), - ) - - for (const sessionId of autoAcceptingSessionIds) { - delete grouped[sessionId] - } - } - - for (const [sessionId, permissions] of Object.entries(grouped)) { - const knownIds = new Set((before.permission[sessionId] ?? []).map((item) => item.id)) - const isViewed = isViewedInCurrentSession(directory, sessionId) - if (isViewed) continue - for (const permission of permissions) { - if (knownIds.has(permission.id)) continue - const toastKey = getPermissionToastKey(sessionId, permission.id) - if (!toastKey || pendingPermissionToastIds.has(toastKey)) continue - pendingPermissionToastIds.add(toastKey) - const description = typeof permission.permission === "string" && permission.permission.trim().length > 0 - ? permission.permission - : "Agent needs your approval" - toast.info("Permission needed", { - id: `permission-${toastKey}`, - description, - action: { - label: "Open session", - onClick: () => openSessionFromToast(sessionId, directory), - }, - }) - } - } - - store.setState((state: DirectoryStore) => { - const merged = { ...state.permission } - for (const [sessionId, permissions] of Object.entries(grouped)) { - merged[sessionId] = permissions - } - for (const sessionId of candidateSessionIds) { - if (grouped[sessionId]) continue - const beforeSignature = beforeSignatures.get(sessionId) ?? "" - const currentSignature = requestSignature(state.permission[sessionId]) - if (currentSignature !== beforeSignature) continue - delete merged[sessionId] - } - return { permission: merged } - }) - } catch { - // Non-fatal: permission resync best-effort - } + await resyncBlockingRequestsForDirectory(directory, store, candidateSessionIds) ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) } @@ -1469,6 +1478,46 @@ export function SyncProvider(props: { return unsubscribe }, [props.directory, childStores]) + // Re-fetch pending questions/permissions on session-switch. + // PR #909 only re-fetches on SSE reconnect, leaving an event-drop gap when + // switching sessions within the same socket — the question.asked event may + // have arrived while a different session was active and the directory store + // was evicted, or the user may navigate back to a directory whose store was + // rebuilt after eviction. A 250ms debounce coalesces rapid switches. + useEffect(() => { + let cancelled = false + let timer: ReturnType | null = null + let lastSessionId: string | null = null + let unsub: (() => void) | undefined + void import("./session-ui-store") + .then(({ useSessionUIStore }) => { + if (cancelled) return + lastSessionId = useSessionUIStore.getState().currentSessionId + unsub = useSessionUIStore.subscribe((state) => { + const nextSessionId = state.currentSessionId + if (nextSessionId === lastSessionId) return + lastSessionId = nextSessionId + if (!nextSessionId) return + const sessionDirectory = state.getDirectoryForSession(nextSessionId) + ?? opencodeClient.getDirectory() + ?? props.directory + if (!sessionDirectory) return + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + const currentStore = childStores.getChild(sessionDirectory) + if (!currentStore) return + void resyncBlockingRequestsForDirectory(sessionDirectory, currentStore).catch(() => undefined) + }, 250) + }) + }) + .catch(() => undefined) + return () => { + cancelled = true + if (timer) clearTimeout(timer) + unsub?.() + } + }, [props.directory, childStores]) + return {props.children} } diff --git a/packages/ui/src/sync/types.ts b/packages/ui/src/sync/types.ts index 402254e2..17c1d5ec 100644 --- a/packages/ui/src/sync/types.ts +++ b/packages/ui/src/sync/types.ts @@ -93,6 +93,7 @@ export type EvictPlan = { max: number ttl: number now: number + hasPendingBlockingRequests?: (directory: string) => boolean } export type DisposeCheck = { @@ -101,6 +102,7 @@ export type DisposeCheck = { pinned: boolean booting: boolean loadingSessions: boolean + hasPendingBlockingRequests: boolean } export type ChildOptions = {