diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts index bf22834e48d9..106cb1dc0326 100644 --- a/packages/app/src/pages/new-session/new-session-draft-controller.ts +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -9,6 +9,7 @@ import { createPromptInputController, createPromptProjectControls } from "@/page import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" import { useSessionKey } from "@/pages/session/session-layout" import { useComposerCommands } from "@/pages/session/use-composer-commands" +import { createPromptAgentSelection } from "./prompt-agent-selection" export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) { const prompt = usePrompt() @@ -18,14 +19,16 @@ export function createNewSessionDraftController(workspace: { worktree: () => str const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() const model = createPromptModelSelection({ agent: () => local.agent.current() }) + const agent = createPromptAgentSelection({ agent: local.agent, model: prompt.model }) - useComposerCommands({ model }) + useComposerCommands({ model, agent }) const controls = createPromptInputController({ sessionKey: route.sessionKey, sessionID: () => route.params.id, queryOptions: serverSync().queryOptions, model, + agent, }) const projectControls = createPromptProjectControls() const input = usePromptInputV2Controller({ diff --git a/packages/app/src/pages/new-session/prompt-agent-selection.ts b/packages/app/src/pages/new-session/prompt-agent-selection.ts new file mode 100644 index 000000000000..b00334327108 --- /dev/null +++ b/packages/app/src/pages/new-session/prompt-agent-selection.ts @@ -0,0 +1,30 @@ +import { batch } from "solid-js" +import type { PromptModel } from "@/context/prompt-state" + +export function createPromptAgentSelection(input: { + agent: { + current(): { model?: Omit; variant?: string } | undefined + set(name: string | undefined): void + move(direction: 1 | -1): void + } + model: { + current(): PromptModel | undefined + set(model: PromptModel | undefined): void + } +}) { + const update = (select: () => void) => + batch(() => { + select() + const agent = input.agent.current() + if (!agent) return + const previous = input.model.current() + const model = agent.model ?? previous + if (!model) return + input.model.set({ ...model, variant: agent.variant ?? previous?.variant }) + }) + + return { + set: (name: string | undefined) => update(() => input.agent.set(name)), + move: (direction: 1 | -1) => update(() => input.agent.move(direction)), + } +} diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index a9b0070bc025..7d802878a131 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -22,6 +22,7 @@ export function createPromptInputController(input: { sessionID: Accessor queryOptions: Pick model?: ModelSelection + agent?: Pick["agent"], "set"> }) { const layout = useLayout() const local = useLocal() @@ -41,7 +42,7 @@ export function createPromptInputController(input: { current: local.agent.current()?.name ?? "", loading: agentsQuery.isLoading, visible: local.agent.visible(), - select: local.agent.set, + select: (name) => (input.agent ?? local.agent).set(name), }, model: { selection: input.model ?? local.model, diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx index e7e51489ee75..b919fea98512 100644 --- a/packages/app/src/pages/session/use-composer-commands.tsx +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -13,7 +13,9 @@ const withCategory = (category: string) => { }) } -export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { +export const useComposerCommands = ( + input: { model?: ModelSelection; agent?: Pick["agent"], "move"> } = {}, +) => { const command = useCommand() const dialog = useDialog() const language = useLanguage() @@ -21,6 +23,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) const model = input.model ?? local.model + const agent = input.agent ?? local.agent const modelCommand = withCategory(language.t("command.category.model")) const agentCommand = withCategory(language.t("command.category.agent")) @@ -69,7 +72,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { keybind: "mod+.", slash: "agent", disabled: !local.agent.visible(), - onSelect: () => local.agent.move(1), + onSelect: () => agent.move(1), }), agentCommand({ id: "agent.cycle.reverse", @@ -77,7 +80,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { description: language.t("command.agent.cycle.reverse.description"), keybind: "shift+mod+.", disabled: !local.agent.visible(), - onSelect: () => local.agent.move(-1), + onSelect: () => agent.move(-1), }), ]) } diff --git a/packages/app/test-browser/prompt-agent-selection.test.ts b/packages/app/test-browser/prompt-agent-selection.test.ts new file mode 100644 index 000000000000..6c2e4d5099bb --- /dev/null +++ b/packages/app/test-browser/prompt-agent-selection.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test" +import { createPromptState, type PromptModel } from "@/context/prompt-state" +import { createPromptAgentSelection } from "@/pages/new-session/prompt-agent-selection" + +const agents = [ + { name: "alpha", model: { providerID: "fixture", modelID: "alpha" }, variant: "low" }, + { name: "beta", model: { providerID: "fixture", modelID: "beta" }, variant: "high" }, + { name: "gamma", model: { providerID: "fixture", modelID: "beta" }, variant: "low" }, + { name: "inherited" }, +] + +function setup(model?: PromptModel) { + const prompt = createPromptState({ model }) + let index = 0 + const agent = { + current: () => agents[index], + set: (name: string | undefined) => { + index = agents.findIndex((agent) => agent.name === name) + }, + move: (direction: 1 | -1) => { + index = (index + direction + agents.length) % agents.length + }, + } + return { prompt, agent, select: createPromptAgentSelection({ agent, model: prompt.model }) } +} + +test("agent selection replaces the model and variant inherited by a new draft", () => { + const { prompt, select } = setup({ providerID: "fixture", modelID: "alpha", variant: "low" }) + select.set("beta") + expect(prompt.model.current()).toEqual({ providerID: "fixture", modelID: "beta", variant: "high" }) + select.set("gamma") + expect(prompt.model.current()).toEqual({ providerID: "fixture", modelID: "beta", variant: "low" }) +}) + +test("both agent cycle directions update draft selection", () => { + const { prompt, select } = setup() + select.move(1) + expect(prompt.model.current()).toEqual({ providerID: "fixture", modelID: "beta", variant: "high" }) + select.move(-1) + expect(prompt.model.current()).toEqual({ providerID: "fixture", modelID: "alpha", variant: "low" }) +}) + +test("agents without defaults preserve manual model and explicit default variant", () => { + const model = { providerID: "other", modelID: "manual", variant: null } + const { prompt, select } = setup(model) + select.set("inherited") + expect(prompt.model.current()).toEqual(model) +}) + +test("initialization and session restoration do not overwrite the draft model", () => { + const model = { providerID: "other", modelID: "manual", variant: "high" } + const { prompt, agent, select } = setup(model) + expect(prompt.model.current()).toEqual(model) + agent.set("beta") + expect(prompt.model.current()).toEqual(model) + select.set("beta") + prompt.model.set(model) + expect(prompt.model.current()).toEqual(model) +}) + +test("a missing agent does not clear the draft model", () => { + const model = { providerID: "fixture", modelID: "alpha", variant: "low" } + const { prompt, select } = setup(model) + select.set("missing") + expect(prompt.model.current()).toEqual(model) +})