summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-13 11:05:08 -0500
committerGitHub <[email protected]>2026-03-13 11:05:08 -0500
commit4ad8116ce37a0e77e7f3c0e9e4e1002bba05b15e (patch)
treeb7e5ed2b05aabb5ed5134520c4eb485c52eb5333 /packages/app/src/components
parent5c7088338c07ad632834ebd4a87feb23d255fb8a (diff)
downloadopencode-4ad8116ce37a0e77e7f3c0e9e4e1002bba05b15e.tar.gz
opencode-4ad8116ce37a0e77e7f3c0e9e4e1002bba05b15e.zip
fix(app): model selection persist by session (#17348)
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/dialog-select-model-unpaid.tsx12
-rw-r--r--packages/app/src/components/dialog-select-model.tsx19
-rw-r--r--packages/app/src/components/prompt-input.tsx155
-rw-r--r--packages/app/src/components/prompt-input/submit.test.ts12
-rw-r--r--packages/app/src/components/prompt-input/submit.ts3
5 files changed, 116 insertions, 85 deletions
diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx
index bcee3f501..2106b3a01 100644
--- a/packages/app/src/components/dialog-select-model-unpaid.tsx
+++ b/packages/app/src/components/dialog-select-model-unpaid.tsx
@@ -13,8 +13,10 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
-export const DialogSelectModelUnpaid: Component = () => {
- const local = useLocal()
+type ModelState = ReturnType<typeof useLocal>["model"]
+
+export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props) => {
+ const model = props.model ?? useLocal().model
const dialog = useDialog()
const providers = useProviders()
const language = useLanguage()
@@ -35,8 +37,8 @@ export const DialogSelectModelUnpaid: Component = () => {
<List
class="[&_[data-slot=list-scroll]]:overflow-visible"
ref={(ref) => (listRef = ref)}
- items={local.model.list}
- current={local.model.current()}
+ items={model.list}
+ current={model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
itemWrapper={(item, node) => (
<Tooltip
@@ -55,7 +57,7 @@ export const DialogSelectModelUnpaid: Component = () => {
</Tooltip>
)}
onSelect={(x) => {
- local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+ model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
index 9f7afb8cd..3654aab85 100644
--- a/packages/app/src/components/dialog-select-model.tsx
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -18,19 +18,22 @@ import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
+type ModelState = ReturnType<typeof useLocal>["model"]
+
const ModelList: Component<{
provider?: string
class?: string
onSelect: () => void
action?: JSX.Element
+ model?: ModelState
}> = (props) => {
- const local = useLocal()
+ const model = props.model ?? useLocal().model
const language = useLanguage()
const models = createMemo(() =>
- local.model
+ model
.list()
- .filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
+ .filter((m) => model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
@@ -41,7 +44,7 @@ const ModelList: Component<{
emptyMessage={language.t("dialog.model.empty")}
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
- current={local.model.current()}
+ current={model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
@@ -63,7 +66,7 @@ const ModelList: Component<{
</Tooltip>
)}
onSelect={(x) => {
- local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+ model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
props.onSelect()
@@ -88,6 +91,7 @@ type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "a
export function ModelSelectorPopover(props: {
provider?: string
+ model?: ModelState
children?: JSX.Element
triggerAs?: ValidComponent
triggerProps?: ModelSelectorTriggerProps
@@ -151,6 +155,7 @@ export function ModelSelectorPopover(props: {
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
<ModelList
provider={props.provider}
+ model={props.model}
onSelect={() => setStore("open", false)}
class="p-1"
action={
@@ -184,7 +189,7 @@ export function ModelSelectorPopover(props: {
)
}
-export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
+export const DialogSelectModel: Component<{ provider?: string; model?: ModelState }> = (props) => {
const dialog = useDialog()
const language = useLanguage()
@@ -202,7 +207,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
</Button>
}
>
- <ModelList provider={props.provider} onSelect={() => dialog.close()} />
+ <ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index fd54de9a0..9048fa895 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1430,39 +1430,76 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="size-4 shrink-0" />
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1">
- <TooltipKeybind
- placement="top"
- gutter={4}
- title={language.t("command.agent.cycle")}
- keybind={command.keybind("agent.cycle")}
- >
- <Select
- size="normal"
- options={agentNames()}
- current={local.agent.current()?.name ?? ""}
- onSelect={local.agent.set}
- class="capitalize max-w-[160px] text-text-base"
- valueClass="truncate text-13-regular text-text-base"
- triggerStyle={control()}
- variant="ghost"
- />
- </TooltipKeybind>
- <Show
- when={providers.paid().length > 0}
- fallback={
+ <div data-component="prompt-agent-control">
+ <TooltipKeybind
+ placement="top"
+ gutter={4}
+ title={language.t("command.agent.cycle")}
+ keybind={command.keybind("agent.cycle")}
+ >
+ <Select
+ size="normal"
+ options={agentNames()}
+ current={local.agent.current()?.name ?? ""}
+ onSelect={local.agent.set}
+ class="capitalize max-w-[160px] text-text-base"
+ valueClass="truncate text-13-regular text-text-base"
+ triggerStyle={control()}
+ triggerProps={{ "data-action": "prompt-agent" }}
+ variant="ghost"
+ />
+ </TooltipKeybind>
+ </div>
+ <div data-component="prompt-model-control">
+ <Show
+ when={providers.paid().length > 0}
+ fallback={
+ <TooltipKeybind
+ placement="top"
+ gutter={4}
+ title={language.t("command.model.choose")}
+ keybind={command.keybind("model.choose")}
+ >
+ <Button
+ data-action="prompt-model"
+ as="div"
+ variant="ghost"
+ size="normal"
+ class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
+ style={control()}
+ onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
+ >
+ <Show when={local.model.current()?.provider?.id}>
+ <ProviderIcon
+ id={local.model.current()!.provider.id}
+ class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
+ style={{ "will-change": "opacity", transform: "translateZ(0)" }}
+ />
+ </Show>
+ <span class="truncate">
+ {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+ </span>
+ <Icon name="chevron-down" size="small" class="shrink-0" />
+ </Button>
+ </TooltipKeybind>
+ }
+ >
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
- <Button
- as="div"
- variant="ghost"
- size="normal"
- class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
- style={control()}
- onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
+ <ModelSelectorPopover
+ model={local.model}
+ triggerAs={Button}
+ triggerProps={{
+ variant: "ghost",
+ size: "normal",
+ style: control(),
+ class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
+ "data-action": "prompt-model",
+ }}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
@@ -1475,57 +1512,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
- </Button>
+ </ModelSelectorPopover>
</TooltipKeybind>
- }
- >
+ </Show>
+ </div>
+ <div data-component="prompt-variant-control">
<TooltipKeybind
placement="top"
gutter={4}
- title={language.t("command.model.choose")}
- keybind={command.keybind("model.choose")}
+ title={language.t("command.model.variant.cycle")}
+ keybind={command.keybind("model.variant.cycle")}
>
- <ModelSelectorPopover
- triggerAs={Button}
- triggerProps={{
- variant: "ghost",
- size: "normal",
- style: control(),
- class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
- }}
- >
- <Show when={local.model.current()?.provider?.id}>
- <ProviderIcon
- id={local.model.current()!.provider.id}
- class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
- style={{ "will-change": "opacity", transform: "translateZ(0)" }}
- />
- </Show>
- <span class="truncate">
- {local.model.current()?.name ?? language.t("dialog.model.select.title")}
- </span>
- <Icon name="chevron-down" size="small" class="shrink-0" />
- </ModelSelectorPopover>
+ <Select
+ size="normal"
+ options={variants()}
+ current={local.model.variant.current() ?? "default"}
+ label={(x) => (x === "default" ? language.t("common.default") : x)}
+ onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
+ class="capitalize max-w-[160px] text-text-base"
+ valueClass="truncate text-13-regular text-text-base"
+ triggerStyle={control()}
+ triggerProps={{ "data-action": "prompt-model-variant" }}
+ variant="ghost"
+ />
</TooltipKeybind>
- </Show>
- <TooltipKeybind
- placement="top"
- gutter={4}
- title={language.t("command.model.variant.cycle")}
- keybind={command.keybind("model.variant.cycle")}
- >
- <Select
- size="normal"
- options={variants()}
- current={local.model.variant.current() ?? "default"}
- label={(x) => (x === "default" ? language.t("common.default") : x)}
- onSelect={(x) => local.model.variant.set(x === "default" ? undefined : x)}
- class="capitalize max-w-[160px] text-text-base"
- valueClass="truncate text-13-regular text-text-base"
- triggerStyle={control()}
- variant="ghost"
- />
- </TooltipKeybind>
+ </div>
<TooltipKeybind
placement="top"
gutter={8}
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index 9f7fac69d..b0166c43a 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -17,6 +17,7 @@ const optimistic: Array<{
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
+const promoted: Array<{ directory: string; sessionID: string }> = []
const sentShell: string[] = []
const syncedDirectories: string[] = []
@@ -86,6 +87,11 @@ beforeAll(async () => {
agent: {
current: () => ({ name: "agent" }),
},
+ session: {
+ promote(directory: string, sessionID: string) {
+ promoted.push({ directory, sessionID })
+ },
+ },
}),
}))
@@ -201,6 +207,7 @@ beforeEach(() => {
enabledAutoAccept.length = 0
optimistic.length = 0
optimisticSeeded.length = 0
+ promoted.length = 0
params = {}
sentShell.length = 0
syncedDirectories.length = 0
@@ -240,6 +247,11 @@ describe("prompt submit worktree selection", () => {
expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"])
expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
+ expect(promoted).toEqual([
+ { directory: "/repo/worktree-a", sessionID: "session-1" },
+ { directory: "/repo/worktree-b", sessionID: "session-2" },
+ ])
+ expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"])
})
test("applies auto-accept to newly created sessions", async () => {
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index e8d765cd9..ba299fe36 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -296,6 +296,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const currentModel = local.model.current()
const currentAgent = local.agent.current()
+ const variant = local.model.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -370,6 +371,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
seed(sessionDirectory, created)
session = created
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
+ local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
@@ -387,7 +389,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
- const variant = local.model.variant.current()
const context = prompt.context.items().slice()
const draft: FollowupDraft = {
sessionID: session.id,