summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/App.svelte32
-rw-r--r--packages/frontend/src/app.css4
-rw-r--r--packages/frontend/src/lib/chat.svelte.ts274
-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
-rw-r--r--packages/frontend/src/lib/config.ts6
-rw-r--r--packages/frontend/src/lib/types.ts57
-rw-r--r--packages/frontend/src/lib/ws.svelte.ts89
-rw-r--r--packages/frontend/src/main.ts9
-rw-r--r--packages/frontend/src/vite-env.d.ts2
14 files changed, 771 insertions, 0 deletions
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
new file mode 100644
index 0000000..038fb09
--- /dev/null
+++ b/packages/frontend/src/App.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+import { onMount } from "svelte";
+import ChatInput from "./lib/components/ChatInput.svelte";
+import ChatPanel from "./lib/components/ChatPanel.svelte";
+import Header from "./lib/components/Header.svelte";
+import { wsClient } from "./lib/ws.svelte.js";
+
+const STORAGE_KEY = "dispatch-theme";
+
+onMount(() => {
+ // Apply saved theme
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ document.documentElement.setAttribute("data-theme", saved);
+ }
+
+ // Connect WebSocket
+ wsClient.connect();
+
+ return () => {
+ wsClient.disconnect();
+ };
+});
+</script>
+
+<div class="flex flex-col h-screen overflow-hidden bg-base-100 text-base-content">
+ <Header />
+ <div class="flex-1 overflow-hidden">
+ <ChatPanel />
+ </div>
+ <ChatInput />
+</div>
diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css
new file mode 100644
index 0000000..5602e1f
--- /dev/null
+++ b/packages/frontend/src/app.css
@@ -0,0 +1,4 @@
+@import "tailwindcss";
+@plugin "daisyui" {
+ themes: light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte;
+}
diff --git a/packages/frontend/src/lib/chat.svelte.ts b/packages/frontend/src/lib/chat.svelte.ts
new file mode 100644
index 0000000..54216ec
--- /dev/null
+++ b/packages/frontend/src/lib/chat.svelte.ts
@@ -0,0 +1,274 @@
+import { config } from "./config.js";
+import type { AgentEvent, ChatMessage, DebugInfo, ToolCallDisplay } from "./types.js";
+import { wsClient } from "./ws.svelte.js";
+
+function generateId() {
+ return Math.random().toString(36).slice(2, 11);
+}
+
+function makeDebugInfo(overrides: Partial<DebugInfo> = {}): DebugInfo {
+ return {
+ timestamp: new Date().toISOString(),
+ model: "deepseek-v4-flash-free",
+ apiBase: config.apiBase,
+ connectionStatus: wsClient.connectionStatus,
+ ...overrides,
+ };
+}
+
+function formatConversation(msgs: ChatMessage[]): string {
+ const lines: string[] = [];
+ lines.push("=== Dispatch Conversation ===");
+ lines.push(`Exported: ${new Date().toISOString()}`);
+ lines.push("");
+
+ for (const msg of msgs) {
+ const role = msg.role === "user" ? "User" : "Assistant";
+ lines.push(`--- ${role} ---`);
+ lines.push(msg.content);
+
+ if (msg.toolCalls && msg.toolCalls.length > 0) {
+ for (const tc of msg.toolCalls) {
+ lines.push(` [Tool: ${tc.name}]`);
+ lines.push(` Args: ${JSON.stringify(tc.arguments)}`);
+ if (tc.result !== undefined) {
+ const prefix = tc.isError ? " Error: " : " Result: ";
+ lines.push(`${prefix}${tc.result}`);
+ }
+ }
+ }
+
+ if (msg.debugInfo) {
+ lines.push(" [Debug Info]");
+ lines.push(` Timestamp: ${msg.debugInfo.timestamp}`);
+ if (msg.debugInfo.error) lines.push(` Error: ${msg.debugInfo.error}`);
+ if (msg.debugInfo.model) lines.push(` Model: ${msg.debugInfo.model}`);
+ if (msg.debugInfo.apiBase) lines.push(` API Base: ${msg.debugInfo.apiBase}`);
+ if (msg.debugInfo.connectionStatus)
+ lines.push(` Connection: ${msg.debugInfo.connectionStatus}`);
+ if (msg.debugInfo.agentStatus) lines.push(` Agent Status: ${msg.debugInfo.agentStatus}`);
+ if (msg.debugInfo.httpStatus) lines.push(` HTTP Status: ${msg.debugInfo.httpStatus}`);
+ if (msg.debugInfo.httpBody) lines.push(` HTTP Body: ${msg.debugInfo.httpBody}`);
+ if (msg.debugInfo.rawEvent)
+ lines.push(` Raw Event: ${JSON.stringify(msg.debugInfo.rawEvent)}`);
+ }
+
+ lines.push("");
+ }
+
+ return lines.join("\n");
+}
+
+function createChatStore() {
+ let messages: ChatMessage[] = $state([]);
+ let agentStatus: "idle" | "running" | "error" = $state("idle");
+ let isConnected = $state(false);
+ let currentAssistantId: string | null = null;
+
+ wsClient.onEvent((event) => {
+ const connected = wsClient.connectionStatus === "connected";
+ if (connected !== isConnected) {
+ isConnected = connected;
+ }
+ handleEvent(event);
+ });
+
+ $effect.root(() => {
+ $effect(() => {
+ isConnected = wsClient.connectionStatus === "connected";
+ });
+ });
+
+ function getCurrentAssistantMessage(): ChatMessage | null {
+ if (!currentAssistantId) return null;
+ return messages.find((m) => m.id === currentAssistantId) ?? null;
+ }
+
+ function ensureCurrentAssistantMessage(): ChatMessage {
+ let msg = getCurrentAssistantMessage();
+ if (!msg) {
+ const id = generateId();
+ currentAssistantId = id;
+ const newMsg: ChatMessage = {
+ id,
+ role: "assistant",
+ content: "",
+ toolCalls: [],
+ isStreaming: true,
+ };
+ messages = [...messages, newMsg];
+ msg = newMsg;
+ }
+ return msg;
+ }
+
+ function handleEvent(event: AgentEvent) {
+ switch (event.type) {
+ case "status": {
+ agentStatus = event.status;
+ if (event.status === "idle" || event.status === "error") {
+ currentAssistantId = null;
+ }
+ break;
+ }
+ case "text-delta": {
+ ensureCurrentAssistantMessage();
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ content: m.content + event.delta,
+ isStreaming: true,
+ };
+ }
+ return m;
+ });
+ break;
+ }
+ case "tool-call": {
+ ensureCurrentAssistantMessage();
+ const toolCall: ToolCallDisplay = {
+ id: event.toolCall.id,
+ name: event.toolCall.name,
+ arguments: event.toolCall.arguments,
+ isExpanded: false,
+ };
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return { ...m, toolCalls: [...(m.toolCalls ?? []), toolCall] };
+ }
+ return m;
+ });
+ break;
+ }
+ case "tool-result": {
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ toolCalls: (m.toolCalls ?? []).map((tc) => {
+ if (tc.id === event.toolResult.toolCallId) {
+ return {
+ ...tc,
+ result: event.toolResult.result,
+ isError: event.toolResult.isError,
+ };
+ }
+ return tc;
+ }),
+ };
+ }
+ return m;
+ });
+ break;
+ }
+ case "done": {
+ messages = messages.map((m) => {
+ if (m.id === currentAssistantId) {
+ return {
+ ...m,
+ content: event.message.content,
+ isStreaming: false,
+ };
+ }
+ return m;
+ });
+ currentAssistantId = null;
+ break;
+ }
+ case "error": {
+ const errMsg: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: `Error: ${event.error}`,
+ isStreaming: false,
+ debugInfo: makeDebugInfo({
+ error: event.error,
+ agentStatus,
+ rawEvent: event,
+ }),
+ };
+ messages = [...messages, errMsg];
+ currentAssistantId = null;
+ agentStatus = "error";
+ break;
+ }
+ }
+ }
+
+ async function sendMessage(text: string) {
+ const userMsg: ChatMessage = {
+ id: generateId(),
+ role: "user",
+ content: text,
+ };
+ messages = [...messages, userMsg];
+ currentAssistantId = null;
+
+ const url = `${config.apiBase}/chat`;
+ try {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message: text }),
+ });
+ if (!res.ok) {
+ const body = await res.text();
+ const errMsg: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: `Error: Failed to send message (HTTP ${res.status})`,
+ isStreaming: false,
+ debugInfo: makeDebugInfo({
+ error: `POST ${url} returned ${res.status}`,
+ agentStatus,
+ httpStatus: res.status,
+ httpBody: body,
+ }),
+ };
+ messages = [...messages, errMsg];
+ }
+ } catch (err) {
+ const errorText = err instanceof Error ? err.message : String(err);
+ const errMsg: ChatMessage = {
+ id: generateId(),
+ role: "assistant",
+ content: "Error: Could not reach the server",
+ isStreaming: false,
+ debugInfo: makeDebugInfo({
+ error: `POST ${url} failed: ${errorText}`,
+ agentStatus,
+ }),
+ };
+ messages = [...messages, errMsg];
+ }
+ }
+
+ function copyConversation(): string {
+ return formatConversation(messages);
+ }
+
+ function clear() {
+ messages = [];
+ currentAssistantId = null;
+ agentStatus = "idle";
+ }
+
+ return {
+ get messages() {
+ return messages;
+ },
+ get agentStatus() {
+ return agentStatus;
+ },
+ get isConnected() {
+ return isConnected;
+ },
+ sendMessage,
+ handleEvent,
+ copyConversation,
+ clear,
+ };
+}
+
+export const chatStore = createChatStore();
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>
diff --git a/packages/frontend/src/lib/config.ts b/packages/frontend/src/lib/config.ts
new file mode 100644
index 0000000..c22746c
--- /dev/null
+++ b/packages/frontend/src/lib/config.ts
@@ -0,0 +1,6 @@
+const API_BASE = import.meta.env.VITE_API_URL ?? "http://localhost:3000";
+
+export const config = {
+ apiBase: API_BASE,
+ wsUrl: `${API_BASE.replace(/^http/, "ws")}/ws`,
+} as const;
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
new file mode 100644
index 0000000..6edb550
--- /dev/null
+++ b/packages/frontend/src/lib/types.ts
@@ -0,0 +1,57 @@
+export interface ToolCallDisplay {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ result?: string;
+ isError?: boolean;
+ isExpanded: boolean;
+}
+
+export interface DebugInfo {
+ timestamp: string;
+ error?: string;
+ model?: string;
+ apiBase?: string;
+ connectionStatus?: string;
+ agentStatus?: string;
+ rawEvent?: unknown;
+ httpStatus?: number;
+ httpBody?: string;
+}
+
+export interface ChatMessage {
+ id: string;
+ role: "user" | "assistant";
+ content: string;
+ toolCalls?: ToolCallDisplay[];
+ isStreaming?: boolean;
+ debugInfo?: DebugInfo;
+}
+
+export type ConnectionStatus = "connecting" | "connected" | "disconnected";
+
+export type AgentEvent =
+ | { type: "status"; status: "idle" | "running" | "error" }
+ | { type: "text-delta"; delta: string }
+ | {
+ type: "tool-call";
+ toolCall: {
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ };
+ }
+ | {
+ type: "tool-result";
+ toolResult: { toolCallId: string; result: string; isError: boolean };
+ }
+ | { type: "error"; error: string }
+ | {
+ type: "done";
+ message: {
+ role: string;
+ content: string;
+ toolCalls?: unknown[];
+ toolResults?: unknown[];
+ };
+ };
diff --git a/packages/frontend/src/lib/ws.svelte.ts b/packages/frontend/src/lib/ws.svelte.ts
new file mode 100644
index 0000000..76c7ef5
--- /dev/null
+++ b/packages/frontend/src/lib/ws.svelte.ts
@@ -0,0 +1,89 @@
+import { config } from "./config.js";
+import type { AgentEvent, ConnectionStatus } from "./types.js";
+
+type EventCallback = (event: AgentEvent) => void;
+
+function createWebSocketClient(url: string) {
+ let connectionStatus: ConnectionStatus = $state("disconnected");
+ let ws: WebSocket | null = null;
+ let reconnectDelay = 1000;
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
+ let manualDisconnect = false;
+ const callbacks: EventCallback[] = [];
+
+ function connect() {
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
+ return;
+ }
+ manualDisconnect = false;
+ connectionStatus = "connecting";
+ ws = new WebSocket(url);
+
+ ws.onopen = () => {
+ connectionStatus = "connected";
+ reconnectDelay = 1000;
+ };
+
+ ws.onmessage = (event: MessageEvent) => {
+ try {
+ const data = JSON.parse(event.data as string) as AgentEvent;
+ for (const cb of callbacks) {
+ cb(data);
+ }
+ } catch {
+ // ignore malformed messages
+ }
+ };
+
+ ws.onclose = () => {
+ connectionStatus = "disconnected";
+ ws = null;
+ if (!manualDisconnect) {
+ scheduleReconnect();
+ }
+ };
+
+ ws.onerror = () => {
+ ws?.close();
+ };
+ }
+
+ function scheduleReconnect() {
+ if (reconnectTimer) clearTimeout(reconnectTimer);
+ reconnectTimer = setTimeout(() => {
+ reconnectTimer = null;
+ connect();
+ }, reconnectDelay);
+ reconnectDelay = Math.min(reconnectDelay * 2, 10000);
+ }
+
+ function disconnect() {
+ manualDisconnect = true;
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ ws?.close();
+ ws = null;
+ connectionStatus = "disconnected";
+ }
+
+ function onEvent(callback: EventCallback) {
+ callbacks.push(callback);
+ return () => {
+ const idx = callbacks.indexOf(callback);
+ if (idx !== -1) callbacks.splice(idx, 1);
+ };
+ }
+
+ return {
+ get connectionStatus() {
+ return connectionStatus;
+ },
+ connect,
+ disconnect,
+ onEvent,
+ };
+}
+
+export const wsClient = createWebSocketClient(config.wsUrl);
diff --git a/packages/frontend/src/main.ts b/packages/frontend/src/main.ts
new file mode 100644
index 0000000..fd54362
--- /dev/null
+++ b/packages/frontend/src/main.ts
@@ -0,0 +1,9 @@
+import App from "./App.svelte";
+import "./app.css";
+import { mount } from "svelte";
+
+const app = mount(App, {
+ target: document.getElementById("app") as HTMLElement,
+});
+
+export default app;
diff --git a/packages/frontend/src/vite-env.d.ts b/packages/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..4078e74
--- /dev/null
+++ b/packages/frontend/src/vite-env.d.ts
@@ -0,0 +1,2 @@
+/// <reference types="svelte" />
+/// <reference types="vite/client" />