diff options
| author | Adam Malczewski <[email protected]> | 2026-06-10 16:48:30 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-10 16:48:30 +0900 |
| commit | b3f7ba523f644224364d155b575fa3f9f13c5eb9 (patch) | |
| tree | 1d131f624fe2e78c3a8ee050d4888b5ddec3f2cc | |
| parent | 871957b930203c019e631c4606cfdf8266d222fa (diff) | |
| download | dispatch-web-b3f7ba523f644224364d155b575fa3f9f13c5eb9.tar.gz dispatch-web-b3f7ba523f644224364d155b575fa3f9f13c5eb9.zip | |
feat(chat,app): Model view in sidebar + split key/model selectors
- move the model picker out of the chat header into a dedicated "Model" sidebar
view; sidebar now seeds two default panels (Model on top, Extensions below)
- split the single model dropdown into two stacked selects: a key selector
(distinct credential keys) + a model selector (models under the current key)
- pure model-select helpers (splitModelName/joinModelName/modelKeys/modelsForKey),
split on the FIRST slash so multi-slash model names stay intact
- onSelect still emits the full `<key>/<model>` string (ChatRequest.model unchanged)
| -rw-r--r-- | src/app/App.svelte | 22 | ||||
| -rw-r--r-- | src/features/chat/model-select.test.ts | 58 | ||||
| -rw-r--r-- | src/features/chat/model-select.ts | 49 | ||||
| -rw-r--r-- | src/features/chat/ui.test.ts | 50 | ||||
| -rw-r--r-- | src/features/chat/ui/ModelSelector.svelte | 48 |
5 files changed, 190 insertions, 37 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index ff6b1ca..f02797e 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -12,7 +12,13 @@ // The view kinds offered in the sidebar's dropdown. Generic data — the // `viewContent` snippet below maps each kind id to its renderer. - const viewKinds = [{ id: "extensions", label: "Extensions" }] as const; + const viewKinds = [ + { id: "model", label: "Model" }, + { id: "extensions", label: "Extensions" }, + ] as const; + + // Default sidebar layout: a Model panel on top, Extensions below. + const initialViews = ["model", "extensions"] as const; // Frontend module list for the "Loaded Modules" view, AGGREGATED from each // feature's public `manifest` export so it can't drift from what's actually @@ -100,14 +106,6 @@ </div> {/if} - <div class="flex items-center gap-2 px-4 py-2"> - <ModelSelector - models={store.models} - selected={store.activeModel} - onSelect={handleSelectModel} - /> - </div> - <div class="relative min-w-0 flex-1 overflow-y-auto"> {#key store.activeConversationId} <ChatView chunks={store.activeChat.chunks} turnMetrics={store.activeChat.turnMetrics} /> @@ -137,7 +135,7 @@ class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > - <ViewSidebar kinds={viewKinds} content={viewContent} /> + <ViewSidebar kinds={viewKinds} initial={initialViews} content={viewContent} /> </div> </aside> @@ -158,7 +156,9 @@ </main> {#snippet viewContent(kind: string)} - {#if kind === "extensions"} + {#if kind === "model"} + <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} /> + {:else if kind === "extensions"} <section> <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3> <Table columns={MODULE_COLUMNS} rows={loadedModules} /> diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts new file mode 100644 index 0000000..109cae1 --- /dev/null +++ b/src/features/chat/model-select.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { joinModelName, modelKeys, modelsForKey, splitModelName } from "./model-select"; + +describe("splitModelName", () => { + it("splits on the first slash", () => { + expect(splitModelName("openai/gpt-4")).toEqual({ key: "openai", model: "gpt-4" }); + }); + + it("keeps slashes in the model part (splits only the first)", () => { + expect(splitModelName("openrouter/anthropic/claude")).toEqual({ + key: "openrouter", + model: "anthropic/claude", + }); + }); + + it("treats a slashless name as all key", () => { + expect(splitModelName("local")).toEqual({ key: "local", model: "" }); + }); +}); + +describe("joinModelName", () => { + it("recombines key + model", () => { + expect(joinModelName("openai", "gpt-4")).toBe("openai/gpt-4"); + }); + + it("returns just the key when the model is empty", () => { + expect(joinModelName("local", "")).toBe("local"); + }); + + it("round-trips with splitModelName", () => { + const full = "openrouter/anthropic/claude"; + const { key, model } = splitModelName(full); + expect(joinModelName(key, model)).toBe(full); + }); +}); + +describe("modelKeys", () => { + it("returns distinct keys in first-seen order", () => { + expect( + modelKeys(["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]), + ).toEqual(["openai", "anthropic", "google"]); + }); + + it("is empty for no models", () => { + expect(modelKeys([])).toEqual([]); + }); +}); + +describe("modelsForKey", () => { + it("returns the model suffixes under a key, in order", () => { + const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; + expect(modelsForKey(models, "openai")).toEqual(["gpt-4", "gpt-4o"]); + }); + + it("returns empty for an unknown key", () => { + expect(modelsForKey(["openai/gpt-4"], "anthropic")).toEqual([]); + }); +}); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts new file mode 100644 index 0000000..b1d70b9 --- /dev/null +++ b/src/features/chat/model-select.ts @@ -0,0 +1,49 @@ +/** + * Pure helpers for the two-step model picker. + * + * Models arrive from `GET /models` as `<key>/<model>` strings, where `key` is + * the credential name (the part before the FIRST slash) and `model` is the rest. + * These pure functions split that into a key selector + a model selector and + * recombine the choice — zero DOM, zero Svelte. + */ + +export interface SplitModel { + readonly key: string; + readonly model: string; +} + +/** Split `<key>/<model>` on the first slash. A slashless name is all-key. */ +export function splitModelName(full: string): SplitModel { + const i = full.indexOf("/"); + if (i === -1) return { key: full, model: "" }; + return { key: full.slice(0, i), model: full.slice(i + 1) }; +} + +/** Recombine a key + model into a `<key>/<model>` name (key-only if no model). */ +export function joinModelName(key: string, model: string): string { + return model === "" ? key : `${key}/${model}`; +} + +/** Distinct keys across all models, in first-seen order. */ +export function modelKeys(models: readonly string[]): string[] { + const seen = new Set<string>(); + const out: string[] = []; + for (const full of models) { + const { key } = splitModelName(full); + if (!seen.has(key)) { + seen.add(key); + out.push(key); + } + } + return out; +} + +/** The model suffixes available under a given key, in order. */ +export function modelsForKey(models: readonly string[], key: string): string[] { + const out: string[] = []; + for (const full of models) { + const split = splitModelName(full); + if (split.key === key) out.push(split.model); + } + return out; +} diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index ddec388..278b2cf 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -1,5 +1,5 @@ import type { StepId } from "@dispatch/wire"; -import { render, screen } from "@testing-library/svelte"; +import { render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { RenderedChunk } from "../../core/chunks"; @@ -605,36 +605,54 @@ describe("Composer", () => { }); describe("ModelSelector", () => { - it("renders the options and current selection", () => { - const models = ["openai/gpt-4", "anthropic/claude-3", "google/gemini"]; + const optionValues = (el: HTMLElement): string[] => + within(el) + .getAllByRole("option") + .map((o) => (o as HTMLOptionElement).value); + + it("renders a key selector (distinct keys) and a model selector (models for the current key)", () => { + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3", "google/gemini"]; render(ModelSelector, { props: { models, selected: "anthropic/claude-3", onSelect: vi.fn() }, }); - const select = screen.getByRole("combobox", { name: "Model selector" }); - expect(select).toBeInTheDocument(); - expect(select).toHaveValue("anthropic/claude-3"); + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(keySelect).toHaveValue("anthropic"); + expect(modelSelect).toHaveValue("claude-3"); - const options = screen.getAllByRole("option"); - expect(options).toHaveLength(3); - expect(options[0]).toHaveValue("openai/gpt-4"); - expect(options[1]).toHaveValue("anthropic/claude-3"); - expect(options[2]).toHaveValue("google/gemini"); + expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // only the models under the selected key + expect(optionValues(modelSelect)).toEqual(["claude-3"]); }); - it("calls onSelect on change", async () => { + it("selecting a key switches to the first model under it", async () => { const onSelect = vi.fn(); const user = userEvent.setup(); - const models = ["openai/gpt-4", "anthropic/claude-3"]; + const models = ["openai/gpt-4", "openai/gpt-4o", "anthropic/claude-3"]; render(ModelSelector, { - props: { models, selected: "openai/gpt-4", onSelect }, + props: { models, selected: "openai/gpt-4o", onSelect }, }); - const select = screen.getByRole("combobox", { name: "Model selector" }); - await user.selectOptions(select, "anthropic/claude-3"); + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "anthropic"); expect(onSelect).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledWith("anthropic/claude-3"); }); + + it("selecting a model keeps the current key", async () => { + const onSelect = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4", "openai/gpt-4o"]; + + render(ModelSelector, { + props: { models, selected: "openai/gpt-4", onSelect }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Model selector" }), "gpt-4o"); + + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); + }); }); diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 3e25ec3..a288cb8 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,4 +1,6 @@ <script lang="ts"> + import { joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + let { models, selected, @@ -8,15 +10,41 @@ selected: string; onSelect: (model: string) => void; } = $props(); + + const keys = $derived(modelKeys(models)); + const current = $derived(splitModelName(selected)); + const keyModels = $derived(modelsForKey(models, current.key)); + + // Switching key jumps to the first model available under it. + function selectKey(key: string): void { + const first = modelsForKey(models, key)[0] ?? ""; + onSelect(joinModelName(key, first)); + } + + function selectModel(model: string): void { + onSelect(joinModelName(current.key, model)); + } </script> -<select - class="select" - value={selected} - onchange={(e) => onSelect(e.currentTarget.value)} - aria-label="Model selector" -> - {#each models as model (model)} - <option value={model}>{model}</option> - {/each} -</select> +<div class="flex flex-col gap-2"> + <select + class="select w-full" + value={current.key} + onchange={(e) => selectKey(e.currentTarget.value)} + aria-label="Key selector" + > + {#each keys as key (key)} + <option value={key}>{key}</option> + {/each} + </select> + <select + class="select w-full" + value={current.model} + onchange={(e) => selectModel(e.currentTarget.value)} + aria-label="Model selector" + > + {#each keyModels as model (model)} + <option value={model}>{model}</option> + {/each} + </select> +</div> |
