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
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