summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-10 16:48:30 +0900
committerAdam Malczewski <[email protected]>2026-06-10 16:48:30 +0900
commitb3f7ba523f644224364d155b575fa3f9f13c5eb9 (patch)
tree1d131f624fe2e78c3a8ee050d4888b5ddec3f2cc
parent871957b930203c019e631c4606cfdf8266d222fa (diff)
downloaddispatch-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.svelte22
-rw-r--r--src/features/chat/model-select.test.ts58
-rw-r--r--src/features/chat/model-select.ts49
-rw-r--r--src/features/chat/ui.test.ts50
-rw-r--r--src/features/chat/ui/ModelSelector.svelte48
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>