Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 4 additions & 1 deletion apps/desktop/src/preload/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1757,7 +1757,10 @@ declare global {
args: AgentChatModelsArgs,
pin?: OpenProjectBinding | null,
) => Promise<AgentChatModelInfo[]>;
modelCatalog: (args?: AgentChatModelCatalogArgs) => Promise<AgentChatModelCatalog>;
modelCatalog: (
args?: AgentChatModelCatalogArgs,
pin?: OpenProjectBinding | null,
) => Promise<AgentChatModelCatalog>;
archive: (
args: AgentChatArchiveArgs,
pin?: OpenProjectBinding | null,
Expand Down
78 changes: 78 additions & 0 deletions apps/desktop/src/preload/preload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,84 @@ describe("preload OAuth bridge", () => {
expect(invoke).not.toHaveBeenCalledWith(IPC.appGetImageDataUrl, expect.anything());
});

// The model catalog enumerates the SERVING machine's ollama/LM Studio
// endpoints, its installed cursor-agent and its opencode inventory. A Work
// tab unions chats from every machine, so a composer for a chat on another
// machine has to read that machine's catalog — reading the bound machine's
// is how the prompt box came to offer models the target could not run.
it("routes the model catalog through the bound runtime, or an explicit chat pin", async () => {
const binding = {
kind: "remote",
key: "remote:target-1:project-1",
targetId: "target-1",
runtimeName: "Remote",
projectId: "project-1",
rootPath: "/remote/project",
displayName: "Project",
};
const chatRuntimePin = {
kind: "remote",
key: "remote:target-2:project-2",
targetId: "target-2",
runtimeName: "Studio",
projectId: "project-2",
rootPath: "/remote/chat-project",
displayName: "Chat project",
};
const invoke = vi.fn(async (channel: string, payload?: unknown) => {
if (channel === IPC.appGetWindowSession) {
return { windowId: 1, project: null, binding };
}
if (channel === IPC.remoteRuntimeCallAction) {
const id = (payload as { id?: string } | undefined)?.id;
return {
ok: true,
result: {
groups: [{ key: id === "target-2" ? "ollama" : "lmstudio", label: id, providers: [] }],
fetchedAt: "2026-05-18T00:00:00.000Z",
},
statusHints: {},
};
}
throw new Error(`unexpected IPC: ${channel}`);
});
const exposeInMainWorld = vi.fn((_name: string, value: unknown) => {
(globalThis as any).__adeBridge = value;
});
vi.doMock("electron", () => ({
contextBridge: { exposeInMainWorld },
ipcRenderer: { invoke, on: vi.fn(), removeListener: vi.fn() },
webFrame: { getZoomLevel: vi.fn(() => 0), setZoomLevel: vi.fn(), getZoomFactor: vi.fn(() => 1) },
}));

await import("./preload");
const bridge = (globalThis as any).__adeBridge;

// No pin: unchanged behaviour — the window's bound runtime answers.
await expect(bridge.agentChat.modelCatalog({ mode: "cached" }))
.resolves.toMatchObject({ groups: [{ key: "lmstudio" }] });
expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, {
id: "target-1",
projectId: "project-1",
request: { domain: "chat", action: "modelCatalog", args: { mode: "cached" } },
});
invoke.mockClear();

// Pinned: the chat's own machine answers, and the bound one is not asked.
await expect(bridge.agentChat.modelCatalog({ mode: "cached" }, chatRuntimePin))
.resolves.toMatchObject({ groups: [{ key: "ollama" }] });
expect(invoke).toHaveBeenCalledWith(IPC.remoteRuntimeCallAction, {
id: "target-2",
projectId: "project-2",
request: { domain: "chat", action: "modelCatalog", args: { mode: "cached" } },
});
expect(invoke).not.toHaveBeenCalledWith(
IPC.remoteRuntimeCallAction,
expect.objectContaining({ id: "target-1" }),
);
expect(invoke).not.toHaveBeenCalledWith(IPC.agentChatModelCatalog, expect.anything());
});

it("reads env files locally while importing and exporting secrets on the bound remote machine", async () => {
const binding = {
kind: "remote",
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6359,9 +6359,23 @@ contextBridge.exposeInMainWorld("ade", {
? runtime.result
: ipcRenderer.invoke(IPC.agentChatModels, args);
},
// Pinned exactly like `models`: the catalog enumerates ollama/LM Studio
// endpoints, the installed cursor-agent and the opencode inventory, all of
// which are facts about the machine that serves the action. A composer
// targeting another machine must read THAT machine's catalog rather than
// the one this window's project tab happens to be bound to.
modelCatalog: async (
args?: AgentChatModelCatalogArgs,
pin?: OpenProjectBinding | null,
): Promise<AgentChatModelCatalog> => {
if (pin) {
return callPinnedRuntimeAction<AgentChatModelCatalog>(
pin,
"chat",
"modelCatalog",
{ args: args ?? {} },
);
}
const runtime = await callProjectRuntimeActionIfBound<
AgentChatModelCatalog
>("chat", "modelCatalog", { args: args ?? {} });
Expand Down
23 changes: 21 additions & 2 deletions apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
import { ModelPicker } from "../shared/ModelPicker/ModelPicker";
import type { AuthStatus } from "../shared/ModelPicker/ModelPickerRail";
import { resolveModelDescriptorWithRuntimeCatalog } from "../shared/ModelPicker/modelCatalog";
import { DEFAULT_RUNTIME_CATALOG_SCOPE } from "../shared/ModelPicker/runtimeCatalogCache";
import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker";
import { getPermissionOptions, type PermissionOption } from "../shared/permissionOptions";
import { ContextUsageDial } from "./usage/ContextUsageDial";
Expand Down Expand Up @@ -1482,6 +1483,7 @@ export function AgentChatComposer({
onPromptHistoryNavigate,
attachments,
composerMachineBinding = null,
modelRuntimePin = null,
attachmentPersistenceUnavailableReason = null,
contextAttachments = [],
allowAttachmentOnlySubmit = false,
Expand Down Expand Up @@ -1624,6 +1626,14 @@ export function AgentChatComposer({
attachments: AgentChatFileRef[];
/** Effective runtime owning this composer and its prompt stashes. */
composerMachineBinding?: OpenProjectBinding | null;
/**
* {@link composerMachineBinding} when it is NOT the machine this window's
* project tab is bound to. What a model picker offers — which models exist,
* which are configured, and their thinking levels — is a fact about the
* machine that will run the turn, so it is read from this binding. `null`
* (the common case) means the bound machine, and keeps the shared catalog.
*/
modelRuntimePin?: OpenProjectBinding | null;
/** Fail-closed reason shown when the selected runtime cannot own new attachments. */
attachmentPersistenceUnavailableReason?: string | null;
contextAttachments?: AgentChatContextAttachment[];
Expand Down Expand Up @@ -1882,6 +1892,9 @@ export function AgentChatComposer({
const fileAddInProgressRef = useRef(false);
const latestComposerMachineBindingRef = useRef(composerMachineBinding);
latestComposerMachineBindingRef.current = composerMachineBinding;
// Catalog bucket for every model-derived control in this composer (picker
// rows, availability, thinking levels). Empty means the bound machine.
const modelCatalogScopeKey = modelRuntimePin?.key ?? DEFAULT_RUNTIME_CATALOG_SCOPE;
const objectPreviewUrlsRef = useRef<Set<string>>(new Set());
const cancelledPendingImageAttachmentsRef = useRef<Set<string>>(new Set());
const pendingImageAttachmentSequenceRef = useRef(0);
Expand Down Expand Up @@ -3463,7 +3476,7 @@ export function AgentChatComposer({
? (parallelModelSlots[parallelConfiguringIndex]?.modelId ?? "")
: (modelId ?? "");
const fastModeSupported = modelSupportsFastMode(
resolveModelDescriptorWithRuntimeCatalog(fastModeModelId) ?? getModelById(fastModeModelId),
resolveModelDescriptorWithRuntimeCatalog(fastModeModelId, modelCatalogScopeKey) ?? getModelById(fastModeModelId),
);
const fastModeActive =
parallelChatMode && parallelConfiguringIndex != null
Expand Down Expand Up @@ -4664,6 +4677,8 @@ export function AgentChatComposer({
metadata={meta}
{...(availableModelIdsForPicker ? { availableModelIds: availableModelIdsForPicker } : {})}
{...(providerAuthStatus ? { providerAuthStatus } : {})}
runtimePin={modelRuntimePin}
catalogScopeKey={modelCatalogScopeKey}
responding={approvalResponding ?? false}
onConfirm={(selection) => {
onApproval("accept", null, { selection: JSON.stringify(selection) });
Expand Down Expand Up @@ -5226,6 +5241,7 @@ export function AgentChatComposer({
{...(providerAuthStatus ? { providerAuthStatus } : {})}
{...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})}
{...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})}
runtimePin={modelRuntimePin}
allowCliOnlyModels={allowCliOnlyModels}
disabled={parallelLaunchBusy}
compact
Expand All @@ -5246,6 +5262,7 @@ export function AgentChatComposer({
disabled={parallelLaunchBusy}
compact
triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER}
catalogScopeKey={modelCatalogScopeKey}
/>
</>
) : null}
Expand All @@ -5262,6 +5279,7 @@ export function AgentChatComposer({
{...(providerAuthStatus ? { providerAuthStatus } : {})}
{...(onOpenAiSettings ? { onOpenSignIn: onOpenAiSettings } : {})}
{...(onRuntimeCatalogRefreshed ? { onRuntimeCatalogRefreshed } : {})}
runtimePin={modelRuntimePin}
allowCliOnlyModels={allowCliOnlyModels}
disabled={modelSelectionLocked}
compact
Expand All @@ -5277,6 +5295,7 @@ export function AgentChatComposer({
disabled={modelSelectionLocked}
compact
triggerClassName={COMPOSER_TOOLBAR_PICKER_TRIGGER}
catalogScopeKey={modelCatalogScopeKey}
/>
</>
) : null}
Expand Down Expand Up @@ -5310,7 +5329,7 @@ export function AgentChatComposer({
usage={usageViewModel}
active={turnActive}
compactionPulse={compactionPulse}
modelLabel={resolveModelDescriptorWithRuntimeCatalog(modelId)?.displayName ?? undefined}
modelLabel={resolveModelDescriptorWithRuntimeCatalog(modelId, modelCatalogScopeKey)?.displayName ?? undefined}
/>
) : null}

Expand Down
112 changes: 112 additions & 0 deletions apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,118 @@ describe("AgentChatPane remote startup", () => {
expect(window.ade.ai.getStatus).not.toHaveBeenCalled();
});

/**
* A Work tab unions chats from every machine on the account, so the machine a
* chat runs on is frequently NOT the one the project tab is bound to. What the
* prompt box offers — which models exist, and their thinking levels — is a
* fact about the machine that will run the turn.
*
* The bridge here answers differently per machine, exactly as two real Macs
* would: unpinned calls land on the bound machine (that is what preload's
* bound path does), pinned calls on the chat's own. Before the composer
* carried the pin, the picker for a chat on the Studio was filled from this
* Mac's catalog and offered `ollama/bound-only`, a model the Studio cannot run.
*/
it("fills the prompt box from the chat's own machine, not the bound one", async () => {
const boundRoot = "/tmp/project-under-test";
const studioBinding = {
kind: "remote" as const,
key: "remote:target-studio:project-studio",
targetId: "target-studio",
projectId: "project-studio",
runtimeName: "Mac Studio",
displayName: "project-under-test",
rootPath: "/Volumes/work/project-under-test",
};
const catalogFor = (localModelId: string, displayName: string) => ({
fetchedAt: "2026-05-22T00:00:00.000Z",
groups: [{
key: "ollama",
displayName: "Ollama",
providers: [{
key: "ollama",
displayName: "Ollama",
badgeColor: "#64748B",
modelCount: 1,
subsections: [{
key: "ollama",
label: "Ollama",
models: [{
id: localModelId,
runtimeModelId: localModelId,
provider: "ollama",
providerKey: "ollama",
groupKey: "ollama",
displayName,
isDefault: false,
isAvailable: true,
}],
}],
}],
}],
});

const session = buildSession("session-studio", { status: "idle", laneId: "lane-studio" });
installAdeMocks({ sessions: [session] });

const modelCatalog = vi.fn(async (_args?: unknown, pin?: { targetId?: string } | null) => (
pin?.targetId === "target-studio"
? catalogFor("ollama/studio-only", "Studio Only")
: catalogFor("ollama/bound-only", "Bound Only")
));
(window.ade.agentChat as any).modelCatalog = modelCatalog;

useAppStore.setState({
project: { rootPath: boundRoot, displayName: "project-under-test" } as any,
projectBinding: LOCAL_PROJECT_BINDING,
openRemoteProjectTabs: [studioBinding] as any,
crossMachineLanesByMachineId: {
studio: {
machineId: "studio",
machineName: "Mac Studio",
targetId: studioBinding.targetId,
projectId: studioBinding.projectId,
binding: studioBinding,
online: true,
lanes: [{
id: "lane-studio",
name: "studio lane",
laneType: "worktree",
branchRef: "refs/heads/studio-lane",
worktreePath: `${studioBinding.rootPath}/.ade/worktrees/studio-lane`,
}],
sessions: [],
prs: [],
lastSyncedAtMs: Date.now(),
error: null,
},
} as any,
selectedLaneId: "lane-studio",
});

renderPane(session);

const trigger = await screen.findByRole("button", { name: /^Select model/ });
fireEvent.pointerDown(trigger, { button: 0 });
fireEvent.click(trigger);

// The catalog request is addressed to the machine the chat runs on.
await waitFor(() => {
expect(modelCatalog).toHaveBeenCalledWith(
expect.objectContaining({ mode: "cached" }),
expect.objectContaining({ targetId: "target-studio" }),
);
});

// ...and the rows the user can pick under the local-models rail come from
// that machine: the Studio's ollama endpoint, never this Mac's.
fireEvent.click(await screen.findByRole("tab", { name: /^Ollama$/i }));
await waitFor(() => {
expect(document.querySelector('[data-model-id="ollama/studio-only"]')).toBeTruthy();
});
expect(document.querySelector('[data-model-id="ollama/bound-only"]')).toBeNull();
});

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 = {
Expand Down
Loading
Loading