summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
committerAdam Malczewski <[email protected]>2026-05-20 20:40:35 +0900
commit8151447758e6826a578363758a755c6cebd1c05f (patch)
tree6afa780c28ca6e4622c1ab30238665caaad4371e /packages/frontend/src/lib/components
parentf05099d450748cc7508f8cbde4e6539db2105f6d (diff)
downloaddispatch-8151447758e6826a578363758a755c6cebd1c05f.tar.gz
dispatch-8151447758e6826a578363758a755c6cebd1c05f.zip
feat: claude max oauth support with multi-account switching, reasoning effort, and dynamic model listing
Diffstat (limited to 'packages/frontend/src/lib/components')
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte13
-rw-r--r--packages/frontend/src/lib/components/Header.svelte2
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte7
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte187
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte53
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte2
6 files changed, 259 insertions, 5 deletions
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
index 387dbdc..c8b3f6e 100644
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -6,8 +6,20 @@ import ToolCallDisplay from "./ToolCallDisplay.svelte";
const { message }: { message: ChatMessage } = $props();
const isUser = $derived(message.role === "user");
+const isSystem = $derived(message.role === "system");
</script>
+{#if isSystem}
+ <div class="flex justify-center my-2">
+ <div class="badge badge-ghost gap-1 text-xs opacity-60">
+ {#each message.content as segment}
+ {#if segment.type === "text"}
+ {segment.text}
+ {/if}
+ {/each}
+ </div>
+ </div>
+{:else}
<div class="chat chat-start mb-2">
<div class="chat-bubble max-w-[80%] break-words {isUser ? 'chat-bubble-primary' : 'bg-transparent'}">
{#if message.thinking}
@@ -31,3 +43,4 @@ const isUser = $derived(message.role === "user");
{/if}
</div>
</div>
+{/if}
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
index 5d14593..721a773 100644
--- a/packages/frontend/src/lib/components/Header.svelte
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -30,7 +30,7 @@ async function handleCopy() {
</div>
<div class="navbar-end flex items-center gap-3">
<span class="text-xs text-base-content/60 hidden sm:block">
- DeepSeek V4 Flash via OpenCode Go
+ {chatStore.activeModelId ?? "Default Model"}
</span>
<button
type="button"
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
index f9925c6..f73140f 100644
--- a/packages/frontend/src/lib/components/MarkdownRenderer.svelte
+++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
@@ -92,9 +92,10 @@
const promise = (async () => {
try {
- // Vite resolves this template literal at build time into a glob
- // over all matching language modules, so each is a separate chunk.
- const mod = await import(`highlight.js/lib/languages/${name}`);
+ // Dynamic import for languages not in the hot set above.
+ // @vite-ignore: the variable `name` is intentionally dynamic;
+ // missing modules are caught by the try/catch below.
+ const mod = await import(/* @vite-ignore */ `highlight.js/lib/languages/${name}`);
hljs.registerLanguage(name, mod.default);
return true;
} catch {
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
new file mode 100644
index 0000000..b88b2e3
--- /dev/null
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -0,0 +1,187 @@
+<script module>
+ const modelCache = new Map<string, string[]>();
+</script>
+
+<script lang="ts">
+ import type { KeyInfo } from "../types.js";
+ import { config } from "../config.js";
+
+ // Moves an element to document.body so modals escape the sidebar's
+ // transform stacking context and cover the full viewport.
+ function portal(node: HTMLElement) {
+ document.body.appendChild(node);
+ return {
+ destroy() {
+ node.remove();
+ },
+ };
+ }
+
+ const {
+ keys = [],
+ activeKeyId = null,
+ activeModelId = null,
+ reasoningEffort = "max",
+ onKeyChange,
+ onModelChange,
+ onReasoningChange,
+ }: {
+ keys?: KeyInfo[];
+ activeKeyId?: string | null;
+ activeModelId?: string | null;
+ reasoningEffort?: string;
+ onKeyChange: (keyId: string) => void;
+ onModelChange: (keyId: string, modelId: string) => void;
+ onReasoningChange: (effort: string) => void;
+ } = $props();
+
+ let showKeyModal = $state(false);
+ let showModelModal = $state(false);
+ let availableModels = $state<string[]>([]);
+ let loadingModels = $state(false);
+ let modelError = $state<string | null>(null);
+
+ function selectKey(keyId: string) {
+ showKeyModal = false;
+ onKeyChange(keyId);
+ }
+
+ async function openModelModal() {
+ if (!activeKeyId) return;
+ showModelModal = true;
+ modelError = null;
+
+ // Check session cache
+ if (modelCache.has(activeKeyId)) {
+ availableModels = modelCache.get(activeKeyId)!;
+ loadingModels = false;
+ return;
+ }
+
+ loadingModels = true;
+ availableModels = [];
+
+ try {
+ const res = await fetch(
+ `${config.apiBase}/models/available?keyId=${encodeURIComponent(activeKeyId)}`,
+ );
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ modelError = data.error ?? `Failed to fetch models (HTTP ${res.status})`;
+ return;
+ }
+ const data = await res.json();
+ availableModels = data.models ?? [];
+ // Cache for session
+ modelCache.set(activeKeyId, availableModels);
+ } catch (err) {
+ modelError = err instanceof Error ? err.message : "Failed to fetch models";
+ } finally {
+ loadingModels = false;
+ }
+ }
+
+ function selectModel(model: string) {
+ showModelModal = false;
+ if (activeKeyId) {
+ onModelChange(activeKeyId, model);
+ }
+ }
+</script>
+
+<div class="bg-base-200 rounded-lg p-3">
+ <div class="flex items-center justify-between">
+ <span class="text-sm font-medium">Key</span>
+ <button class="btn btn-sm btn-outline" onclick={() => (showKeyModal = true)}>
+ {activeKeyId ?? "Select Key"}
+ </button>
+ </div>
+
+ <div class="flex items-center justify-between mt-2">
+ <span class="text-sm font-medium">Model</span>
+ <button class="btn btn-sm btn-outline" onclick={openModelModal} disabled={!activeKeyId}>
+ {activeModelId ?? "Select Model"}
+ </button>
+ </div>
+
+ {#if activeModelId}
+ <div class="flex items-center justify-between mt-2">
+ <span class="text-sm font-medium">Thinking</span>
+ <select
+ class="select select-bordered select-sm"
+ value={reasoningEffort}
+ onchange={(e) => onReasoningChange(e.currentTarget.value)}
+ >
+ <option value="none">Off</option>
+ <option value="low">Low</option>
+ <option value="medium">Medium</option>
+ <option value="high">High</option>
+ <option value="max">Max</option>
+ </select>
+ </div>
+ {/if}
+</div>
+
+{#if showKeyModal}
+ <div class="modal modal-open" use:portal>
+ <div class="modal-box">
+ <h3 class="font-bold text-xl">Select Key</h3>
+ <div class="mt-4 flex flex-col gap-2">
+ {#each keys as key}
+ <button
+ class="btn {key.id === activeKeyId
+ ? 'btn-primary'
+ : 'btn-ghost'} justify-start text-base"
+ onclick={() => selectKey(key.id)}
+ >
+ <span class="font-mono">{key.id}</span>
+ <span class="badge ml-auto">{key.provider}</span>
+ <span
+ class="badge {key.status === 'active'
+ ? 'badge-success'
+ : 'badge-error'}">{key.status}</span
+ >
+ </button>
+ {/each}
+ </div>
+ <div class="modal-action">
+ <button class="btn" onclick={() => (showKeyModal = false)}>Cancel</button>
+ </div>
+ </div>
+ <button type="button" class="modal-backdrop" onclick={() => (showKeyModal = false)} aria-label="Close modal"></button>
+ </div>
+{/if}
+
+{#if showModelModal}
+ <div class="modal modal-open" use:portal>
+ <div class="modal-box">
+ <h3 class="font-bold text-xl">Select Model</h3>
+ {#if loadingModels}
+ <div class="flex justify-center py-8">
+ <span class="loading loading-spinner loading-lg"></span>
+ </div>
+ {:else if modelError}
+ <div class="alert alert-error mt-4 text-base">
+ <span>{modelError}</span>
+ </div>
+ {:else}
+ <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto">
+ {#each availableModels as model}
+ <button
+ class="btn {model === activeModelId
+ ? 'btn-primary'
+ : 'btn-ghost'} justify-start font-mono text-base"
+ onclick={() => selectModel(model)}
+ >
+ {model}
+ </button>
+ {/each}
+ </div>
+ {/if}
+ <div class="modal-action">
+ <button class="btn" onclick={() => (showModelModal = false)}>Cancel</button>
+ </div>
+ </div>
+ <button type="button" class="modal-backdrop" onclick={() => (showModelModal = false)} aria-label="Close modal"></button>
+ </div>
+{/if}
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
new file mode 100644
index 0000000..fe92486
--- /dev/null
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -0,0 +1,53 @@
+<script lang="ts">
+ import ModelStatus from "./ModelStatus.svelte";
+ import TaskListPanel from "./TaskListPanel.svelte";
+ import ConfigPanel from "./ConfigPanel.svelte";
+ import SkillsBrowser from "./SkillsBrowser.svelte";
+ import PermissionLog from "./PermissionLog.svelte";
+ import type { TaskItem, LogEntry, KeyInfo, ModelInfo } from "../types.js";
+
+ const {
+ models = [],
+ keys = [],
+ tags = [],
+ tasks = [],
+ permissionLog = [],
+ apiBase = "",
+ }: {
+ models?: ModelInfo[];
+ keys?: KeyInfo[];
+ tags?: string[];
+ tasks?: TaskItem[];
+ permissionLog?: LogEntry[];
+ apiBase?: string;
+ } = $props();
+
+ let selected = $state("Tasks");
+
+ const options = ["Model Status", "Tasks", "Config", "Skills", "Permission Log"];
+</script>
+
+<div class="bg-base-200 rounded-lg p-3">
+ <select
+ class="select select-bordered select-sm w-full"
+ bind:value={selected}
+ >
+ {#each options as option}
+ <option value={option}>{option}</option>
+ {/each}
+ </select>
+
+ <div class="mt-2">
+ {#if selected === "Model Status"}
+ <ModelStatus {models} {keys} {tags} />
+ {:else if selected === "Tasks"}
+ <TaskListPanel {tasks} />
+ {:else if selected === "Config"}
+ <ConfigPanel {apiBase} />
+ {:else if selected === "Skills"}
+ <SkillsBrowser {apiBase} />
+ {:else if selected === "Permission Log"}
+ <PermissionLog entries={permissionLog} />
+ {/if}
+ </div>
+</div>
diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
index d85372c..805de6d 100644
--- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte
+++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
@@ -3,7 +3,7 @@ import type { ToolCallDisplay } from "../types.js";
const { toolCall }: { toolCall: ToolCallDisplay } = $props();
-let isExpanded = $state(toolCall.isExpanded);
+let isExpanded = $state(false);
function toggle() {
isExpanded = !isExpanded;