From 65694f2ff3643b0e2bbccc11a8ae05cf8a1b8422 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:07:55 -0400 Subject: [PATCH 1/2] Sync Work model picker from AI status cache after Cursor auth. AgentChatPane kept a stale local auth map that locked the picker Off until remount; listen for shared cache update/invalidate events without force getStatus probes. Co-authored-by: Cursor --- .../components/chat/AgentChatPane.test.tsx | 182 +++++++++++++++++- .../components/chat/AgentChatPane.tsx | 136 ++++++++++--- docs/features/chat/composer-and-ui.md | 12 +- 3 files changed, 300 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 22734deab..ccb4b3fa4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -25,7 +25,12 @@ import type { import { createDynamicCursorCliModelDescriptor, getModelById } from "../../../shared/modelRegistry"; import { invalidateAgentChatSessionListCache } from "../../lib/agentChatSessionListCache"; import { invalidateAgentChatSlashCommandsCache } from "../../lib/agentChatSlashCommandsCache"; -import { getAiStatusCached, invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; +import { + AI_STATUS_CACHE_UPDATED_EVENT, + getAiStatusCached, + invalidateAiDiscoveryCache, + type AiStatusCacheUpdatedEventDetail, +} from "../../lib/aiDiscoveryCache"; import { DRAFT_LAUNCH_JOB_STALE_AFTER_MS } from "../../lib/draftLaunchJobs"; import { invalidateProjectConfigCache } from "../../lib/projectConfigCache"; import { useAppStore } from "../../state/appStore"; @@ -1348,6 +1353,181 @@ describe("AgentChatPane remote startup", () => { expect(window.ade.ai.getStatus).not.toHaveBeenCalled(); }); + it("applies shared AI status cache updates so Cursor unlocks without remount or force refresh", async () => { + const projectRoot = "/tmp/project-under-test"; + const unauthorizedStatus: AiSettingsStatus = { + mode: "subscription", + availableProviders: { + claude: { + binary: { present: false, source: "missing", path: null }, + auth: { ready: false, mode: "none", detail: null }, + }, + codex: true, + cursor: false, + droid: false, + }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + ], + availableModelIds: ["openai/gpt-5.4"], + } as AiSettingsStatus; + const authorizedStatus: AiSettingsStatus = { + ...unauthorizedStatus, + availableProviders: { + ...unauthorizedStatus.availableProviders, + cursor: true, + }, + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + { type: "api-key", provider: "cursor" }, + ], + availableModelIds: ["openai/gpt-5.4", "cursor/auto"], + } as AiSettingsStatus; + + const session = buildSession("session-1", { status: "idle" }); + installAdeMocks({ sessions: [session], aiStatus: unauthorizedStatus }); + useAppStore.setState({ + project: { rootPath: projectRoot } as any, + projectBinding: LOCAL_PROJECT_BINDING, + lanes: [{ + id: session.laneId, + name: "Lane 1", + laneType: "worktree", + branchRef: "refs/heads/lane-1", + worktreePath: `${projectRoot}/lane-1`, + } as any], + selectedLaneId: session.laneId, + }); + seedCursorRuntimeModelCatalog(); + + renderPane(session); + + const trigger = await screen.findByRole("button", { name: /^Select model/ }); + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^Cursor$/i })); + expect(await screen.findByText("Connect Cursor")).toBeTruthy(); + fireEvent.keyDown(document, { key: "Escape" }); + + vi.mocked(window.ade.ai.getStatus).mockClear(); + vi.mocked(window.ade.ai.getStatus).mockResolvedValue(authorizedStatus); + + // Simulate Settings writing the shared cache, then broadcasting UPDATED. + await act(async () => { + await getAiStatusCached({ projectRoot, force: true }); + }); + const forceCallsAfterSharedRefresh = vi.mocked(window.ade.ai.getStatus).mock.calls.filter( + (call) => call[0]?.force === true, + ).length; + expect(forceCallsAfterSharedRefresh).toBeGreaterThanOrEqual(1); + + await act(async () => { + window.dispatchEvent(new CustomEvent( + AI_STATUS_CACHE_UPDATED_EVENT, + { detail: { projectRoot } }, + )); + }); + + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^Cursor$/i })); + + await waitFor(() => { + expect(screen.queryByText("Connect Cursor")).toBeNull(); + }); + expect(screen.queryByRole("button", { name: /Set up Cursor/i })).toBeNull(); + + const forceCallsAfterPickerOpen = vi.mocked(window.ade.ai.getStatus).mock.calls.filter( + (call) => call[0]?.force === true, + ).length; + // Pane must not issue another force probe after the shared-cache writer. + expect(forceCallsAfterPickerOpen).toBe(forceCallsAfterSharedRefresh); + }); + + it("settles an AI status invalidate with a non-force refill, not a force probe", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const projectRoot = "/tmp/project-under-test"; + const unauthorizedStatus: AiSettingsStatus = { + mode: "subscription", + availableProviders: { + claude: { + binary: { present: false, source: "missing", path: null }, + auth: { ready: false, mode: "none", detail: null }, + }, + codex: true, + cursor: false, + droid: false, + }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + ], + availableModelIds: ["openai/gpt-5.4"], + } as AiSettingsStatus; + const authorizedStatus: AiSettingsStatus = { + ...unauthorizedStatus, + availableProviders: { + ...unauthorizedStatus.availableProviders, + cursor: true, + }, + detectedAuth: [ + { type: "cli-subscription", cli: "codex", authenticated: true }, + { type: "api-key", provider: "cursor" }, + ], + availableModelIds: ["openai/gpt-5.4", "cursor/auto"], + } as AiSettingsStatus; + + const session = buildSession("session-1", { status: "idle" }); + installAdeMocks({ sessions: [session], aiStatus: unauthorizedStatus }); + useAppStore.setState({ + project: { rootPath: projectRoot } as any, + projectBinding: LOCAL_PROJECT_BINDING, + lanes: [{ + id: session.laneId, + name: "Lane 1", + laneType: "worktree", + branchRef: "refs/heads/lane-1", + worktreePath: `${projectRoot}/lane-1`, + } as any], + selectedLaneId: session.laneId, + }); + seedCursorRuntimeModelCatalog(); + + renderPane(session); + await screen.findByRole("button", { name: /^Select model/ }); + + vi.mocked(window.ade.ai.getStatus).mockClear(); + vi.mocked(window.ade.ai.getStatus).mockResolvedValue(authorizedStatus); + + await act(async () => { + invalidateAiDiscoveryCache(projectRoot); + await vi.advanceTimersByTimeAsync(300); + }); + + await waitFor(() => { + expect(vi.mocked(window.ade.ai.getStatus)).toHaveBeenCalled(); + }); + const forceCalls = vi.mocked(window.ade.ai.getStatus).mock.calls.filter( + (call) => call[0]?.force === true, + ); + expect(forceCalls).toHaveLength(0); + + const trigger = screen.getByRole("button", { name: /^Select model/ }); + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("tab", { name: /^Cursor$/i })); + await waitFor(() => { + expect(screen.queryByText("Connect Cursor")).toBeNull(); + }); + } finally { + vi.useRealTimers(); + } + }); + it("skips mount-time session delta fetches for remote chats", async () => { const session = buildSession("session-1", { status: "idle" }); installAdeMocks({ sessions: [session] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index e9cf3894a..e8bb69d97 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -253,7 +253,16 @@ import { listAgentChatSessionsCached, } from "../../lib/agentChatSessionListCache"; import { getAgentChatSlashCommandsCached } from "../../lib/agentChatSlashCommandsCache"; -import { getAgentChatModelsCached, getAiStatusCached, invalidateAiDiscoveryCache, peekAiStatusCached } from "../../lib/aiDiscoveryCache"; +import { + AI_STATUS_CACHE_INVALIDATED_EVENT, + AI_STATUS_CACHE_UPDATED_EVENT, + getAgentChatModelsCached, + getAiStatusCached, + invalidateAiDiscoveryCache, + peekAiStatusCached, + type AiStatusCacheInvalidatedEventDetail, + type AiStatusCacheUpdatedEventDetail, +} from "../../lib/aiDiscoveryCache"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { invalidateSessionListCache } from "../../lib/sessionListCache"; import { @@ -3490,17 +3499,14 @@ export function AgentChatPane({ // Seed availableModelIds, aiStatus, and providerConnections synchronously // from the cached AI status (if any). This avoids a "not configured" flash // in the model picker every time a chat pane mounts: the previously-known - // configured set is shown immediately, and `refreshAvailableModels` below - // re-verifies asynchronously and corrects any stale entries. We only block - // sends when the *fresh* status confirms the provider is unauthenticated; - // the seeded value is purely cosmetic for the picker's "Ready / not - // configured" labels. + // configured set is shown immediately. Cache update/invalidation listeners + // and `refreshAvailableModels` keep the seed in sync after Settings auth + // or other shared-cache writers without remounting the pane. const seedAiStatus = useMemo( () => peekAiStatusCached(projectRoot), // projectRoot is stable for the lifetime of a project session — recompute - // only when the user actually switches projects. We intentionally do not - // depend on cache mutations; refreshAvailableModels overrides state once - // the async re-check resolves. + // only when the user actually switches projects. Cache mutations are + // applied via AI_STATUS_CACHE_* listeners below, not this memo. // eslint-disable-next-line react-hooks/exhaustive-deps [projectRoot], ); @@ -5724,6 +5730,32 @@ export function AgentChatPane({ awaitingInput: selectedSessionAwaitingInput, }); + const applyAiStatusSnapshot = useCallback((status: AiStatusSnapshot) => { + setAiStatus(status); + setProviderConnections({ + claude: status.providerConnections?.claude ?? null, + codex: status.providerConnections?.codex ?? null, + cursor: status.providerConnections?.cursor ?? null, + droid: status.providerConnections?.droid ?? null, + }); + const orderedAvailable = orderAvailableModelIds(deriveConfiguredModelIds(status, { includeDroid: true })); + setAvailableModelIds(orderedAvailable); + return orderedAvailable; + }, []); + + const resolveAiStatusRuntimeScope = useCallback(() => { + const runtimePin = selectedSessionIdRef.current + ? chatRuntimePinRef.current + : draftExecutionBindingRef.current; + if (!selectedSessionIdRef.current && draftExecutionBindingRequiredRef.current && !runtimePin) { + return null; + } + return { + runtimePin, + runtimeProjectRoot: runtimePin?.rootPath ?? projectRoot, + }; + }, [projectRoot]); + const refreshAvailableModels = useCallback(async (options?: { force?: boolean }) => { ++availableModelsRefreshSeqRef.current; const selectedModelProvider = modelId.trim() @@ -5735,16 +5767,14 @@ export function AgentChatPane({ selectedSession?.provider === "opencode" || selectedModelProvider === "opencode" ); - const runtimePin = selectedSessionIdRef.current - ? chatRuntimePinRef.current - : draftExecutionBindingRef.current; - if (!selectedSessionIdRef.current && draftExecutionBindingRequiredRef.current && !runtimePin) { + const scope = resolveAiStatusRuntimeScope(); + if (!scope) { setAiStatus(null); setProviderConnections(null); setAvailableModelIds([]); return []; } - const runtimeProjectRoot = runtimePin?.rootPath ?? projectRoot; + const { runtimePin, runtimeProjectRoot } = scope; if (options?.force === true) { invalidateAiDiscoveryCache(runtimeProjectRoot); } @@ -5755,17 +5785,7 @@ export function AgentChatPane({ force: options?.force === true, ...(shouldRefreshOpenCodeInventory ? { refreshOpenCodeInventory: true } : {}), }); - setAiStatus(status); - setProviderConnections({ - claude: status.providerConnections?.claude ?? null, - codex: status.providerConnections?.codex ?? null, - cursor: status.providerConnections?.cursor ?? null, - droid: status.providerConnections?.droid ?? null, - }); - const available = deriveConfiguredModelIds(status, { includeDroid: true }); - const orderedAvailable = orderAvailableModelIds(available); - setAvailableModelIds(orderedAvailable); - return orderedAvailable; + return applyAiStatusSnapshot(status); } catch { setAiStatus(null); setProviderConnections(null); @@ -5819,7 +5839,71 @@ export function AgentChatPane({ setAvailableModelIds([]); return []; } - }, [modelId, projectRoot, selectedSession?.provider, sessionProvider]); + }, [applyAiStatusSnapshot, modelId, resolveAiStatusRuntimeScope, selectedSession?.provider, sessionProvider]); + + useEffect(() => { + let active = true; + let settleTimer: number | null = null; + let stale = false; + let settleGeneration = 0; + + const applyFromPeek = () => { + const scope = resolveAiStatusRuntimeScope(); + if (!scope) return false; + const updated = peekAiStatusCached(scope.runtimeProjectRoot, scope.runtimePin); + if (!updated) return false; + applyAiStatusSnapshot(updated); + stale = false; + return true; + }; + + const onUpdated = (event: Event) => { + const detail = (event as CustomEvent).detail; + const scope = resolveAiStatusRuntimeScope(); + if (!scope) return; + if ((detail?.projectRoot ?? null) !== (scope.runtimeProjectRoot ?? null)) return; + applyFromPeek(); + }; + + const onInvalidated = (event: Event) => { + const detail = (event as CustomEvent).detail; + const scope = resolveAiStatusRuntimeScope(); + if (!scope) return; + if (detail && !detail.allProjects && detail.projectRoot !== (scope.runtimeProjectRoot ?? null)) return; + stale = true; + const generation = ++settleGeneration; + if (settleTimer != null) { + window.clearTimeout(settleTimer); + } + // Wait briefly for a paired UPDATED from another writer (Settings). If + // nothing arrives and this tile is active, refill once without force. + // Prefer peek after the refill so a newer invalidate cannot apply an + // orphaned in-flight status; getAiStatusCached already coalesces IPC. + settleTimer = window.setTimeout(() => { + settleTimer = null; + if (!active || !stale || !isTileActive || generation !== settleGeneration) return; + const settledScope = resolveAiStatusRuntimeScope(); + if (!settledScope) return; + void getAiStatusCached({ + projectRoot: settledScope.runtimeProjectRoot, + pin: settledScope.runtimePin, + }).then(() => { + if (!active || !stale || generation !== settleGeneration) return; + applyFromPeek(); + }).catch(() => undefined); + }, 250); + }; + + window.addEventListener(AI_STATUS_CACHE_UPDATED_EVENT, onUpdated); + window.addEventListener(AI_STATUS_CACHE_INVALIDATED_EVENT, onInvalidated); + return () => { + active = false; + settleGeneration += 1; + if (settleTimer != null) window.clearTimeout(settleTimer); + window.removeEventListener(AI_STATUS_CACHE_UPDATED_EVENT, onUpdated); + window.removeEventListener(AI_STATUS_CACHE_INVALIDATED_EVENT, onInvalidated); + }; + }, [applyAiStatusSnapshot, isTileActive, resolveAiStatusRuntimeScope]); const touchSession = useCallback((sessionId: string | null | undefined, touchedAt = new Date().toISOString()) => { if (!sessionId) return; diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 7702d3f84..8db39972e 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -717,9 +717,15 @@ provider availability from only `availableModelIds`. The ids are a discovered inventory and can lag authentication; for CLI-backed providers, a positive provider-auth status is enough to expose registry models. The full status read starts only while picker content is mounted, uses the shared project cache, and -does not poll. Local and cross-machine fork handoffs additionally apply a -same-provider descriptor filter, so they can show newly registered models from -that provider without allowing a cross-provider fork. +does not poll. When a caller **does** pass `providerAuthStatus` (Work chat's +`AgentChatPane`), it opts the picker out of the live auth hook — so that caller +must itself listen for `ade:ai-status-cache-updated` / `invalidated`, apply +`peekAiStatusCached` on update, and at most settle an orphan invalidate with one +coalesced non-force `getAiStatusCached` for the active tile. Otherwise Settings +auth looks Connected while the Work picker stays Off until remount. Local and +cross-machine fork handoffs additionally apply a same-provider descriptor +filter, so they can show newly registered models from that provider without +allowing a cross-provider fork. ### Attachment handling From 99eb64936b9642e9c6a0696df9f41398af31ce41 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:21:59 -0400 Subject: [PATCH 2/2] Refresh OpenCode inventory on AI status invalidate settle. Keep the Work pane settle refill aligned with refreshAvailableModels so an active OpenCode session does not re-apply a stale inventory snapshot. Co-authored-by: Cursor --- .../components/chat/AgentChatPane.test.tsx | 68 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 26 +++++-- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index ccb4b3fa4..8dcaa5263 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1528,6 +1528,74 @@ describe("AgentChatPane remote startup", () => { } }); + it("settles an OpenCode AI status invalidate with refreshOpenCodeInventory", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const projectRoot = "/tmp/project-under-test"; + const openCodeStatus: AiSettingsStatus = { + mode: "subscription", + availableProviders: { + claude: { + binary: { present: false, source: "missing", path: null }, + auth: { ready: false, mode: "none", detail: null }, + }, + codex: false, + cursor: false, + droid: false, + }, + models: { claude: [], codex: [], cursor: [], droid: [] }, + features: [], + detectedAuth: [], + availableModelIds: ["opencode/openai/gpt-5.4"], + opencodeBinaryInstalled: true, + } as AiSettingsStatus; + + const session = buildSession("session-opencode-1", { + status: "idle", + provider: "opencode", + modelId: "opencode/openai/gpt-5.4", + }); + installAdeMocks({ sessions: [session], aiStatus: openCodeStatus }); + useAppStore.setState({ + project: { rootPath: projectRoot } as any, + projectBinding: LOCAL_PROJECT_BINDING, + lanes: [{ + id: session.laneId, + name: "Lane 1", + laneType: "worktree", + branchRef: "refs/heads/lane-1", + worktreePath: `${projectRoot}/lane-1`, + } as any], + selectedLaneId: session.laneId, + }); + + renderPane(session); + await screen.findByRole("button", { name: /^Select model/ }); + + vi.mocked(window.ade.ai.getStatus).mockClear(); + vi.mocked(window.ade.ai.getStatus).mockResolvedValue(openCodeStatus); + + await act(async () => { + invalidateAiDiscoveryCache(projectRoot); + await vi.advanceTimersByTimeAsync(300); + }); + + await waitFor(() => { + expect(vi.mocked(window.ade.ai.getStatus)).toHaveBeenCalledWith( + expect.objectContaining({ + refreshOpenCodeInventory: true, + }), + ); + }); + const forceCalls = vi.mocked(window.ade.ai.getStatus).mock.calls.filter( + (call) => call[0]?.force === true, + ); + expect(forceCalls).toHaveLength(0); + } finally { + vi.useRealTimers(); + } + }); + it("skips mount-time session delta fetches for remote chats", async () => { const session = buildSession("session-1", { status: "idle" }); installAdeMocks({ sessions: [session] }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index e8bb69d97..75a321232 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -5756,17 +5756,20 @@ export function AgentChatPane({ }; }, [projectRoot]); - const refreshAvailableModels = useCallback(async (options?: { force?: boolean }) => { - ++availableModelsRefreshSeqRef.current; + const shouldRefreshOpenCodeInventoryForStatus = useCallback(() => { const selectedModelProvider = modelId.trim() ? resolveChatRuntimeProvider(resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId)) : null; - const shouldRefreshOpenCodeInventory = - sessionProvider === "opencode" + return sessionProvider === "opencode" && ( selectedSession?.provider === "opencode" || selectedModelProvider === "opencode" ); + }, [modelId, selectedSession?.provider, sessionProvider]); + + const refreshAvailableModels = useCallback(async (options?: { force?: boolean }) => { + ++availableModelsRefreshSeqRef.current; + const shouldRefreshOpenCodeInventory = shouldRefreshOpenCodeInventoryForStatus(); const scope = resolveAiStatusRuntimeScope(); if (!scope) { setAiStatus(null); @@ -5839,7 +5842,11 @@ export function AgentChatPane({ setAvailableModelIds([]); return []; } - }, [applyAiStatusSnapshot, modelId, resolveAiStatusRuntimeScope, selectedSession?.provider, sessionProvider]); + }, [ + applyAiStatusSnapshot, + resolveAiStatusRuntimeScope, + shouldRefreshOpenCodeInventoryForStatus, + ]); useEffect(() => { let active = true; @@ -5884,9 +5891,11 @@ export function AgentChatPane({ if (!active || !stale || !isTileActive || generation !== settleGeneration) return; const settledScope = resolveAiStatusRuntimeScope(); if (!settledScope) return; + const shouldRefreshOpenCodeInventory = shouldRefreshOpenCodeInventoryForStatus(); void getAiStatusCached({ projectRoot: settledScope.runtimeProjectRoot, pin: settledScope.runtimePin, + ...(shouldRefreshOpenCodeInventory ? { refreshOpenCodeInventory: true } : {}), }).then(() => { if (!active || !stale || generation !== settleGeneration) return; applyFromPeek(); @@ -5903,7 +5912,12 @@ export function AgentChatPane({ window.removeEventListener(AI_STATUS_CACHE_UPDATED_EVENT, onUpdated); window.removeEventListener(AI_STATUS_CACHE_INVALIDATED_EVENT, onInvalidated); }; - }, [applyAiStatusSnapshot, isTileActive, resolveAiStatusRuntimeScope]); + }, [ + applyAiStatusSnapshot, + isTileActive, + resolveAiStatusRuntimeScope, + shouldRefreshOpenCodeInventoryForStatus, + ]); const touchSession = useCallback((sessionId: string | null | undefined, touchedAt = new Date().toISOString()) => { if (!sessionId) return;