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:
@@ -126,7 +126,7 @@ defineExpose({ focus });
|
|||||||
<button
|
<button
|
||||||
class="btn-attach"
|
class="btn-attach"
|
||||||
@click="toggleNotePicker"
|
@click="toggleNotePicker"
|
||||||
:disabled="!store.chatReady"
|
:disabled="!store.chatReady || store.streaming"
|
||||||
title="Attach a note"
|
title="Attach a note"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -180,18 +180,18 @@ defineExpose({ focus });
|
|||||||
@keydown="onInputKeydown"
|
@keydown="onInputKeydown"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
store.chatReady
|
!store.chatReady ? 'Chat unavailable'
|
||||||
? 'Start a new chat... (Enter to send)'
|
: store.streaming ? 'Generating response…'
|
||||||
: 'Chat unavailable'
|
: 'Start a new chat… (Enter to send)'
|
||||||
"
|
"
|
||||||
:disabled="!store.chatReady"
|
:disabled="!store.chatReady || store.streaming"
|
||||||
rows="1"
|
rows="1"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn-send"
|
class="btn-send"
|
||||||
@click="onSubmit"
|
@click="onSubmit"
|
||||||
:disabled="!messageInput.trim() || !store.chatReady"
|
:disabled="!messageInput.trim() || !store.chatReady || store.streaming"
|
||||||
>
|
>
|
||||||
↑
|
↑
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -30,12 +30,24 @@ interface ConvStreamState {
|
|||||||
contextMeta: ContextMeta | null;
|
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", () => {
|
export const useChatStore = defineStore("chat", () => {
|
||||||
const conversations = ref<Conversation[]>([]);
|
const conversations = ref<Conversation[]>([]);
|
||||||
const currentConversation = ref<ConversationDetail | null>(null);
|
const currentConversation = ref<ConversationDetail | null>(null);
|
||||||
const total = ref(0);
|
const total = ref(0);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const convStreams = ref<Record<number, ConvStreamState>>({});
|
const convStreams = ref<Record<number, ConvStreamState>>({});
|
||||||
|
const convQueues = ref<Record<number, QueuedMessage[]>>({});
|
||||||
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
const ollamaStatus = ref<"checking" | "available" | "unavailable">("checking");
|
||||||
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
|
const modelStatus = ref<"checking" | "loaded" | "cold" | "not_found">("checking");
|
||||||
const defaultModel = ref("");
|
const defaultModel = ref("");
|
||||||
@@ -67,6 +79,16 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
return convStreams.value[id]?.streaming ?? false;
|
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)
|
// Chat is usable when Ollama is up and model is at least installed (cold starts still work)
|
||||||
const chatReady = computed(
|
const chatReady = computed(
|
||||||
() => ollamaStatus.value === "available" && (modelStatus.value === "loaded" || modelStatus.value === "cold")
|
() => 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)) {
|
if (currentConversation.value && idSet.has(currentConversation.value.id)) {
|
||||||
currentConversation.value = null;
|
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) {
|
async function deleteConversation(id: number) {
|
||||||
@@ -134,6 +159,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
currentConversation.value = null;
|
currentConversation.value = null;
|
||||||
}
|
}
|
||||||
delete convStreams.value[id];
|
delete convStreams.value[id];
|
||||||
|
delete convQueues.value[id];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useToastStore().show("Failed to delete conversation", "error");
|
useToastStore().show("Failed to delete conversation", "error");
|
||||||
throw e;
|
throw e;
|
||||||
@@ -173,6 +199,17 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
|
|
||||||
const convId = currentConversation.value.id;
|
const convId = currentConversation.value.id;
|
||||||
const s = _getOrInitStream(convId);
|
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;
|
s.contextMeta = null;
|
||||||
|
|
||||||
// If a write tool is waiting for confirmation, intercept single-word yes/no
|
// 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.status = "";
|
||||||
s.pendingTool = null;
|
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> {
|
async function reconnectIfGenerating(convId: number): Promise<void> {
|
||||||
@@ -501,6 +555,8 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
defaultModel,
|
defaultModel,
|
||||||
chatReady,
|
chatReady,
|
||||||
isStreamingConv,
|
isStreamingConv,
|
||||||
|
queuedCount,
|
||||||
|
clearQueue,
|
||||||
fetchConversations,
|
fetchConversations,
|
||||||
createConversation,
|
createConversation,
|
||||||
fetchConversation,
|
fetchConversation,
|
||||||
|
|||||||
@@ -114,7 +114,8 @@ const streamingRendered = computed(() => {
|
|||||||
|
|
||||||
const inputPlaceholder = computed(() => {
|
const inputPlaceholder = computed(() => {
|
||||||
if (!store.chatReady) return "Chat unavailable";
|
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 () => {
|
onMounted(async () => {
|
||||||
@@ -283,7 +284,7 @@ async function removeConversation(id: number) {
|
|||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const content = messageInput.value.trim();
|
const content = messageInput.value.trim();
|
||||||
if (!content || store.streaming) return;
|
if (!content) return;
|
||||||
|
|
||||||
// Auto-create conversation if none selected
|
// Auto-create conversation if none selected
|
||||||
if (!store.currentConversation) {
|
if (!store.currentConversation) {
|
||||||
@@ -631,6 +632,13 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<p
|
||||||
v-if="!store.currentConversation.messages.length && !store.streaming"
|
v-if="!store.currentConversation.messages.length && !store.streaming"
|
||||||
class="empty-msg"
|
class="empty-msg"
|
||||||
@@ -775,7 +783,7 @@ onUnmounted(() => {
|
|||||||
@keydown="onInputKeydown"
|
@keydown="onInputKeydown"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
:placeholder="inputPlaceholder"
|
:placeholder="inputPlaceholder"
|
||||||
:disabled="store.streaming || !store.chatReady"
|
:disabled="!store.chatReady"
|
||||||
rows="1"
|
rows="1"
|
||||||
></textarea>
|
></textarea>
|
||||||
<button
|
<button
|
||||||
@@ -1601,4 +1609,30 @@ details[open] .thinking-summary::before {
|
|||||||
margin-bottom: 0.5rem;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ function togglePanel(panel: keyof typeof panelOpen.value) {
|
|||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const content = messageInput.value.trim();
|
const content = messageInput.value.trim();
|
||||||
if (!content || chatStore.streaming) return;
|
if (!content) return;
|
||||||
|
|
||||||
messageInput.value = "";
|
messageInput.value = "";
|
||||||
resetTextareaHeight();
|
resetTextareaHeight();
|
||||||
@@ -318,6 +318,13 @@ onUnmounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<p
|
||||||
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
|
||||||
class="empty-chat-msg"
|
class="empty-chat-msg"
|
||||||
@@ -331,9 +338,8 @@ onUnmounted(async () => {
|
|||||||
ref="inputEl"
|
ref="inputEl"
|
||||||
v-model="messageInput"
|
v-model="messageInput"
|
||||||
class="chat-input"
|
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"
|
rows="1"
|
||||||
:disabled="chatStore.streaming"
|
|
||||||
@keydown="onInputKeydown"
|
@keydown="onInputKeydown"
|
||||||
@input="autoResize"
|
@input="autoResize"
|
||||||
></textarea>
|
></textarea>
|
||||||
@@ -625,4 +631,25 @@ details[open] .thinking-summary::before {
|
|||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user