summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui/ModelSelector.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
committerAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
commitfb7eb3105e1fbbdefdad9348f9132815b5bbe52d (patch)
treedeb95e97a917424ea67470b168b91349a41d1c08 /src/features/chat/ui/ModelSelector.svelte
parent63588c3c69b32d180dc78a2cc187cc8aa22ad7e0 (diff)
downloaddispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.tar.gz
dispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.zip
feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics
Implement the frontend of the Named Agents feature (plan §5-§9): - New features/named-agents module: pure logic (types re-exported from @dispatch/wire + transport-contract, validation, provider/model options, snapshot helpers, network normalizers, auto-save status model) + an injected auto-save controller (name debounce, immediate provider/model save, monotonic stale-response guard) + the collapsible sidebar card view. - Store: /named-agents CRUD + boot seed; selectModel(model, snapshot?) persists the raw model WITH the namedAgent snapshot; draft promotion carries it; refreshModel restores ModelResponse.namedAgent. - Chat store setNamedAgent (sends namedAgent on chat.send); Tab.snapshot + setSnapshot (retained across rename/remove, persisted with tabs). - Model selector: synthetic Named Agents provider ALWAYS first; selecting one resolves to the raw model + records a snapshot; a retained snapshot of a renamed/removed agent still displays as unavailable (plan §7). - Composition root wires the view (viewKinds + loaded-modules manifest) + feeds the selector. Tests: logic + controller + selector + app-store CRUD (1246 pass), typecheck 0, biome clean (only pre-existing config infos pending a future migrate), build OK.
Diffstat (limited to 'src/features/chat/ui/ModelSelector.svelte')
-rw-r--r--src/features/chat/ui/ModelSelector.svelte198
1 files changed, 147 insertions, 51 deletions
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index 1d06848..c66d3dd 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,11 +1,16 @@
<script lang="ts">
import type { ModelMetadata } from "@dispatch/transport-contract";
+ import type { NamedAgent, NamedAgentSelectionSnapshot } from "../../named-agents";
import {
+ isNamedAgentSelection,
isVisionModel,
joinModelName,
modelKeyLabel,
- modelKeys,
modelsForKey,
+ NAMED_AGENTS_PROVIDER,
+ namedAgentNames,
+ namedAgentSelectionLabel,
+ providerKeysWithNamedAgents,
splitModelName,
} from "../model-select";
@@ -14,6 +19,9 @@
selected,
onSelect,
modelInfo = {},
+ namedAgents = [],
+ namedAgentSnapshot = null,
+ onSelectNamedAgent,
}: {
models: readonly string[];
selected: string;
@@ -25,35 +33,88 @@
* Optional — absent metadata → no badge (treated as non-vision).
*/
modelInfo?: Readonly<Record<string, ModelMetadata>>;
+ /** The user-curated Named Agents (plan §6) offered under the synthetic provider. */
+ namedAgents?: readonly NamedAgent[];
+ /** The active conversation's Named Agent snapshot (null when not selected via one). */
+ namedAgentSnapshot?: NamedAgentSelectionSnapshot | null;
+ /** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */
+ onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void;
} = $props();
- const keys = $derived(modelKeys(models));
+ // Whether the current selection is a Named Agent snapshot (the conversation was
+ // selected via one; its captured raw model equals `selected`).
+ const currentIsNamedAgent = $derived(isNamedAgentSelection(selected, namedAgentSnapshot));
+
+ const providerKeys = $derived(providerKeysWithNamedAgents(models));
const current = $derived(splitModelName(selected));
const keyModels = $derived(modelsForKey(models, current.key));
+ // The provider shown in the provider dropdown: the synthetic Named Agents
+ // provider when the selection is a Named Agent snapshot, else the raw key.
+ const currentProvider = $derived(
+ currentIsNamedAgent || current.key === NAMED_AGENTS_PROVIDER
+ ? NAMED_AGENTS_PROVIDER
+ : current.key,
+ );
+
+ const namedNames = $derived(namedAgentNames(namedAgents));
+
// Whether the currently-selected full model name is vision-capable.
const selectedVision = $derived(isVisionModel(modelInfo, selected));
- // Whether the currently-selected full name is PRESENT in the catalog. When a
- // saved credential/model disappears, we show it as unavailable rather than
- // silently switching to something else.
- const selectedUnavailable = $derived(selected !== "" && !models.includes(selected));
- // The key part is itself missing from the catalog (e.g. a whole numbered
- // OpenCode credential was removed).
- const keyUnavailable = $derived(selected !== "" && !keys.includes(current.key));
-
- // Switching key jumps to the first model available under it.
- function selectKey(key: string): void {
- if (key === current.key) return;
+ // Whether the currently-selected raw model is PRESENT in the catalog (only
+ // meaningful for a raw selection — a Named Agent snapshot resolves to a raw
+ // model that may have left the catalog).
+ const rawMissing = $derived(
+ !currentIsNamedAgent && selected !== "" && !models.includes(selected),
+ );
+ const keyUnavailable = $derived(
+ !currentIsNamedAgent && selected !== "" && !providerKeys.includes(current.key),
+ );
+
+ // Switching provider. Going TO the Named Agents provider: jump to the first
+ // Named Agent (mirrors selectKey jumping to the first model); the model
+ // dropdown then lists named names. Going to a raw provider: existing behavior.
+ function selectProvider(key: string): void {
+ if (key === currentProvider) return;
+ if (key === NAMED_AGENTS_PROVIDER) {
+ selectFirstNamedAgent();
+ return;
+ }
const first = modelsForKey(models, key)[0] ?? "";
onSelect(joinModelName(key, first));
}
- function selectModel(model: string): void {
+ // Selecting a raw model suffix under the current raw provider.
+ function selectRawModel(model: string): void {
onSelect(joinModelName(current.key, model));
}
- // The full `<key>/<model>` name for a model suffix under the current key.
+ // Selecting a Named Agent name (under the synthetic provider): resolve it to a
+ // snapshot + raw model and report via onSelectNamedAgent.
+ function selectNamedAgent(name: string): void {
+ const agent = namedAgents.find((a) => a.name === name);
+ if (agent === undefined) return;
+ onSelectNamedAgent?.({
+ namedAgentId: agent.id,
+ namedAgentName: agent.name,
+ model: agent.model,
+ });
+ }
+
+ // Jump to the first configured Named Agent (used when the provider is switched
+ // to Named Agents), matching the raw-provider "jump to first model" behavior.
+ function selectFirstNamedAgent(): void {
+ const first = namedAgents[0];
+ if (first === undefined) return; // no named agents — the model dropdown shows the disabled placeholder
+ onSelectNamedAgent?.({
+ namedAgentId: first.id,
+ namedAgentName: first.name,
+ model: first.model,
+ });
+ }
+
+ // The full `<provider>/<model>` name for a raw model suffix under `current.key`.
function fullNameFor(modelSuffix: string): string {
return joinModelName(current.key, modelSuffix);
}
@@ -62,58 +123,93 @@
<div class="flex flex-col gap-2">
<select
class="select w-full"
- value={current.key}
- onchange={(e) => selectKey(e.currentTarget.value)}
+ value={currentProvider}
+ onchange={(e) => selectProvider(e.currentTarget.value)}
aria-label="Key selector"
>
- {#if keyUnavailable}
+ {#if keyUnavailable && currentProvider !== NAMED_AGENTS_PROVIDER}
<option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option>
{/if}
- {#each keys as key (key)}
- <option value={key}>{modelKeyLabel(key)}</option>
+ {#each providerKeys as key (key)}
+ <option value={key}>
+ {key === NAMED_AGENTS_PROVIDER ? "Named Agents" : modelKeyLabel(key)}
+ </option>
{/each}
</select>
+
<select
class="select w-full"
- value={current.model}
- onchange={(e) => selectModel(e.currentTarget.value)}
+ value={currentIsNamedAgent ? (namedAgentSnapshot?.namedAgentName ?? "") : current.model}
+ onchange={
+ currentIsNamedAgent
+ ? (e) => selectNamedAgent(e.currentTarget.value)
+ : (e) => selectRawModel(e.currentTarget.value)
+ }
aria-label="Model selector"
+ disabled={currentProvider === NAMED_AGENTS_PROVIDER && namedAgents.length === 0}
>
- {#each keyModels as model (model)}
- <option value={model}>
- {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
- </option>
- {/each}
- {#if selectedUnavailable && !keyUnavailable}
- <option value={current.model} disabled>{current.model} (unavailable)</option>
+ {#if currentProvider === NAMED_AGENTS_PROVIDER}
+ {#if namedAgents.length === 0 && !currentIsNamedAgent}
+ <option value="" disabled>No named agents configured</option>
+ {:else}
+ {#each namedNames as name (name)}
+ <option value={name}>{namedAgentSelectionLabel(name)}</option>
+ {/each}
+ {#if currentIsNamedAgent && !namedNames.includes(namedAgentSnapshot?.namedAgentName ?? "")}
+ <!-- The selected Named Agent was renamed/removed — keep showing the
+ captured snapshot name rather than silently switching (plan §7). -->
+ <option value={namedAgentSnapshot?.namedAgentName ?? ""} disabled>
+ {namedAgentSelectionLabel(namedAgentSnapshot?.namedAgentName ?? "")} (unavailable)
+ </option>
+ {/if}
+ {/if}
+ {:else}
+ {#each keyModels as model (model)}
+ <option value={model}>
+ {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
+ </option>
+ {/each}
+ {#if rawMissing && !keyUnavailable}
+ <option value={current.model} disabled>{current.model} (unavailable)</option>
+ {/if}
{/if}
</select>
- {#if selectedUnavailable}
+
+ {#if rawMissing}
<div class="text-xs text-warning" role="status">
Selected model “{selected}” is unavailable — choose another credential.
</div>
{/if}
- {#if selectedVision}
- <div class="flex items-center gap-1 text-xs text-base-content/60">
- <svg
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="none"
- stroke="currentColor"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- class="h-3.5 w-3.5"
- aria-hidden="true"
- >
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
- <circle cx="12" cy="12" r="3"></circle>
- </svg>
- <span>Vision — this model sees images natively</span>
- </div>
- {:else}
+
+ {#if currentIsNamedAgent}
<div class="text-xs text-base-content/40">
- Pasted images are auto-described (vision handoff)
+ Selected via <strong>Named Agents</strong> — snapshot of {namedAgentSnapshot?.namedAgentName}.
</div>
{/if}
-</div>
+
+ {#if !currentIsNamedAgent}
+ {#if selectedVision}
+ <div class="flex items-center gap-1 text-xs text-base-content/60">
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ class="h-3.5 w-3.5"
+ aria-hidden="true"
+ >
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+ <circle cx="12" cy="12" r="3"></circle>
+ </svg>
+ <span>Vision — this model sees images natively</span>
+ </div>
+ {:else}
+ <div class="text-xs text-base-content/40">
+ Pasted images are auto-described (vision handoff)
+ </div>
+ {/if}
+ {/if}
+</div> \ No newline at end of file