diff --git a/client/index.html b/client/index.html index f753e61..3776b26 100644 --- a/client/index.html +++ b/client/index.html @@ -5,6 +5,7 @@ Navi +
diff --git a/client/js/app.js b/client/js/app.js index b8027f2..09fe036 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -1,8 +1,8 @@ import { api } from './api.js'; import { WsClient } from './ws.js'; -import { appendMessage, appendToolCall, appendTypingIndicator, - removeTypingIndicator, appendError, showEmptyState, - scrollToBottom } from './chat.js'; +import { appendMessage, appendStreamBubble, finalizeStreamBubble, + appendToolCall, appendTypingIndicator, removeTypingIndicator, + appendError, showEmptyState, scrollToBottom } from './chat.js'; import { renderProfiles, renderSessions, updateChatHeader } from './sidebar.js'; // ── DOM refs ───────────────────────────────────────────────────────────────── @@ -132,8 +132,7 @@ case 'stream_start': streaming = true; removeTypingIndicator(messagesEl); - currentBubble = appendMessage(messagesEl, 'assistant', ''); - currentBubble.classList.add('cursor'); + currentBubble = appendStreamBubble(messagesEl); break; case 'stream_delta': @@ -165,10 +164,11 @@ streaming = false; removeTypingIndicator(messagesEl); if (currentBubble) { - currentBubble.classList.remove('cursor'); if (finalContent !== undefined) { - currentBubble.textContent = finalContent; + finalizeStreamBubble(currentBubble, finalContent); updatePreview(currentId, finalContent); + } else { + currentBubble.classList.remove('cursor'); } currentBubble = null; } diff --git a/client/js/chat.js b/client/js/chat.js index 903ac47..9005d7e 100644 --- a/client/js/chat.js +++ b/client/js/chat.js @@ -1,4 +1,24 @@ -/** Chat area DOM helpers. All functions receive the messages container element. */ +/** Chat area DOM helpers. */ + +import { marked } from 'https://esm.sh/marked@12'; +import hljs from 'https://esm.sh/highlight.js@11'; + +// ── Markdown setup ──────────────────────────────────────────────────────────── + +marked.use({ + gfm: true, + breaks: true, +}); + +function renderMarkdown(text) { + const div = document.createElement('div'); + div.className = 'prose'; + div.innerHTML = marked.parse(text); + div.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el)); + return div; +} + +// ── Tool icons ──────────────────────────────────────────────────────────────── const TOOL_ICONS = { web_search: '🔍', @@ -9,6 +29,8 @@ ssh_exec: '🖧', }; +// ── Helpers ─────────────────────────────────────────────────────────────────── + function esc(str) { return String(str ?? '') .replace(/&/g, '&').replace(/ `${esc(k)}${esc(JSON.stringify(v))}`) + .join(''); + + const card = document.createElement('details'); + card.className = `tool-card${success ? '' : ' error'}`; card.innerHTML = ` -
+ ${icon} - ${esc(event.tool)} -
-
${esc(event.result)}
`; + ${esc(event.tool)} + ${success ? '✓' : '✗'} + +
+ ${argsLines ? `
${argsLines}
` : ''} +
${esc(event.result)}
+
`; el.appendChild(card); } diff --git a/client/style.css b/client/style.css index c5f2136..4c38303 100644 --- a/client/style.css +++ b/client/style.css @@ -189,31 +189,104 @@ line-height: 1.55; box-shadow: var(--shadow); word-break: break-word; - white-space: pre-wrap; + /* no white-space: pre-wrap — markdown handles this */ } -.msg.user .bubble { background: var(--user-bubble); color: var(--user-text); border-bottom-right-radius: 3px; } +.msg.user .bubble { background: var(--user-bubble); color: var(--user-text); border-bottom-right-radius: 3px; white-space: pre-wrap; } .msg.assistant .bubble { background: var(--bot-bubble); color: var(--bot-text); border-bottom-left-radius: 3px; } .msg-time { font-size: 11px; color: var(--text-muted); margin-top: 4px; padding: 0 2px; } -/* Tool call card */ +/* ── Markdown prose ───────────────────────────────── */ + +.prose { line-height: 1.6; } +.prose p { margin: 0 0 0.6em; } +.prose p:last-child { margin-bottom: 0; } +.prose h1,.prose h2,.prose h3,.prose h4 { font-weight: 700; margin: 0.8em 0 0.3em; line-height: 1.25; } +.prose h1 { font-size: 1.4em; } .prose h2 { font-size: 1.2em; } .prose h3 { font-size: 1.05em; } +.prose ul,.prose ol { padding-left: 1.4em; margin: 0.4em 0; } +.prose li { margin: 0.15em 0; } +.prose code { font-family: "Fira Code", "Cascadia Code", ui-monospace, monospace; font-size: 0.85em; + background: #2a2a2a; color: #e2b97e; padding: 1px 5px; border-radius: 4px; } +.prose pre { margin: 0.6em 0; border-radius: 8px; overflow: hidden; } +.prose pre code { background: none; color: inherit; padding: 0; border-radius: 0; font-size: 0.82em; } +.prose pre .hljs { padding: 12px 16px; border-radius: 8px; } +.prose blockquote { border-left: 3px solid #444; margin: 0.5em 0; padding: 0.2em 0 0.2em 0.8em; color: var(--text-muted); } +.prose table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.9em; } +.prose th,.prose td { border: 1px solid #333; padding: 5px 10px; text-align: left; } +.prose th { background: #222; } +.prose a { color: #60a5fa; text-decoration: none; } +.prose a:hover { text-decoration: underline; } +.prose hr { border: none; border-top: 1px solid #333; margin: 0.8em 0; } +.prose strong { font-weight: 700; } +.prose em { font-style: italic; } + +/* ── Tool call card (accordion) ──────────────────── */ + .tool-card { align-self: flex-start; - max-width: 80%; + max-width: 84%; background: var(--tool-bg); border: 1px solid var(--tool-border); border-radius: var(--radius); - padding: 8px 12px; font-size: 12px; color: var(--tool-text); + overflow: hidden; +} +.tool-card.error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } + +.tool-card summary { list-style: none; } +.tool-card summary::-webkit-details-marker { display: none; } + +.tool-header { + display: flex; + align-items: center; + gap: 7px; + padding: 8px 12px; + cursor: pointer; + user-select: none; + font-weight: 600; +} +.tool-header:hover { background: rgba(255,255,255,0.04); } +.tool-icon { font-size: 14px; } +.tool-name { flex: 1; } +.tool-status { font-size: 13px; opacity: 0.8; } +.tool-card:not([open]) .tool-header::after { content: '›'; font-size: 16px; opacity: 0.5; } +.tool-card[open] .tool-header::after { content: '‹'; font-size: 16px; opacity: 0.5; } + +.tool-body { + border-top: 1px solid var(--tool-border); + padding: 8px 12px; display: flex; flex-direction: column; - gap: 4px; + gap: 6px; + animation: fadeSlide 0.18s ease; } -.tool-card .tool-header { display: flex; align-items: center; gap: 6px; font-weight: 600; } -.tool-card .tool-icon { font-size: 14px; } -.tool-card .tool-result { color: var(--text-muted); font-size: 11px; margin-top: 2px; max-height: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.tool-card.error { background: var(--error-bg); border-color: var(--error-border); color: var(--error-text); } +@keyframes fadeSlide { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } + +.tool-args { + display: grid; + grid-template-columns: max-content 1fr; + gap: 2px 10px; + font-size: 11px; + opacity: 0.85; +} +.arg-key { color: var(--text-muted); font-style: italic; } +.arg-val { word-break: break-all; } + +.tool-result-pre { + margin: 0; + padding: 8px 10px; + background: rgba(0,0,0,0.25); + border-radius: 6px; + font-family: ui-monospace, monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + max-height: 260px; + overflow-y: auto; + color: var(--text); +} /* Typing indicator */ .typing {