diff --git a/packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte b/packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte index d24b882..a3b3712 100644 --- a/packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte +++ b/packages/ui/src/lib/components/ChatPanel/ChatPanel.svelte @@ -113,6 +113,10 @@ let attachments: { name: string; type: string; dataUrl: string; file: File }[] = []; let fileInput: HTMLInputElement; let abortController: AbortController | null = null; + // Queue of messages the user sent while a stream was in progress. + // Each entry also carries the ID of the pending user bubble so we can + // remove it when the message transitions from "queued" to "streaming". + let queuedMessages: { content: string; pendingId: string }[] = []; // Klonode session tab ID → Claude CLI session ID is now tracked in // sessionsStore.cliSessionIds and persisted to localStorage, so reloads // and Vite server restarts preserve conversation continuity. Use @@ -141,8 +145,29 @@ async function handleSend() { const msg = inputValue.trim(); - if (!msg || $chatStore.isLoading) return; + if (!msg) return; inputValue = ''; + + // If a stream is already in progress, queue the message and show a + // pending bubble so the user can see it waiting. + if ($chatStore.isLoading) { + const pendingId = Math.random().toString(36).slice(2, 10); + queuedMessages = [...queuedMessages, { content: msg, pendingId }]; + chatStore.update(s => ({ + ...s, + messages: [...s.messages, { + id: pendingId, + role: 'user' as const, + content: msg, + timestamp: new Date(), + pending: true, + }], + })); + await tick(); + scrollToBottom(); + return; + } + activityLog = []; streamingText = ''; @@ -156,6 +181,28 @@ scrollToBottom(); } + /** + * Pull the next message from the queue and start streaming it. + * Removes its pending bubble, then calls sendMessageStreaming. + * sendMessageStreaming calls dequeueNext itself when it finishes, so the + * whole queue drains automatically and in order. + */ + async function dequeueNext() { + if (queuedMessages.length === 0) return; + const next = queuedMessages[0]; + queuedMessages = queuedMessages.slice(1); + // Remove the pending bubble — sendMessageStreaming will add a real one + chatStore.update(s => ({ + ...s, + messages: s.messages.filter(m => m.id !== next.pendingId), + })); + activityLog = []; + streamingText = ''; + await sendMessageStreaming(next.content); + await tick(); + scrollToBottom(); + } + async function sendMessageStreaming(userMessage: string) { const settings = $settingsStore; if (settings.connectionMode === 'cli' && !settings.cliPath) { @@ -289,18 +336,31 @@ console.warn('[Klonode] Graph refresh failed:', e); } } + + // Drain the send queue — start the next message if one is waiting + await dequeueNext(); } catch (err) { abortController = null; + const wasAborted = err instanceof Error && err.name === 'AbortError'; chatStore.update(s => ({ ...s, isLoading: false, messages: s.messages.map(m => m.id === loadingId ? { - ...m, loading: false, content: `Feil: ${err instanceof Error ? err.message : 'Ukjent feil'}`, + ...m, loading: false, + content: wasAborted + ? 'Stoppet av bruker.' + : `Feil: ${err instanceof Error ? err.message : 'Ukjent feil'}`, } : m), })); + // On user abort the queue stays intact so they can resume manually. + // On a real error we drain so queued messages still get sent. + if (!wasAborted) { + await dequeueNext(); + } } } else { // API mode: use regular sendMessage (no streaming) await sendMessage(userMessage); + await dequeueNext(); } } @@ -642,7 +702,10 @@ Rules: {#each $chatStore.messages as msg (msg.id)} {#if msg.role === 'user'}
-
{msg.content}
+
{msg.content}
+ {#if msg.pending} + queued + {/if}
{:else if msg.role === 'assistant'}
{#if $chatStore.isLoading} - {:else} - {/if} +
@@ -1103,6 +1164,17 @@ Rules: padding: 8px 12px; font-size: 12px; color: #e5e7eb; max-width: 85%; line-height: 1.5; } + .user-bubble.pending { opacity: 0.55; } + .queued-pill { + font-size: 9px; padding: 2px 8px; border-radius: 10px; + background: rgba(245, 158, 11, 0.12); + color: #fbbf24; + border: 1px solid rgba(245, 158, 11, 0.3); + font-weight: 600; + margin-top: 3px; + align-self: flex-end; + cursor: help; + } .assistant-message { border-radius: 8px; padding: 10px 12px; diff --git a/packages/ui/src/lib/stores/chat.ts b/packages/ui/src/lib/stores/chat.ts index d9392eb..8c39703 100644 --- a/packages/ui/src/lib/stores/chat.ts +++ b/packages/ui/src/lib/stores/chat.ts @@ -38,6 +38,12 @@ export interface ChatMessage { * resolves. */ interrupted?: boolean; + /** + * True while this user message is waiting in the send queue. The message + * is visible in the chat history but has not yet been sent to Claude. + * Cleared when the queue drains and the message starts streaming. + */ + pending?: boolean; } export interface ChatComparison {