From b3f7ba523f644224364d155b575fa3f9f13c5eb9 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 10 Jun 2026 16:48:30 +0900 Subject: 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 `/` string (ChatRequest.model unchanged) --- src/app/App.svelte | 22 ++++++------ src/features/chat/model-select.test.ts | 58 +++++++++++++++++++++++++++++++ src/features/chat/model-select.ts | 49 ++++++++++++++++++++++++++ src/features/chat/ui.test.ts | 50 +++++++++++++++++--------- src/features/chat/ui/ModelSelector.svelte | 48 +++++++++++++++++++------ 5 files changed, 190 insertions(+), 37 deletions(-) create mode 100644 src/features/chat/model-select.test.ts create mode 100644 src/features/chat/model-select.ts 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 @@ {/if} -
- -
-
{#key store.activeConversationId} @@ -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%'})" > - +
@@ -158,7 +156,9 @@ {#snippet viewContent(kind: string)} - {#if kind === "extensions"} + {#if kind === "model"} + + {:else if kind === "extensions"}

Frontend modules

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 `/` 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 `/` 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 `/` 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(); + 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 @@ - +
+ + +
-- cgit v1.2.3