summaryrefslogtreecommitdiffhomepage
path: root/src/app
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-08-12 22:01:59 +0900
committerAdam Malczewski <[email protected]>2026-08-12 22:01:59 +0900
commit5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443 (patch)
tree2f1d5ee2674d1dcc9909e89f06a82a7b10b4191a /src/app
parent774519e052fd869952cc5e05aca159e4bf978fa1 (diff)
downloaddispatch-web-5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443.tar.gz
dispatch-web-5f10a40b26dbfdc10b2c02cdf23ec3a7edfa9443.zip
feat(models): numbered OpenCode credential selection, labels, migration
Adopt the numbered OPENCODE_API_KEY_N scheme in the model picker and concurrency surfaces. - model-select: numeric sort of opencode-<n> keys (#2 before #10); friendly OpenCode #N labels (raw opencode-N/<model> value still selected/persisted); migrateLegacyModelName rewrites opencode/<model> -> opencode-1/<model>. - ModelSelector: renders friendly key labels; surfaces a disappeared saved credential as UNAVAILABLE (disabled option + notice) without silently switching to another model. - app store: DEFAULT_MODEL -> opencode-1/...; catalogs no longer auto-fallback the active model when the selected credential is absent; boot restore migrates persisted legacy opencode/<model> tab models idempotently. - concurrency: providerLabel renders opencode-<n> as OpenCode #<n> in the limit rows and Add dropdown (raw provider id preserved on the wire). - Tests: parsing/sorting/labels/migration, unavailable-state selector behavior, no-silent-fallback, and concurrency provider label; fixtures updated to the numbered scheme. Verification: bun install, bun run typecheck (0 errors), bun run test (1179 passed), bun run check (clean, exit 0), bun run build (vite OK).
Diffstat (limited to 'src/app')
-rw-r--r--src/app/App.test.ts4
-rw-r--r--src/app/store.svelte.ts33
-rw-r--r--src/app/store.test.ts73
3 files changed, 86 insertions, 24 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index f43a6b8..06e562f 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -58,7 +58,7 @@ function fakeFetchImpl(): typeof fetch {
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -492,7 +492,7 @@ describe("App component interaction tests", () => {
const failingFetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 8997ce0..e11a445 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -53,7 +53,7 @@ import {
unsubscribe as protocolUnsubscribe,
} from "../core/protocol";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
-import { createChatStore } from "../features/chat";
+import { createChatStore, migrateLegacyModelName } from "../features/chat";
import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
import type {
ConcurrencyCooldownResult,
@@ -91,7 +91,7 @@ import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
import { randomId } from "./uuid";
-const DEFAULT_MODEL = "opencode/deepseek-v4-flash";
+const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash";
/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */
export type WarmResult =
@@ -1112,7 +1112,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// authoritative conversation record is the source of truth, so a
// differing tab workspace is corrected here.
tabsStore.setTitle(conv.id, conv.title);
- if (tabsStore.tabs.find((t) => t.conversationId === conv.id)?.workspaceId !== conv.workspaceId) {
+ if (
+ tabsStore.tabs.find((t) => t.conversationId === conv.id)?.workspaceId !==
+ conv.workspaceId
+ ) {
tabsStore.setWorkspaceId(conv.id, conv.workspaceId);
}
}
@@ -1231,12 +1234,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (data === undefined) return;
models = data.models;
modelInfo = data.modelInfo ?? {};
- if (data.models.length > 0 && !data.models.includes(activeModel)) {
- const first = data.models[0];
- if (first !== undefined) {
- activeModel = first;
- draftStore.setModel(first);
- }
+ // Do NOT silently switch the active model when the current selection is
+ // missing from the catalog (e.g. a saved numbered OpenCode credential was
+ // removed). The model selector surfaces it as unavailable; the user picks
+ // explicitly. activeModel is still normalized through the legacy-migration
+ // helper so an old `opencode/...` value is rewritten once.
+ if (migrateLegacyModelName(activeModel) !== activeModel) {
+ activeModel = migrateLegacyModelName(activeModel);
}
})
.catch((err) => {
@@ -1262,7 +1266,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const persistedState = storageAdapter.load();
if (persistedState !== null && persistedState.tabs.length > 0) {
for (const tab of persistedState.tabs) {
- const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId);
+ // One-time persisted-state migration: rewrite a legacy `opencode/<model>`
+ // tab model to the numbered `opencode-1/<model>` namespace, and persist the
+ // migrated value back so a re-load is a no-op (idempotent).
+ const model = migrateLegacyModelName(tab.model);
+ if (model !== tab.model) {
+ tabsStore.setModel(tab.conversationId, model);
+ }
+ const store = createChatFor(tab.conversationId, model, tab.workspaceId);
chatStores.set(tab.conversationId, store);
void store.load();
// Watch each restored conversation's live turns: after a reload mid-turn the
@@ -1275,7 +1286,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
(t) => t.conversationId === persistedState.activeConversationId,
);
if (activeTab !== undefined) {
- activeModel = activeTab.model;
+ activeModel = migrateLegacyModelName(activeTab.model);
}
}
}
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 47c3977..16d1760 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -107,7 +107,7 @@ interface FakeFetchOptions {
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
- const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"];
const history = opts?.history ?? {};
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
@@ -524,7 +524,7 @@ describe("createAppStore", () => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -573,7 +573,7 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl({
- models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
+ models: ["opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
}),
localStorage: createFakeStorage(),
});
@@ -582,7 +582,7 @@ describe("createAppStore", () => {
await new Promise((r) => setTimeout(r, 50));
expect(store.models).toEqual([
- "opencode/deepseek-v4-flash",
+ "opencode-1/deepseek-v4-flash",
"openai/gpt-4o",
"anthropic/claude-3",
]);
@@ -599,11 +599,62 @@ describe("createAppStore", () => {
});
ws.resolveOpen();
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
store.dispose();
});
+ it("does not silently switch the active model when the selected one is absent from the catalog", async () => {
+ // The catalog only has umans; the active (default/selected) OpenCode model is
+ // NOT available. The FE must NOT auto-fall back to the first catalog model —
+ // it keeps the selection and lets the model selector surface it as unavailable.
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(store.models).toEqual(["umans/umans-glm-5.2"]);
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => {
+ const ws = fakeSocket();
+ // Seed localStorage with a persisted tab whose model is the OBSOLETE
+ // `opencode/deepseek-v4-flash` namespace.
+ const storage = createFakeStorage();
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: [
+ {
+ conversationId: "c-legacy",
+ title: "Legacy",
+ model: "opencode/deepseek-v4-flash",
+ workspaceId: "default",
+ },
+ ],
+ activeConversationId: null,
+ }),
+ );
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+ // After restore, the restored tab's model + the persisted store are migrated.
+ const restored = store.tabs.find((t) => t.conversationId === "c-legacy");
+ expect(restored?.model).toBe("opencode-1/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+ store.dispose();
+ });
+
it("draft: sending the first message creates a tab titled from the message", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -679,7 +730,7 @@ describe("createAppStore", () => {
store.send("first message");
// New tab opens with the default model until the persisted-model fetch resolves.
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
// Wait for the persisted model fetch to resolve.
await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
@@ -1212,7 +1263,7 @@ describe("createAppStore", () => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -1518,7 +1569,7 @@ describe("createAppStore", () => {
const limits = opts?.limits ?? {
limits: [
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
],
};
const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
@@ -1526,7 +1577,7 @@ describe("createAppStore", () => {
providers: [
{ providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
{
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
@@ -1586,7 +1637,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.limits).toEqual([
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
]);
store.dispose();
});
@@ -1758,7 +1809,7 @@ describe("createAppStore", () => {
autoReduced: false,
});
expect(result.providers[1]).toMatchObject({
- providerId: "openai-compat",
+ providerId: "opencode-1",
paused: true,
pausedUntil: 1_719_408_000_000,
cooldownMs: 350,