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
6 changes: 5 additions & 1 deletion src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { openServerWindow } from "./utils/openServerWindow";
import {
applyFastModeToggle,
getFastModeProvider,
getFastModeUnavailableReason,
isFastModeActive,
} from "./utils/fastModeServiceTier";
import { handleLayoutSlotHotkeys } from "./utils/ui/layoutSlotHotkeys";
Expand Down Expand Up @@ -731,7 +732,10 @@ function AppInner() {
createCustomEvent(CUSTOM_EVENTS.ANALYTICS_REBUILD_TOAST, {
type: "error",
title: "Fast mode",
message: `Fast mode is not available for ${model} on its current provider route, for example through a gateway or a custom base URL.`,
message:
getFastModeUnavailableReason(model, providersConfig) === "model"
? `Fast mode is not available for ${model}: this model has no fast mode.`
: `Fast mode is not available for ${model} on its current provider route, for example through a gateway or a custom base URL.`,
})
);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { stopKeyboardPropagation } from "@/browser/utils/events";
import {
KEYBINDS,
formatKeybind,
isCommandPaletteTarget,
isDialogOpen,
isTerminalFocused,
matchesKeybind,
Expand Down Expand Up @@ -126,8 +127,7 @@ export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps>
};
const onKeyDown = (event: KeyboardEvent) => {
if (!matchesKeybind(event, KEYBINDS.FOCUS_BACKGROUND_PROCESSES) || isDialogOpen()) return;
// isDialogOpen() misses the command palette (cmdk); focus must not move behind it.
if (event.target instanceof Element && event.target.closest("[cmdk-root]")) return;
if (isCommandPaletteTarget(event.target)) return;
if (isTerminalFocused(event.target)) return; // the terminal owns its keystrokes
if (rootRef.current?.closest("[inert]")) return;
event.preventDefault();
Expand Down
6 changes: 2 additions & 4 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ import {
formatKeybind,
KEYBINDS,
isDesktopViewportFocused,
isCommandPaletteTarget,
isDialogOpen,
isEditableElement,
} from "@/browser/utils/ui/keybinds";
Expand Down Expand Up @@ -1359,10 +1360,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return;
}
event.preventDefault();
// isDialogOpen() misses the command palette (cmdk); focus must not move behind it.
const inCommandPalette =
event.target instanceof Element && event.target.closest("[cmdk-root]") != null;
if (!isDialogOpen() && !inCommandPalette) focusMessageInput();
if (!isDialogOpen() && !isCommandPaletteTarget(event.target)) focusMessageInput();
};
window.addEventListener("keydown", handleFocusChat, { capture: true });
return () => window.removeEventListener("keydown", handleFocusChat, { capture: true });
Expand Down
4 changes: 3 additions & 1 deletion src/browser/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
isDesktopViewportFocused,
isTerminalFocused,
isDialogOpen,
isCommandPaletteTarget,
} from "@/browser/utils/ui/keybinds";
import type { StreamingMessageAggregator } from "@/browser/utils/messages/StreamingMessageAggregator";
import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler";
Expand Down Expand Up @@ -143,7 +144,8 @@ export function useAIViewKeybinds({
// Focus chat input works anywhere (even in input fields)
if (matchesKeybind(e, KEYBINDS.FOCUS_CHAT)) {
e.preventDefault();
if (!dialogOpen) chatInputAPI.current?.focus();
// Same rule as the creation screen: focus must not move behind the command palette (#5752).
if (!dialogOpen && !isCommandPaletteTarget(e.target)) chatInputAPI.current?.focus();
return;
}

Expand Down
42 changes: 42 additions & 0 deletions src/browser/utils/fastModeServiceTier.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { describe, expect, mock, test } from "bun:test";

import type { APIClient } from "@/browser/contexts/API";
import type { ProvidersConfigMap } from "@/common/orpc/types";
import {
applyFastModeServiceTierChange,
applyFastModeToggle,
getFastModeProvider,
getFastModeServiceTierChange,
getFastModeUnavailableReason,
isFastModeActive,
} from "./fastModeServiceTier";

Expand All @@ -22,6 +24,46 @@ function createWriter() {
}

describe("fast mode service tier", () => {
test("says whether the model or its route lacks fast mode (#5753)", () => {
// No route gives these models fast mode.
expect(getFastModeUnavailableReason("google:gemini-3-pro", null)).toBe("model");
expect(getFastModeUnavailableReason("anthropic:claude-haiku-4-5", null)).toBe("model");
expect(getFastModeUnavailableReason("xai:grok-code-fast-1", null)).toBe("model");
// These models have fast mode, so a null provider means the route cannot send it.
expect(getFastModeUnavailableReason("mux-gateway:anthropic/claude-opus-5-5", null)).toBe(
"route"
);
expect(getFastModeUnavailableReason("openrouter:openai/gpt-6-astra", null)).toBe("route");
expect(getFastModeUnavailableReason("xai:grok-4.7", null)).toBe("route");

// The capability comes from the provider config, whatever the route.
const anthropic = { apiKeySet: true, isEnabled: true, isConfigured: true };
const mapped = {
anthropic: {
...anthropic,
models: [
{ id: "team-opus", mappedToModel: "anthropic:claude-opus-5-5" },
{ id: "team-haiku", mappedToModel: "anthropic:claude-haiku-4-5" },
],
},
} as unknown as ProvidersConfigMap;
expect(getFastModeUnavailableReason("anthropic:team-opus", mapped)).toBe("route");
expect(getFastModeUnavailableReason("anthropic:team-haiku", mapped)).toBe("model");
const custom = {
anthropic,
"team-claude": { ...anthropic, providerType: "anthropic-messages" },
"team-compat": { ...anthropic, providerType: "openai-compatible" },
} as unknown as ProvidersConfigMap;
// getFastModeProvider refuses custom providers, so the route is the reason for Opus.
expect(getFastModeProvider("team-claude:claude-opus-5-5", { providersConfig: custom })).toBe(
null
);
expect(getFastModeUnavailableReason("team-claude:claude-opus-5-5", custom)).toBe("route");
expect(getFastModeUnavailableReason("team-claude:claude-haiku-4-5", custom)).toBe("model");
// A generic OpenAI-compatible dialect gives the model no known identity.
expect(getFastModeUnavailableReason("team-compat:claude-opus-5-5", custom)).toBe("model");
});

test("resolves direct native providers and rejects gateway routes", () => {
expect(getFastModeProvider("openai:gpt-5.6-sol", { resolvedRouteProvider: "direct" })).toBe(
"openai"
Expand Down
31 changes: 31 additions & 0 deletions src/browser/utils/fastModeServiceTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import { isGrokFrontierModel } from "@/common/types/thinking";
import { anthropicFastModeAvailable } from "@/common/utils/ai/anthropicFastMode";
import { getExplicitGatewayPrefix, normalizeToCanonical } from "@/common/utils/ai/models";
import { openaiServiceTierAvailable } from "@/common/utils/ai/openaiProviderOptionsAvailability";
import {
customProviderWireOrigin,
isCustomProviderConfig,
} from "@/common/utils/providers/customProviders";
import { resolveModelForMetadata } from "@/common/utils/providers/modelEntries";

export type FastModeProvider = "openai" | "xai" | "anthropic";
Expand Down Expand Up @@ -64,6 +68,33 @@ export function getFastModeProvider(
: null;
}

/**
* Why getFastModeProvider returned null: "model" when the model has no Fast mode on any route,
* "route" when the model has one but this route (gateway, custom provider, custom base URL, ...)
* cannot send it. The shortcut's toast used to blame the route for every model, Gemini included
* (#5753).
*/
export function getFastModeUnavailableReason(
modelString: string,
providersConfig: ProvidersConfigMap | null
): "model" | "route" {
// The model's capability identity comes from the provider config, not from the route: an
// explicit mapping ("Treat as") or a Coder instance's upstream, then a custom provider's
// wire dialect (an anthropic-messages provider serving claude-opus-5-5 is an Opus model).
let capabilityModel = normalizeToCanonical(resolveModelForMetadata(modelString, providersConfig));
Comment thread
ThomasK33 marked this conversation as resolved.
const separator = modelString.indexOf(":");
const custom = separator > 0 ? providersConfig?.[modelString.slice(0, separator)] : undefined;
if (isCustomProviderConfig(custom) && capabilityModel === normalizeToCanonical(modelString)) {
const wireOrigin = customProviderWireOrigin(custom.providerType);
if (wireOrigin != null) capabilityModel = `${wireOrigin}:${modelString.slice(separator + 1)}`;
}
// That model's own provider, called directly, is the route where Fast mode works if the model
// has it at all. No config here: config gates (missing provider, ZDR) are route reasons too.
return getFastModeProvider(capabilityModel, { resolvedRouteProvider: "direct" })
? "route"
: "model";
}

/**
* Fast mode is a temporary priority-tier override. The restore target lives in
* providers.jsonc so every browser origin and desktop client observes the same state.
Expand Down
9 changes: 9 additions & 0 deletions src/browser/utils/ui/keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,15 @@ export function isDialogOpen(): boolean {
);
}

/**
* True when a key event comes from inside the command palette. isDialogOpen() does not see the
* palette (cmdk renders no modal overlay), so global shortcuts that move focus check this too:
* focus must not move behind the open palette.
*/
export function isCommandPaletteTarget(target: EventTarget | null): boolean {
return target instanceof Element && target.closest("[cmdk-root]") != null;
}

/**
* Format a keybind for display to users.
* Returns Mac-style symbols on macOS, or Windows-style text elsewhere.
Expand Down
51 changes: 51 additions & 0 deletions tests/bugbash/repros/focusAndLabels.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,54 @@ test(
await expect(screen.getByText(/^Fast mode is not available/)).toBeVisible();
}
);

test(
"Ctrl+I in the workspace view leaves focus in the command palette",
{ tags: ["bugbash", "5752"] },
async ({ app, screen, browser }) => {
await browser.setViewport({ width: 1440, height: 900 });
await openPlayground(app, screen, browser);
await screen.getByRole("textbox", "Message").tap();

await browser.keyboard.press("Control+Shift+P");
const palette = screen.getByRole("combobox", "Command palette");
await expect(palette).toBeFocused();
await browser.keyboard.press("Control+i");
await expect(palette).toBeFocused();
}
);

test(
"The fast mode shortcut says when the model has no fast mode",
// mock-only: the test switches the shared workspace's model and must put it back. With mock AI
// the workspace starts on Opus 5.5. Real mode starts it on BUGBASH_APP_MODEL, which can be any
// model, and the model search matches ids, not the labels the test can read.
{ tags: ["bugbash", "5753", "mock-only"] },
async ({ app, screen, browser }) => {
await browser.setViewport({ width: 1440, height: 900 });
await openPlayground(app, screen, browser);
const composer = screen.getByRole("textbox", "Message");
const search = screen.getByRole("textbox", "Search [provider:model-name]");
const pickModel = async (from: string, model: string, label: string) => {
await screen.getByRole("combobox").filter({ hasText: from }).tap();
await search.fill(model);
await browser.keyboard.press("Enter");
await expect(search).toBeHidden();
await expect(screen.getByRole("combobox").filter({ hasText: label })).toBeVisible();
};
try {
// Haiku 4.5 has no fast mode on any route, so the toast must name the model, not the route.
await pickModel("Opus 5.5", "anthropic:claude-haiku-4-5", "Haiku 4.5");
await composer.tap();
await browser.keyboard.press("Control+Shift+F");
await expect(screen.getByText(/this model has no fast mode/)).toBeVisible();
} finally {
// Every repro in a run shares this workspace: put its model back, also after a failure.
// The first switch can fail before it changes the model, so restore only when it did.
const onOpus = await screen.getByRole("combobox").filter({ hasText: "Opus 5.5" }).isVisible();
if (!onOpus) {
await pickModel("Haiku 4.5", "anthropic:claude-opus-5-5", "Opus 5.5");
}
}
}
);
Loading