feat: Multi-Queue — mehrere Nachrichten senden waehrend Claude arbeitet
All checks were successful
Build AppImage / build (push) Has been skipped

Statt Single-Slot-Puffer jetzt FIFO-Queue:
- Nachrichten erscheinen sofort im Chat (mit Queued-Markierung)
- Werden automatisch nacheinander abgearbeitet
- Send-Button + Mikrofon nie disabled waehrend Processing
- Queue visuell mit Anzahl + Cancel-Button
- Stopp loescht gesamte Queue

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-20 21:52:26 +02:00
parent 22bf5333af
commit 5b857ebba4
3 changed files with 77 additions and 34 deletions

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { emit } from '@tauri-apps/api/event'; import { emit } from '@tauri-apps/api/event';
import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, type Message } from '$lib/stores/app'; import { messages, currentInput, isProcessing, addMessage, currentSessionId, messageToDb, queuedMessage, messageQueue, type Message } from '$lib/stores/app';
import { marked, type Tokens } from 'marked'; import { marked, type Tokens } from 'marked';
import { tick, onDestroy, onMount } from 'svelte'; import { tick, onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
@ -450,14 +450,16 @@
window.addEventListener('keydown', handleGlobalKeydown); window.addEventListener('keydown', handleGlobalKeydown);
// Queue-Auto-Dispatch: sobald isProcessing von true auf false wechselt // Queue-Auto-Dispatch: sobald isProcessing von true auf false wechselt
// und ein Puffer vorhanden ist, wird er automatisch abgeschickt. // wird die naechste Nachricht aus der Queue abgeschickt (FIFO).
let lastProcessing = false; let lastProcessing = false;
const unsubProcessing = isProcessing.subscribe((val) => { const unsubProcessing = isProcessing.subscribe((val) => {
if (lastProcessing && !val) { if (lastProcessing && !val) {
const queued = get(queuedMessage); const queue = get(messageQueue);
if (queued) { if (queue.length > 0) {
$queuedMessage = null; const [next, ...rest] = queue;
dispatchMessage(queued).catch((e) => console.error('Queue-Dispatch fehlgeschlagen:', e)); messageQueue.set(rest);
$queuedMessage = rest.length > 0 ? rest[0] : null;
dispatchMessage(next).catch((e) => console.error('Queue-Dispatch fehlgeschlagen:', e));
} }
} }
lastProcessing = val; lastProcessing = val;
@ -470,18 +472,31 @@
}); });
function cancelQueued() { function cancelQueued() {
// Alle gequeuten Nachrichten verwerfen + aus dem Chat entfernen
messageQueue.set([]);
$queuedMessage = null; $queuedMessage = null;
messages.update((msgs) => msgs.filter((m) => !(m as any).queued));
} }
async function sendMessage() { async function sendMessage() {
const text = $currentInput.trim(); const text = $currentInput.trim();
if (!text) return; if (!text) return;
// Waehrend Claude antwortet: Single-Slot-Puffer statt Doppel-Send. // Waehrend Claude antwortet: Nachricht in FIFO-Queue.
// Der Subscriber weiter unten sendet den Puffer automatisch ab, sobald // Sofort als User-Message im Chat anzeigen (mit queued-Marker).
// isProcessing von true auf false wechselt. // Der Subscriber dispatcht automatisch wenn Claude fertig ist.
if ($isProcessing) { if ($isProcessing) {
messageQueue.update((q) => [...q, text]);
$queuedMessage = text; $queuedMessage = text;
// Sofort im Chat anzeigen damit User sieht dass die Nachricht angekommen ist
const queuedMsg: Message = {
id: crypto.randomUUID(),
role: 'user',
content: text,
timestamp: new Date(),
queued: true,
};
messages.update((msgs) => [...msgs, queuedMsg]);
$currentInput = ''; $currentInput = '';
return; return;
} }
@ -509,18 +524,28 @@
} }
} }
// Nachricht hinzufügen (wird durch den Store-Subscriber gespeichert) // Pruefen ob die Nachricht schon als queued im Chat steht
const msgId = crypto.randomUUID(); const existingQueued = get(messages).find((m) => m.queued && m.content === text);
const msg: Message = { if (existingQueued) {
id: msgId, // Queued-Markierung entfernen — Nachricht ist jetzt aktiv
role: 'user', messages.update((msgs) =>
content: text, msgs.map((m) => m.id === existingQueued.id ? { ...m, queued: false } : m)
timestamp: new Date(), );
}; // In DB speichern (wurde vorher nicht gespeichert da queued)
messages.update((msgs) => [...msgs, msg]); await saveMessageToDb({ ...existingQueued, queued: false });
} else {
// Sofort speichern (nicht auf Subscriber warten) // Neue Nachricht hinzufuegen (normaler Send, nicht aus Queue)
await saveMessageToDb(msg); const msgId = crypto.randomUUID();
const msg: Message = {
id: msgId,
role: 'user',
content: text,
timestamp: new Date(),
};
messages.update((msgs) => [...msgs, msg]);
// Sofort speichern (nicht auf Subscriber warten)
await saveMessageToDb(msg);
}
$currentInput = ''; $currentInput = '';
$isProcessing = true; $isProcessing = true;
@ -758,10 +783,12 @@
</div> </div>
{:else} {:else}
{#each $messages as message, index} {#each $messages as message, index}
<div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'} class:editing={editingMessageId === message.id}> <div class="message" class:user={message.role === 'user'} class:assistant={message.role === 'assistant'} class:system={message.role === 'system'} class:editing={editingMessageId === message.id} class:queued={message.queued}>
<div class="message-header"> <div class="message-header">
<span class="message-role"> <span class="message-role">
{#if message.role === 'user'} {#if message.role === 'user' && message.queued}
⏳ Du (wartet)
{:else if message.role === 'user'}
👤 Du 👤 Du
{:else if message.role === 'assistant'} {:else if message.role === 'assistant'}
🤖 {#if message.model}{message.model.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}{:else}Claude{/if} 🤖 {#if message.model}{message.model.replace('claude-', '').replace(/-\d{8}$/, '').replace(/(\D)-(\d)/g, '$1 $2').replace(/(\d)-(\d)/g, '$1.$2').replace(/\b[a-z]/g, c => c.toUpperCase())}{:else}Claude{/if}
@ -850,18 +877,18 @@
<span class="transcript-text">{liveTranscript}</span> <span class="transcript-text">{liveTranscript}</span>
</div> </div>
{/if} {/if}
{#if $queuedMessage} {#if $messageQueue.length > 0}
<div class="queued-pill" title="Wird gesendet sobald Claude die aktuelle Antwort fertig hat"> <div class="queued-pill" title="Wird gesendet sobald Claude die aktuelle Antwort fertig hat">
<span class="queued-icon">📬</span> <span class="queued-icon">📬</span>
<span class="queued-text">Nachricht wartet: „{$queuedMessage.slice(0, 80)}{$queuedMessage.length > 80 ? '…' : ''}"</span> <span class="queued-text">{$messageQueue.length} Nachricht{$messageQueue.length > 1 ? 'en' : ''} in der Queue</span>
<button class="queued-cancel" onclick={cancelQueued} aria-label="Wartende Nachricht verwerfen"></button> <button class="queued-cancel" onclick={cancelQueued} aria-label="Wartende Nachrichten verwerfen"></button>
</div> </div>
{/if} {/if}
<textarea <textarea
bind:this={inputTextarea} bind:this={inputTextarea}
bind:value={$currentInput} bind:value={$currentInput}
onkeydown={handleKeydown} onkeydown={handleKeydown}
placeholder={$isProcessing ? 'Nachricht wird nach aktueller Antwort gesendet...' : 'Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)'} placeholder={$isProcessing ? 'Nachricht eingeben — wird nach Antwort automatisch gesendet' : 'Nachricht eingeben... (Ctrl+K = Focus, Ctrl+Enter = Senden)'}
disabled={isRecording} disabled={isRecording}
rows="3" rows="3"
></textarea> ></textarea>
@ -870,7 +897,6 @@
class="mic-button" class="mic-button"
class:recording={isRecording} class:recording={isRecording}
onclick={toggleRecording} onclick={toggleRecording}
disabled={$isProcessing}
title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'} title={isRecording ? 'Aufnahme stoppen' : 'Spracheingabe starten'}
> >
{#if isRecording} {#if isRecording}
@ -886,7 +912,7 @@
disabled={!$currentInput.trim() || isRecording} disabled={!$currentInput.trim() || isRecording}
> >
{#if $isProcessing} {#if $isProcessing}
📬
{:else} {:else}
{/if} {/if}
@ -1110,6 +1136,17 @@
margin-left: auto; margin-left: auto;
} }
.message.queued {
opacity: 0.6;
border-left: 3px solid var(--accent, #f59e0b);
animation: pulse-queued 2s ease-in-out infinite;
}
@keyframes pulse-queued {
0%, 100% { opacity: 0.6; }
50% { opacity: 0.8; }
}
.message.assistant { .message.assistant {
margin-right: auto; margin-right: auto;
} }

View file

@ -35,6 +35,7 @@ export interface Message {
timestamp: Date; timestamp: Date;
agentId?: string; agentId?: string;
model?: string; model?: string;
queued?: boolean; // Nachricht wartet in der Queue auf Dispatch
} }
export interface Permission { export interface Permission {
@ -58,9 +59,13 @@ export const selectedAgentId = writable<string | null>(null);
export const currentModel = writable(''); export const currentModel = writable('');
export const currentSessionId = writable<string | null>(null); export const currentSessionId = writable<string | null>(null);
// Single-Slot-Puffer: wenn eine Nachricht abgeschickt wird waehrend Claude noch // Message-Queue: Nachrichten die waehrend der Verarbeitung eingehen, werden hier
// antwortet, wird sie hier gehalten und nach Ende der aktuellen Antwort // gesammelt und nach Ende der aktuellen Antwort FIFO abgearbeitet.
// automatisch gesendet. Neuere Inhalte ueberschreiben einen wartenden Slot. // Jede Nachricht erscheint sofort im Chat als User-Message.
export const messageQueue = writable<string[]>([]);
// Abwaertskompatibel: queuedMessage zeigt die naechste wartende Nachricht
// (Legacy — wird in ChatPanel noch referenziert)
export const queuedMessage = writable<string | null>(null); export const queuedMessage = writable<string | null>(null);
// Agent-Modus für Multi-Agent-Architektur // Agent-Modus für Multi-Agent-Architektur

View file

@ -2,7 +2,7 @@
import '../app.css'; import '../app.css';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, queuedMessage, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores'; import { isProcessing, agentCount, currentModel, sessionStats, contextPercent, contextUsage, initEventListeners, cleanupEventListeners, currentSessionId, setMessagesFromDb, stickyContextInfo, agentMode, queuedMessage, messageQueue, type DbMessage, type StickyContextInfo, type AgentMode } from '$lib/stores';
import StopButton from '$lib/components/StopButton.svelte'; import StopButton from '$lib/components/StopButton.svelte';
import UpdateDialog from '$lib/components/UpdateDialog.svelte'; import UpdateDialog from '$lib/components/UpdateDialog.svelte';
@ -122,9 +122,10 @@
} catch (err) { } catch (err) {
console.error('Fehler beim Stoppen:', err); console.error('Fehler beim Stoppen:', err);
} }
// Wartende Nachricht verwerfen — der User hat bewusst abgebrochen, // Wartende Nachrichten verwerfen — der User hat bewusst abgebrochen,
// die Queue soll nicht automatisch nachfeuern. // die Queue soll nicht automatisch nachfeuern.
$queuedMessage = null; $queuedMessage = null;
$messageQueue = [];
$isProcessing = false; $isProcessing = false;
} }