Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 181 additions & 1 deletion apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<AiStatusCacheUpdatedEventDetail>(
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] });
Expand Down
136 changes: 110 additions & 26 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<AiStatusSnapshot | null>(
() => 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],
);
Expand Down Expand Up @@ -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()
Expand All @@ -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);
}
Expand All @@ -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);
Expand Down Expand Up @@ -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<AiStatusCacheUpdatedEventDetail>).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<AiStatusCacheInvalidatedEventDetail>).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);
Comment thread
arul28 marked this conversation as resolved.
}, 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;
Expand Down
Loading
Loading