summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 19:40:21 +0900
committerAdam Malczewski <[email protected]>2026-05-19 19:40:21 +0900
commitf78a91c20f658dd404277919a0b872b352c99bb6 (patch)
tree58cfffb655da4443f4b7a39543b86f988f15239f /packages/frontend/src/lib/components
downloaddispatch-main.tar.gz
dispatch-main.zip
Phase 1: single agent + basic UIHEADmain
- Bun monorepo with @dispatch/core, @dispatch/api, @dispatch/frontend - Agent runtime with Vercel AI SDK, streaming via WebSocket - Tools: read_file, write_file, list_files (scoped to working directory) - Hono API server with POST /chat, GET /status, GET /health, WS /ws - Svelte 5 + DaisyUI frontend with chat UI, theme switcher, copy button - OpenCode Go (Zen) as LLM provider, deepseek-v4-flash-free model - Docker setup (dev + prod) with bin/ scripts and gopass secrets - Biome v2 linting/formatting, Vitest tests (44 passing) - Debug info attached to error messages for diagnostics
Diffstat (limited to 'packages/frontend/src/lib/components')
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte45
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte26
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte58
-rw-r--r--packages/frontend/src/lib/components/Header.svelte54
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte65
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte50
6 files changed, 298 insertions, 0 deletions
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
new file mode 100644
index 0000000..b929923
--- /dev/null
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+import { chatStore } from "../chat.svelte.js";
+
+let inputEl: HTMLInputElement | undefined;
+let inputValue = $state("");
+const isDisabled = $derived(chatStore.agentStatus === "running");
+
+$effect(() => {
+ inputEl?.focus();
+});
+
+function handleKeydown(e: KeyboardEvent) {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ submit();
+ }
+}
+
+function submit() {
+ const text = inputValue.trim();
+ if (!text || isDisabled) return;
+ inputValue = "";
+ chatStore.sendMessage(text);
+}
+</script>
+
+<div class="flex items-center gap-2 p-3 border-t border-base-300 bg-base-100">
+ <input
+ bind:this={inputEl}
+ bind:value={inputValue}
+ type="text"
+ placeholder={isDisabled ? "Agent is running..." : "Type a message..."}
+ class="input input-bordered flex-1"
+ disabled={isDisabled}
+ onkeydown={handleKeydown}
+ />
+ <button
+ type="button"
+ class="btn btn-primary"
+ disabled={isDisabled || !inputValue.trim()}
+ onclick={submit}
+ >
+ Send
+ </button>
+</div>
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
new file mode 100644
index 0000000..447bb29
--- /dev/null
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+import type { ChatMessage } from "../types.js";
+import ToolCallDisplay from "./ToolCallDisplay.svelte";
+
+const { message }: { message: ChatMessage } = $props();
+
+const isUser = $derived(message.role === "user");
+</script>
+
+<div class="chat {isUser ? 'chat-end' : 'chat-start'} mb-2">
+ <div class="chat-bubble {isUser ? 'chat-bubble-primary' : 'chat-bubble-secondary'} max-w-[80%] break-words">
+ {#if message.content}
+ <span>{message.content}</span>
+ {/if}
+ {#if message.isStreaming}
+ <span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5 align-middle">▌</span>
+ {/if}
+ {#if message.toolCalls && message.toolCalls.length > 0}
+ <div class="mt-2">
+ {#each message.toolCalls as toolCall (toolCall.id)}
+ <ToolCallDisplay {toolCall} />
+ {/each}
+ </div>
+ {/if}
+ </div>
+</div>
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
new file mode 100644
index 0000000..44efb0b
--- /dev/null
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -0,0 +1,58 @@
+<script lang="ts">
+import { chatStore } from "../chat.svelte.js";
+import { wsClient } from "../ws.svelte.js";
+import ChatMessageComponent from "./ChatMessage.svelte";
+
+let messagesEl: HTMLDivElement | undefined;
+
+const statusColor = $derived(
+ wsClient.connectionStatus === "connected"
+ ? "bg-success"
+ : wsClient.connectionStatus === "connecting"
+ ? "bg-warning"
+ : "bg-error",
+);
+
+$effect(() => {
+ // Trigger on messages change to scroll
+ void chatStore.messages;
+ if (messagesEl) {
+ messagesEl.scrollTop = messagesEl.scrollHeight;
+ }
+});
+</script>
+
+<div class="flex flex-col h-full">
+ <!-- Status bar -->
+ <div class="flex items-center gap-3 px-4 py-2 bg-base-200 border-b border-base-300 text-xs">
+ <span class="flex items-center gap-1.5">
+ <span class="w-2 h-2 rounded-full {statusColor}"></span>
+ <span class="capitalize text-base-content/70">{wsClient.connectionStatus}</span>
+ </span>
+ <span class="text-base-content/50">|</span>
+ <span class="text-base-content/70">
+ Agent:
+ <span
+ class="font-semibold {chatStore.agentStatus === 'running'
+ ? 'text-warning'
+ : chatStore.agentStatus === 'error'
+ ? 'text-error'
+ : 'text-success'}"
+ >
+ {chatStore.agentStatus === "running" ? "running..." : chatStore.agentStatus}
+ </span>
+ </span>
+ </div>
+
+ <!-- Messages -->
+ <div bind:this={messagesEl} class="flex-1 overflow-y-auto p-4">
+ {#if chatStore.messages.length === 0}
+ <div class="flex items-center justify-center h-full text-base-content/40 text-sm">
+ Send a message to start a conversation
+ </div>
+ {/if}
+ {#each chatStore.messages as message (message.id)}
+ <ChatMessageComponent {message} />
+ {/each}
+ </div>
+</div>
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
new file mode 100644
index 0000000..79d371c
--- /dev/null
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -0,0 +1,54 @@
+<script lang="ts">
+import { chatStore } from "../chat.svelte.js";
+import ThemeSwitcher from "./ThemeSwitcher.svelte";
+
+let showThemeSwitcher = $state(false);
+let copyLabel = $state("Copy");
+
+function resetCopyLabel() {
+ copyLabel = "Copy";
+}
+
+async function handleCopy() {
+ const text = chatStore.copyConversation();
+ try {
+ await navigator.clipboard.writeText(text);
+ copyLabel = "Copied";
+ setTimeout(resetCopyLabel, 1500);
+ } catch {
+ copyLabel = "Failed";
+ setTimeout(resetCopyLabel, 1500);
+ }
+}
+</script>
+
+<header class="navbar bg-base-200 border-b border-base-300 px-4 min-h-14 flex-shrink-0">
+ <div class="flex-1">
+ <span class="text-xl font-bold tracking-tight">Dispatch</span>
+ </div>
+ <div class="flex-none flex items-center gap-3">
+ <span class="text-xs text-base-content/60 hidden sm:block">
+ DeepSeek V4 Flash via OpenCode Go
+ </span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={handleCopy}
+ aria-label="Copy conversation"
+ >
+ {copyLabel}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={() => (showThemeSwitcher = !showThemeSwitcher)}
+ aria-label="Switch theme"
+ >
+ Theme
+ </button>
+ </div>
+</header>
+
+{#if showThemeSwitcher}
+ <ThemeSwitcher onclose={() => (showThemeSwitcher = false)} />
+{/if}
diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
new file mode 100644
index 0000000..6984e3f
--- /dev/null
+++ b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
@@ -0,0 +1,65 @@
+<script lang="ts">
+const THEMES = [
+ "light",
+ "dark",
+ "dracula",
+ "night",
+ "nord",
+ "sunset",
+ "cyberpunk",
+ "forest",
+ "cmyk",
+ "coffee",
+ "caramellatte",
+] as const;
+
+const STORAGE_KEY = "dispatch-theme";
+
+const { onclose }: { onclose: () => void } = $props();
+
+let currentTheme = $state(
+ (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY)) || "dark",
+);
+
+function selectTheme(theme: string) {
+ currentTheme = theme;
+ document.documentElement.setAttribute("data-theme", theme);
+ localStorage.setItem(STORAGE_KEY, theme);
+ onclose();
+}
+</script>
+
+<!-- Backdrop -->
+<div
+ class="fixed inset-0 z-40 bg-black/40"
+ role="button"
+ tabindex="0"
+ onclick={onclose}
+ onkeydown={(e) => e.key === "Escape" && onclose()}
+ aria-label="Close theme switcher"
+></div>
+
+<!-- Modal -->
+<div
+ class="fixed top-16 right-4 z-50 bg-base-100 border border-base-300 rounded-xl shadow-xl p-4 w-56"
+ role="dialog"
+ aria-label="Theme switcher"
+>
+ <p class="text-sm font-semibold mb-3 text-base-content">Select Theme</p>
+ <ul class="space-y-1">
+ {#each THEMES as theme}
+ <li>
+ <button
+ type="button"
+ class="w-full text-left px-3 py-1.5 rounded-lg text-sm capitalize hover:bg-base-200 transition-colors {currentTheme ===
+ theme
+ ? 'bg-primary text-primary-content'
+ : ''}"
+ onclick={() => selectTheme(theme)}
+ >
+ {theme}
+ </button>
+ </li>
+ {/each}
+ </ul>
+</div>
diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
new file mode 100644
index 0000000..f8e1f38
--- /dev/null
+++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+import type { ToolCallDisplay } from "../types.js";
+
+const { toolCall }: { toolCall: ToolCallDisplay } = $props();
+
+let isExpanded = $state(toolCall.isExpanded);
+
+function toggle() {
+ isExpanded = !isExpanded;
+}
+</script>
+
+<div class="collapse collapse-arrow bg-base-200 my-1 rounded-lg border border-base-300">
+ <button
+ type="button"
+ class="collapse-title flex items-center gap-2 text-sm font-medium cursor-pointer w-full text-left"
+ onclick={toggle}
+ aria-expanded={isExpanded}
+ >
+ <span class="badge badge-neutral badge-sm">tool</span>
+ <span class="font-mono">{toolCall.name}</span>
+ {#if toolCall.result !== undefined}
+ {#if toolCall.isError}
+ <span class="badge badge-error badge-sm ml-auto">error</span>
+ {:else}
+ <span class="badge badge-success badge-sm ml-auto">done</span>
+ {/if}
+ {:else}
+ <span class="badge badge-warning badge-sm ml-auto">pending</span>
+ {/if}
+ </button>
+
+ {#if isExpanded}
+ <div class="collapse-content text-xs">
+ <div class="mt-2">
+ <p class="font-semibold text-base-content/70 mb-1">Arguments</p>
+ <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all">{JSON.stringify(toolCall.arguments, null, 2)}</pre>
+ </div>
+ {#if toolCall.result !== undefined}
+ <div class="mt-2">
+ <p class="font-semibold text-base-content/70 mb-1">Result</p>
+ <pre
+ class="rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all {toolCall.isError
+ ? 'bg-error/20 text-error'
+ : 'bg-base-300'}">{toolCall.result}</pre>
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>