summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--bun.lock9
-rw-r--r--packages/frontend/package.json3
-rw-r--r--packages/frontend/src/App.svelte2
-rw-r--r--packages/frontend/src/app.css78
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte2
-rw-r--r--packages/frontend/src/lib/components/ChatMessage.svelte14
-rw-r--r--packages/frontend/src/lib/components/ChatPanel.svelte9
-rw-r--r--packages/frontend/src/lib/components/Header.svelte4
-rw-r--r--packages/frontend/src/lib/components/MarkdownRenderer.svelte58
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte73
-rw-r--r--packages/frontend/src/lib/components/ToolCallDisplay.svelte82
11 files changed, 229 insertions, 105 deletions
diff --git a/bun.lock b/bun.lock
index 2440fe3..44cf8c2 100644
--- a/bun.lock
+++ b/bun.lock
@@ -39,6 +39,9 @@
"name": "@dispatch/frontend",
"version": "0.0.1",
"dependencies": {
+ "highlight.js": "^11.11.1",
+ "marked": "^18.0.4",
+ "marked-highlight": "^2.2.4",
"svelte": "^5.0.0",
},
"devDependencies": {
@@ -324,6 +327,8 @@
"graceful-fs": ["[email protected]", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+ "highlight.js": ["[email protected]", "", {}, "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w=="],
+
"hono": ["[email protected]", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
"is-reference": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
@@ -368,6 +373,10 @@
"magic-string": ["[email protected]", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+ "marked": ["[email protected]", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="],
+
+ "marked-highlight": ["[email protected]", "", { "peerDependencies": { "marked": ">=4 <19" } }, "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ=="],
+
"mri": ["[email protected]", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["[email protected]", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
diff --git a/packages/frontend/package.json b/packages/frontend/package.json
index acb23ad..2b68692 100644
--- a/packages/frontend/package.json
+++ b/packages/frontend/package.json
@@ -12,6 +12,9 @@
"typecheck": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
+ "highlight.js": "^11.11.1",
+ "marked": "^18.0.4",
+ "marked-highlight": "^2.2.4",
"svelte": "^5.0.0"
},
"devDependencies": {
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index c8e3803..b980abf 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -26,7 +26,7 @@ onMount(() => {
});
</script>
-<div class="flex flex-col h-screen overflow-hidden bg-base-100 text-base-content">
+<div class="flex flex-col h-screen overflow-hidden">
<Header />
<div class="flex-1 overflow-hidden">
<ChatPanel />
diff --git a/packages/frontend/src/app.css b/packages/frontend/src/app.css
index 5602e1f..a9ee1b6 100644
--- a/packages/frontend/src/app.css
+++ b/packages/frontend/src/app.css
@@ -1,4 +1,80 @@
@import "tailwindcss";
+@import "highlight.js/styles/atom-one-dark.min.css";
@plugin "daisyui" {
- themes: light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte;
+ themes: light, dark, dracula, night, nord, sunset, cyberpunk, forest, cmyk, coffee, caramellatte, garden, luxury;
+}
+
+.markdown-body {
+ & p {
+ margin-block: 0.5em;
+ &:first-child { margin-block-start: 0; }
+ &:last-child { margin-block-end: 0; }
+ }
+ & h1, & h2, & h3, & h4, & h5, & h6 {
+ font-weight: 600;
+ line-height: 1.25;
+ margin-block: 0.75em 0.25em;
+ &:first-child { margin-block-start: 0; }
+ }
+ & h1 { font-size: 1.4em; }
+ & h2 { font-size: 1.2em; }
+ & h3 { font-size: 1.1em; }
+ & ul, & ol {
+ padding-inline-start: 1.5em;
+ margin-block: 0.5em;
+ }
+ & ul { list-style-type: disc; }
+ & ol { list-style-type: decimal; }
+ & li { margin-block: 0.15em; }
+ & pre {
+ overflow-x: auto;
+ border-radius: var(--radius-box);
+ margin-block: 0.5em;
+ background-color: oklch(var(--color-neutral));
+ color: oklch(var(--color-neutral-content));
+ }
+ & pre code {
+ display: block;
+ padding: 0.75em 1em;
+ font-size: 0.8125em;
+ line-height: 1.5;
+ }
+ & :not(pre) > code {
+ font-size: 0.875em;
+ padding: 0.15em 0.4em;
+ border-radius: var(--radius-selector);
+ background-color: oklch(var(--color-base-content) / 0.1);
+ }
+ & blockquote {
+ border-inline-start: 3px solid oklch(var(--color-base-content) / 0.2);
+ padding-inline-start: 0.75em;
+ margin-block: 0.5em;
+ opacity: 0.8;
+ }
+ & a {
+ color: oklch(var(--color-primary));
+ text-decoration: underline;
+ &:hover { opacity: 0.8; }
+ }
+ & strong { font-weight: 600; }
+ & table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-block: 0.5em;
+ font-size: 0.875em;
+ }
+ & th, & td {
+ border: 1px solid oklch(var(--color-base-content) / 0.15);
+ padding: 0.4em 0.75em;
+ text-align: start;
+ }
+ & th {
+ font-weight: 600;
+ background-color: oklch(var(--color-base-200));
+ }
+ & hr {
+ border: none;
+ border-top: 1px solid oklch(var(--color-base-content) / 0.2);
+ margin-block: 0.75em;
+ }
}
diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte
index b929923..92c78b9 100644
--- a/packages/frontend/src/lib/components/ChatInput.svelte
+++ b/packages/frontend/src/lib/components/ChatInput.svelte
@@ -30,7 +30,7 @@ function submit() {
bind:value={inputValue}
type="text"
placeholder={isDisabled ? "Agent is running..." : "Type a message..."}
- class="input input-bordered flex-1"
+ class="input flex-1"
disabled={isDisabled}
onkeydown={handleKeydown}
/>
diff --git a/packages/frontend/src/lib/components/ChatMessage.svelte b/packages/frontend/src/lib/components/ChatMessage.svelte
index 5212790..1b92de3 100644
--- a/packages/frontend/src/lib/components/ChatMessage.svelte
+++ b/packages/frontend/src/lib/components/ChatMessage.svelte
@@ -1,5 +1,6 @@
<script lang="ts">
import type { ChatMessage } from "../types.js";
+import MarkdownRenderer from "./MarkdownRenderer.svelte";
import ToolCallDisplay from "./ToolCallDisplay.svelte";
const { message }: { message: ChatMessage } = $props();
@@ -10,14 +11,17 @@ const isUser = $derived(message.role === "user");
<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.thinking}
- <details class="mb-2">
- <summary class="cursor-pointer text-sm text-base-content/60 italic">Thinking...</summary>
- <p class="text-sm text-base-content/60 italic mt-1 whitespace-pre-wrap">{message.thinking}</p>
- </details>
+ <div class="collapse collapse-arrow mb-2">
+ <input type="checkbox" />
+ <div class="collapse-title text-sm opacity-60 italic p-0 min-h-0">Thinking...</div>
+ <div class="collapse-content text-sm opacity-60 italic p-0">
+ <p class="whitespace-pre-wrap mt-1">{message.thinking}</p>
+ </div>
+ </div>
{/if}
{#each message.content as segment, i (segment.type === "tool-call" ? segment.id : i)}
{#if segment.type === "text"}
- <span>{segment.text}</span>
+ <MarkdownRenderer text={segment.text} streaming={message.isStreaming} />
{:else if segment.type === "tool-call"}
<ToolCallDisplay toolCall={segment} />
{/if}
diff --git a/packages/frontend/src/lib/components/ChatPanel.svelte b/packages/frontend/src/lib/components/ChatPanel.svelte
index 44efb0b..e0491e7 100644
--- a/packages/frontend/src/lib/components/ChatPanel.svelte
+++ b/packages/frontend/src/lib/components/ChatPanel.svelte
@@ -5,13 +5,6 @@ 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
@@ -26,7 +19,7 @@ $effect(() => {
<!-- 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="status status-sm {wsClient.connectionStatus === 'connected' ? 'status-success' : wsClient.connectionStatus === 'connecting' ? 'status-warning' : 'status-error'}"></span>
<span class="capitalize text-base-content/70">{wsClient.connectionStatus}</span>
</span>
<span class="text-base-content/50">|</span>
diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte
index 79d371c..cf466fe 100644
--- a/packages/frontend/src/lib/components/Header.svelte
+++ b/packages/frontend/src/lib/components/Header.svelte
@@ -23,10 +23,10 @@ async function handleCopy() {
</script>
<header class="navbar bg-base-200 border-b border-base-300 px-4 min-h-14 flex-shrink-0">
- <div class="flex-1">
+ <div class="navbar-start">
<span class="text-xl font-bold tracking-tight">Dispatch</span>
</div>
- <div class="flex-none flex items-center gap-3">
+ <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
</span>
diff --git a/packages/frontend/src/lib/components/MarkdownRenderer.svelte b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
new file mode 100644
index 0000000..808159e
--- /dev/null
+++ b/packages/frontend/src/lib/components/MarkdownRenderer.svelte
@@ -0,0 +1,58 @@
+<script lang="ts">
+ import { Marked } from "marked";
+ import { markedHighlight } from "marked-highlight";
+ import hljs from "highlight.js/lib/core";
+ import bash from "highlight.js/lib/languages/bash";
+ import javascript from "highlight.js/lib/languages/javascript";
+ import json from "highlight.js/lib/languages/json";
+ import python from "highlight.js/lib/languages/python";
+ import typescript from "highlight.js/lib/languages/typescript";
+
+ hljs.registerLanguage("bash", bash);
+ hljs.registerLanguage("sh", bash);
+ hljs.registerLanguage("shell", bash);
+ hljs.registerLanguage("javascript", javascript);
+ hljs.registerLanguage("js", javascript);
+ hljs.registerLanguage("json", json);
+ hljs.registerLanguage("python", python);
+ hljs.registerLanguage("py", python);
+ hljs.registerLanguage("typescript", typescript);
+ hljs.registerLanguage("ts", typescript);
+
+ const md = new Marked(
+ markedHighlight({
+ emptyLangClass: "hljs",
+ langPrefix: "hljs language-",
+ highlight(code: string, lang: string) {
+ const language = hljs.getLanguage(lang) ? lang : "plaintext";
+ return hljs.highlight(code, { language, ignoreIllegals: true }).value;
+ },
+ }),
+ {
+ gfm: true,
+ breaks: true,
+ },
+ );
+
+ const { text = "", streaming = false }: { text?: string; streaming?: boolean } = $props();
+
+ function closeOpenDelimiters(src: string): string {
+ let out = src;
+ const fenceCount = (out.match(/^```/gm) || []).length;
+ if (fenceCount % 2 !== 0) out += "\n```";
+ const boldCount = (out.match(/\*\*/g) || []).length;
+ if (boldCount % 2 !== 0) out += "**";
+ const inlineCode = (out.match(/(?<!`)`(?!`)/g) || []).length;
+ if (inlineCode % 2 !== 0) out += "`";
+ return out;
+ }
+
+ const html = $derived.by(() => {
+ const src = streaming ? closeOpenDelimiters(text) : text;
+ return md.parse(src) as string;
+ });
+</script>
+
+<div class="markdown-body">
+ {@html html}
+</div>
diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
index 6984e3f..fe12cc5 100644
--- a/packages/frontend/src/lib/components/ThemeSwitcher.svelte
+++ b/packages/frontend/src/lib/components/ThemeSwitcher.svelte
@@ -1,16 +1,8 @@
<script lang="ts">
const THEMES = [
- "light",
- "dark",
- "dracula",
- "night",
- "nord",
- "sunset",
- "cyberpunk",
- "forest",
- "cmyk",
- "coffee",
- "caramellatte",
+ "light", "dark", "dracula", "night", "nord", "sunset",
+ "cyberpunk", "forest", "cmyk", "coffee", "caramellatte",
+ "garden", "luxury",
] as const;
const STORAGE_KEY = "dispatch-theme";
@@ -21,6 +13,12 @@ let currentTheme = $state(
(typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY)) || "dark",
);
+let dialogEl: HTMLDialogElement | undefined = $state();
+
+$effect(() => {
+ if (dialogEl && !dialogEl.open) dialogEl.showModal();
+});
+
function selectTheme(theme: string) {
currentTheme = theme;
document.documentElement.setAttribute("data-theme", theme);
@@ -29,37 +27,22 @@ function selectTheme(theme: string) {
}
</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>
+<dialog class="modal" bind:this={dialogEl} oncancel={onclose}>
+ <div class="modal-box w-56">
+ <h3 class="text-sm font-semibold mb-3">Select Theme</h3>
+ <ul class="menu menu-sm">
+ {#each THEMES as theme}
+ <li>
+ <button
+ type="button"
+ class="capitalize {currentTheme === theme ? 'menu-active' : ''}"
+ onclick={() => selectTheme(theme)}
+ >
+ {theme}
+ </button>
+ </li>
+ {/each}
+ </ul>
+ </div>
+ <form method="dialog" class="modal-backdrop"><button>close</button></form>
+</dialog>
diff --git a/packages/frontend/src/lib/components/ToolCallDisplay.svelte b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
index 070a8e3..f92f0f7 100644
--- a/packages/frontend/src/lib/components/ToolCallDisplay.svelte
+++ b/packages/frontend/src/lib/components/ToolCallDisplay.svelte
@@ -43,7 +43,7 @@ const shellResult = $derived(
);
</script>
-<div class="collapse collapse-arrow bg-base-200 my-1 rounded-lg border border-base-300 {isExpanded ? 'collapse-open' : ''}">
+<div class="collapse collapse-arrow bg-base-200 text-base-content my-1 rounded-lg border border-base-300 {isExpanded ? 'collapse-open' : ''}">
<button
type="button"
class="collapse-title flex items-center gap-2 text-sm font-medium cursor-pointer w-full text-left"
@@ -67,49 +67,48 @@ const shellResult = $derived(
{/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>
+ <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 isShell && toolCall.result !== undefined}
- {#if shellResult !== null}
- <div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout:</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stdout || "(empty)"}</pre>
- </div>
- {#if shellResult.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/80 mb-1">stderr:</p>
- <pre class="bg-error/10 text-error rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stderr}</pre>
- </div>
- {/if}
- <div class="mt-2 flex items-center gap-2">
- <span class="font-semibold text-base-content/70">exit code:</span>
- <span class="badge badge-sm {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">{shellResult.exitCode}</span>
- </div>
- {:else}
- <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}
- {:else if isShell && toolCall.shellOutput}
- {#if toolCall.shellOutput.stdout}
+ {#if shellResult !== null}
+ <div class="mt-2">
+ <p class="font-semibold text-base-content/70 mb-1">stdout:</p>
+ <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stdout || "(empty)"}</pre>
+ </div>
+ {#if shellResult.stderr}
<div class="mt-2">
- <p class="font-semibold text-base-content/70 mb-1">stdout</p>
- <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs">{toolCall.shellOutput.stdout}</pre>
+ <p class="font-semibold text-error/80 mb-1">stderr:</p>
+ <pre class="bg-error/10 text-error rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all font-mono">{shellResult.stderr}</pre>
</div>
{/if}
- {#if toolCall.shellOutput.stderr}
- <div class="mt-2">
- <p class="font-semibold text-error/70 mb-1">stderr</p>
- <pre class="bg-error/10 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs text-error">{toolCall.shellOutput.stderr}</pre>
- </div>
- {/if}
- <span class="text-xs text-base-content/50 italic">Running...</span>
- {:else if toolCall.result !== undefined}
+ <div class="mt-2 flex items-center gap-2">
+ <span class="font-semibold text-base-content/70">exit code:</span>
+ <span class="badge badge-sm {shellResult.exitCode === 0 ? 'badge-success' : 'badge-error'}">{shellResult.exitCode}</span>
+ </div>
+ {:else}
+ <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}
+ {:else if isShell && toolCall.shellOutput}
+ {#if toolCall.shellOutput.stdout}
+ <div class="mt-2">
+ <p class="font-semibold text-base-content/70 mb-1">stdout</p>
+ <pre class="bg-base-300 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs">{toolCall.shellOutput.stdout}</pre>
+ </div>
+ {/if}
+ {#if toolCall.shellOutput.stderr}
+ <div class="mt-2">
+ <p class="font-semibold text-error/70 mb-1">stderr</p>
+ <pre class="bg-error/10 rounded p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all text-xs text-error">{toolCall.shellOutput.stderr}</pre>
+ </div>
+ {/if}
+ <span class="text-xs text-base-content/50 italic">Running...</span>
+ {:else if toolCall.result !== undefined}
<div class="mt-2">
<p class="font-semibold text-base-content/70 mb-1">Result</p>
<pre
@@ -118,6 +117,5 @@ const shellResult = $derived(
: 'bg-base-300'}">{toolCall.result}</pre>
</div>
{/if}
- </div>
- {/if}
+ </div>
</div>