diff options
| -rw-r--r-- | bun.lock | 9 | ||||
| -rw-r--r-- | packages/frontend/package.json | 3 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/app.css | 78 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatMessage.svelte | 14 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatPanel.svelte | 9 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/Header.svelte | 4 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/MarkdownRenderer.svelte | 58 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ThemeSwitcher.svelte | 73 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ToolCallDisplay.svelte | 82 |
11 files changed, 229 insertions, 105 deletions
@@ -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> |
