Queue concurrent prompts instead of dropping or conflicting

Chat store: when sendMessage() is called while streaming, push to a
per-conversation queue and return. When the stream ends the next queued
message fires automatically via setTimeout(0) to keep the call stack
clean. clearQueue() and queuedCount exposed for UI consumption.
Queue is cleaned up on conversation delete.

ChatView/WorkspaceView: remove the streaming guard from the local
sendMessage() functions and the :disabled on the textarea so users can
type and submit freely while streaming. Input placeholder changes to
"Type to queue next message…" during streaming. A small " N queued ×"
chip appears below the streaming bubble showing queue depth with a
cancel button.

DashboardChatInput: disable input, attach button, and send button
during streaming. The dashboard creates a fresh conversation per
message so in-conversation queuing doesn't apply — locking the input
is the correct UX here. Placeholder updates to "Generating response…"
during streaming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 22:05:49 -04:00
parent e34f6d6e54
commit 059a2e06d5
4 changed files with 130 additions and 13 deletions
@@ -126,7 +126,7 @@ defineExpose({ focus });
<button
class="btn-attach"
@click="toggleNotePicker"
:disabled="!store.chatReady"
:disabled="!store.chatReady || store.streaming"
title="Attach a note"
>
<svg
@@ -180,18 +180,18 @@ defineExpose({ focus });
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="
store.chatReady
? 'Start a new chat... (Enter to send)'
: 'Chat unavailable'
!store.chatReady ? 'Chat unavailable'
: store.streaming ? 'Generating response…'
: 'Start a new chat… (Enter to send)'
"
:disabled="!store.chatReady"
:disabled="!store.chatReady || store.streaming"
rows="1"
></textarea>
<button
class="btn-send"
@click="onSubmit"
:disabled="!messageInput.trim() || !store.chatReady"
:disabled="!messageInput.trim() || !store.chatReady || store.streaming"
>
&uarr;
</button>
+57 -1
View File
@@ -30,12 +30,24 @@ interface ConvStreamState {
contextMeta: ContextMeta | null;
}
interface QueuedMessage {
content: string;
contextNoteId?: number | null;
includeNoteIds?: number[];
think: boolean;
contextNoteTitle?: string;
excludeNoteIds?: number[];
ragProjectId?: number | null;
workspaceProjectId?: number | null;
}
export const useChatStore = defineStore("chat", () => {
const conversations = ref<Conversation[]>([]);
const currentConversation = ref<ConversationDetail | null>(null);
const total = ref(0);
const loading = ref(false);
const convStreams = ref<Record<number, ConvStreamState>>({});
const convQueues = ref<Record<number, QueuedMessage[]>>({});
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
const defaultModel = ref("");
@@ -67,6 +79,16 @@ export const useChatStore = defineStore("chat", () => {
return convStreams.value[id]?.streaming ?? false;
}
const queuedCount = computed(() => {
const id = currentConversation.value?.id;
return id ? (convQueues.value[id]?.length ?? 0) : 0;
});
function clearQueue() {
const id = currentConversation.value?.id;
if (id) convQueues.value[id] = [];
}
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
const chatReady = computed(
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
@@ -123,7 +145,10 @@ export const useChatStore = defineStore("chat", () => {
if (currentConversation.value && idSet.has(currentConversation.value.id)) {
currentConversation.value = null;
}
for (const id of ids) delete convStreams.value[id];
for (const id of ids) {
delete convStreams.value[id];
delete convQueues.value[id];
}
}
async function deleteConversation(id: number) {
@@ -134,6 +159,7 @@ export const useChatStore = defineStore("chat", () => {
currentConversation.value = null;
}
delete convStreams.value[id];
delete convQueues.value[id];
} catch (e) {
useToastStore().show("Failed to delete conversation", "error");
throw e;
@@ -173,6 +199,17 @@ export const useChatStore = defineStore("chat", () => {
const convId = currentConversation.value.id;
const s = _getOrInitStream(convId);
// If already streaming, queue the message for after the current stream ends.
if (s.streaming) {
if (!convQueues.value[convId]) convQueues.value[convId] = [];
convQueues.value[convId].push({
content, contextNoteId, includeNoteIds, think, contextNoteTitle,
excludeNoteIds, ragProjectId, workspaceProjectId,
});
return;
}
s.contextMeta = null;
// If a write tool is waiting for confirmation, intercept single-word yes/no
@@ -361,6 +398,23 @@ export const useChatStore = defineStore("chat", () => {
s.status = "";
s.pendingTool = null;
}
// Process next queued message, if any.
// Use setTimeout so this frame resolves before the next send begins.
const queue = convQueues.value[convId];
if (queue?.length && currentConversation.value?.id === convId) {
const next = queue.shift()!;
setTimeout(() => sendMessage(
next.content,
next.contextNoteId,
next.includeNoteIds,
next.think,
next.contextNoteTitle,
next.excludeNoteIds,
next.ragProjectId,
next.workspaceProjectId,
), 0);
}
}
async function reconnectIfGenerating(convId: number): Promise<void> {
@@ -501,6 +555,8 @@ export const useChatStore = defineStore("chat", () => {
defaultModel,
chatReady,
isStreamingConv,
queuedCount,
clearQueue,
fetchConversations,
createConversation,
fetchConversation,
+37 -3
View File
@@ -114,7 +114,8 @@ const streamingRendered = computed(() => {
const inputPlaceholder = computed(() => {
if (!store.chatReady) return "Chat unavailable";
return "Type a message... (Enter to send, Shift+Enter for new line)";
if (store.streaming) return "Type to queue next message (Enter to queue)";
return "Type a message… (Enter to send, Shift+Enter for new line)";
});
onMounted(async () => {
@@ -283,7 +284,7 @@ async function removeConversation(id: number) {
async function sendMessage() {
const content = messageInput.value.trim();
if (!content || store.streaming) return;
if (!content) return;
// Auto-create conversation if none selected
if (!store.currentConversation) {
@@ -631,6 +632,13 @@ onUnmounted(() => {
</div>
</div>
<!-- Queued message indicator -->
<div v-if="store.queuedCount" class="queued-indicator">
<span class="queued-icon"></span>
{{ store.queuedCount }} message{{ store.queuedCount > 1 ? 's' : '' }} queued
<button class="queued-cancel" @click="store.clearQueue()" aria-label="Cancel queued messages">×</button>
</div>
<p
v-if="!store.currentConversation.messages.length && !store.streaming"
class="empty-msg"
@@ -775,7 +783,7 @@ onUnmounted(() => {
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="inputPlaceholder"
:disabled="store.streaming || !store.chatReady"
:disabled="!store.chatReady"
rows="1"
></textarea>
<button
@@ -1601,4 +1609,30 @@ details[open] .thinking-summary::before {
margin-bottom: 0.5rem;
}
}
.queued-indicator {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--color-text-muted);
padding: 0.3rem 1rem;
margin: 0.25rem 0;
}
.queued-icon {
font-size: 0.75rem;
}
.queued-cancel {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1rem;
line-height: 1;
padding: 0 0.2rem;
margin-left: 0.1rem;
}
.queued-cancel:hover {
color: var(--color-text);
}
</style>
+30 -3
View File
@@ -126,7 +126,7 @@ function togglePanel(panel: keyof typeof panelOpen.value) {
async function sendMessage() {
const content = messageInput.value.trim();
if (!content || chatStore.streaming) return;
if (!content) return;
messageInput.value = "";
resetTextareaHeight();
@@ -318,6 +318,13 @@ onUnmounted(async () => {
</div>
</div>
<!-- Queued message indicator -->
<div v-if="chatStore.queuedCount" class="queued-indicator">
<span></span>
{{ chatStore.queuedCount }} message{{ chatStore.queuedCount > 1 ? 's' : '' }} queued
<button class="queued-cancel" @click="chatStore.clearQueue()" aria-label="Cancel queued messages">×</button>
</div>
<p
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
class="empty-chat-msg"
@@ -331,9 +338,8 @@ onUnmounted(async () => {
ref="inputEl"
v-model="messageInput"
class="chat-input"
placeholder="Message the agent... (Enter to send)"
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent (Enter to send)'"
rows="1"
:disabled="chatStore.streaming"
@keydown="onInputKeydown"
@input="autoResize"
></textarea>
@@ -625,4 +631,25 @@ details[open] .thinking-summary::before {
max-height: 300px;
overflow-y: auto;
}
.queued-indicator {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--color-text-muted);
padding: 0.3rem 0.75rem;
margin: 0.25rem 0;
}
.queued-cancel {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1rem;
line-height: 1;
padding: 0 0.2rem;
}
.queued-cancel:hover {
color: var(--color-text);
}
</style>