diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index 0a4fdbef..b0115f30 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -27,6 +27,9 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" }, { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" }, { "key": "mod+shift+t", "command": "chat.newTerminal", "when": "!terminalFocus" }, + { "key": "mod+alt+c", "command": "chat.newClaude", "when": "!terminalFocus" }, + { "key": "mod+alt+x", "command": "chat.newCodex", "when": "!terminalFocus" }, + { "key": "mod+alt+g", "command": "chat.newGemini", "when": "!terminalFocus" }, { "key": "mod+o", "command": "editor.openFavorite" } ] ``` @@ -54,6 +57,9 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`)) - `chat.newTerminal`: create a new terminal-first thread preserving the active thread's branch/worktree state +- `chat.newClaude`: create a new chat thread with Claude preselected +- `chat.newCodex`: create a new chat thread with Codex preselected +- `chat.newGemini`: create a new chat thread with Gemini preselected - `editor.openFavorite`: open current project/worktree in the last-used editor - `script.{id}.run`: run a project script by id (for example `script.test.run`) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a78694da..e7fa9be1 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { appendVoiceTranscriptToPrompt, + shouldEnableProviderModelsQuery, deriveComposerVoiceState, describeVoiceRecordingStartError, hasServerAcknowledgedLocalDispatch, @@ -118,6 +119,29 @@ describe("voice helpers", () => { }); }); +describe("shouldEnableProviderModelsQuery", () => { + it("keeps Gemini model discovery enabled while the provider is still switchable", () => { + expect(shouldEnableProviderModelsQuery({ provider: "gemini", lockedProvider: null })).toBe( + true, + ); + }); + + it("disables Gemini model discovery when another provider has already locked the thread", () => { + expect(shouldEnableProviderModelsQuery({ provider: "gemini", lockedProvider: "codex" })).toBe( + false, + ); + }); + + it("keeps Codex and Claude discovery enabled regardless of the active lock", () => { + expect( + shouldEnableProviderModelsQuery({ provider: "codex", lockedProvider: "claudeAgent" }), + ).toBe(true); + expect( + shouldEnableProviderModelsQuery({ provider: "claudeAgent", lockedProvider: "codex" }), + ).toBe(true); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 5e0fbfe4..a5bb60e7 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -414,6 +414,17 @@ export function shouldRenderTerminalWorkspace(options: { ); } +export function shouldEnableProviderModelsQuery(input: { + provider: "codex" | "claudeAgent" | "gemini"; + lockedProvider: "codex" | "claudeAgent" | "gemini" | null; +}): boolean { + if (input.provider !== "gemini") { + return true; + } + + return input.lockedProvider === null || input.lockedProvider === "gemini"; +} + export function shouldAutoDeleteTerminalThreadOnLastClose(options: { isLastTerminal: boolean; isServerThread: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bc72eb9d..4b0e02d9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -81,6 +81,7 @@ import { buildThreadBreadcrumbs, enrichSubagentWorkEntries, resolveActiveThreadTitle, + shouldEnableProviderModelsQuery, } from "./ChatView.logic"; import { createRelevantWorkLogThreadsSelector, @@ -1195,7 +1196,7 @@ export default function ChatView({ providerModelsQueryOptions({ provider: "gemini", binaryPath: settings.geminiBinaryPath || null, - enabled: selectedProvider === "gemini" || lockedProvider === "gemini", + enabled: shouldEnableProviderModelsQuery({ provider: "gemini", lockedProvider }), }), ); const claudeDynamicAgentsQuery = useQuery( diff --git a/apps/web/src/components/chat/composerProviderRegistry.browser.tsx b/apps/web/src/components/chat/composerProviderRegistry.browser.tsx new file mode 100644 index 00000000..40fbee33 --- /dev/null +++ b/apps/web/src/components/chat/composerProviderRegistry.browser.tsx @@ -0,0 +1,55 @@ +import "../../index.css"; + +import { ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { renderProviderTraitsPicker } from "./composerProviderRegistry"; + +const GEMINI_THREAD_ID = ThreadId.makeUnsafe("thread-gemini-registry-picker"); + +async function mountGeminiTraitsPicker(props?: { open?: boolean }) { + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + <> + {renderProviderTraitsPicker({ + provider: "gemini", + threadId: GEMINI_THREAD_ID, + model: "gemini-2.5-pro", + modelOptions: undefined, + prompt: "", + ...(props?.open !== undefined ? { open: props.open } : {}), + onPromptChange: () => {}, + })} + , + { container: host }, + ); + + const cleanup = async () => { + await screen.unmount(); + host.remove(); + }; + + return { + [Symbol.asyncDispose]: cleanup, + cleanup, + }; +} + +describe("renderProviderTraitsPicker (Gemini browser)", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("opens the Gemini traits menu when the shared composer state requests it", async () => { + await using _ = await mountGeminiTraitsPicker({ open: true }); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Effort"); + expect(text).toContain("Dynamic"); + expect(text).toContain("512 Tokens"); + }); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 5cc0aee6..ed5cf037 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; -import { getComposerProviderState } from "./composerProviderRegistry"; +import { ThreadId } from "@t3tools/contracts"; +import { isValidElement, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { getComposerProviderState, renderProviderTraitsPicker } from "./composerProviderRegistry"; describe("getComposerProviderState", () => { it("returns codex defaults when no codex draft options exist", () => { @@ -261,3 +263,30 @@ describe("getComposerProviderState", () => { }); }); }); + +describe("renderProviderTraitsPicker", () => { + it("keeps Gemini traits pickers controlled by shared composer state", () => { + const onOpenChange = vi.fn(); + const picker = renderProviderTraitsPicker({ + provider: "gemini", + threadId: ThreadId.makeUnsafe("thread-gemini-traits"), + model: "auto-gemini-3", + modelOptions: undefined, + prompt: "", + open: true, + onOpenChange, + shortcutLabel: "Ctrl+Shift+E", + onPromptChange: () => {}, + }); + + expect(isValidElement(picker)).toBe(true); + const pickerElement = picker as ReactElement<{ + open?: boolean; + onOpenChange?: (open: boolean) => void; + shortcutLabel?: string | null; + }>; + expect(pickerElement.props.open).toBe(true); + expect(pickerElement.props.onOpenChange).toBe(onOpenChange); + expect(pickerElement.props.shortcutLabel).toBe("Ctrl+Shift+E"); + }); +}); diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 3c4b460c..6b13479b 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -233,6 +233,9 @@ const composerProviderRegistry: Record = { modelOptions, prompt, includeFastMode, + open, + onOpenChange, + shortcutLabel, onPromptChange, }) => ( = { model={model} modelOptions={modelOptions} prompt={prompt} + {...(open !== undefined ? { open } : {})} + {...(onOpenChange ? { onOpenChange } : {})} + {...(shortcutLabel !== undefined ? { shortcutLabel } : {})} {...(includeFastMode === undefined ? {} : { includeFastMode })} onPromptChange={onPromptChange} /> diff --git a/apps/web/src/shortcutsSheet.test.ts b/apps/web/src/shortcutsSheet.test.ts index e833450b..e83719d0 100644 --- a/apps/web/src/shortcutsSheet.test.ts +++ b/apps/web/src/shortcutsSheet.test.ts @@ -110,4 +110,36 @@ describe("buildShortcutSheetSections", () => { ), ).toBe(true); }); + + it("includes the Gemini thread shortcut when the binding exists", () => { + const sections = buildShortcutSheetSections({ + keybindings: [ + { + command: "chat.newGemini", + shortcut: { + key: "g", + modKey: true, + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: true, + }, + }, + ], + projectScripts: [], + platform: "Linux", + context: { + terminalFocus: false, + terminalOpen: false, + terminalWorkspaceOpen: false, + }, + isElectron: false, + }); + + expect( + sections[0]?.entries.some( + (entry) => entry.label === "New Gemini thread" && entry.shortcutLabel === "Ctrl+Alt+G", + ), + ).toBe(true); + }); }); diff --git a/apps/web/src/shortcutsSheet.ts b/apps/web/src/shortcutsSheet.ts index c5fc4a17..fbf396e6 100644 --- a/apps/web/src/shortcutsSheet.ts +++ b/apps/web/src/shortcutsSheet.ts @@ -91,6 +91,11 @@ const AVAILABLE_NOW_DEFINITIONS: readonly ShortcutDefinition[] = [ label: "New Codex thread", description: "Start a fresh thread with Codex selected.", }, + { + command: "chat.newGemini", + label: "New Gemini thread", + description: "Start a fresh thread with Gemini selected.", + }, { command: "chat.split", label: "Split chat",