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>
352 lines
8.0 KiB
Vue
352 lines
8.0 KiB
Vue
<script setup lang="ts">
|
|
import { ref, nextTick } from "vue";
|
|
import { apiGet } from "@/api/client";
|
|
import { useChatStore } from "@/stores/chat";
|
|
import type { Note } from "@/types/note";
|
|
|
|
const emit = defineEmits<{
|
|
submit: [payload: { content: string; contextNoteId?: number }];
|
|
}>();
|
|
|
|
const store = useChatStore();
|
|
|
|
const messageInput = ref("");
|
|
const inputEl = ref<HTMLTextAreaElement | null>(null);
|
|
|
|
// Note picker state
|
|
const attachedNote = ref<{ id: number; title: string } | null>(null);
|
|
const showNotePicker = ref(false);
|
|
const noteSearchQuery = ref("");
|
|
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
|
|
const noteSearchLoading = ref(false);
|
|
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function onSubmit() {
|
|
const content = messageInput.value.trim();
|
|
if (!content) return;
|
|
emit("submit", {
|
|
content,
|
|
contextNoteId: attachedNote.value?.id,
|
|
});
|
|
messageInput.value = "";
|
|
attachedNote.value = null;
|
|
resetTextareaHeight();
|
|
}
|
|
|
|
function onInputKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
onSubmit();
|
|
}
|
|
}
|
|
|
|
function autoResize() {
|
|
const el = inputEl.value;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
el.style.height = Math.min(el.scrollHeight, 120) + "px";
|
|
}
|
|
|
|
function resetTextareaHeight() {
|
|
const el = inputEl.value;
|
|
if (!el) return;
|
|
el.style.height = "auto";
|
|
}
|
|
|
|
// Note picker
|
|
function toggleNotePicker() {
|
|
showNotePicker.value = !showNotePicker.value;
|
|
if (showNotePicker.value) {
|
|
noteSearchQuery.value = "";
|
|
noteSearchResults.value = [];
|
|
nextTick(() => {
|
|
const el = document.querySelector(
|
|
".dashboard-chat .note-picker-search"
|
|
) as HTMLInputElement;
|
|
el?.focus();
|
|
});
|
|
}
|
|
}
|
|
|
|
function onNoteSearchInput() {
|
|
if (noteSearchTimer) clearTimeout(noteSearchTimer);
|
|
noteSearchTimer = setTimeout(async () => {
|
|
const q = noteSearchQuery.value.trim();
|
|
if (!q) {
|
|
noteSearchResults.value = [];
|
|
return;
|
|
}
|
|
noteSearchLoading.value = true;
|
|
try {
|
|
const data = await apiGet<{ notes: Note[] }>(
|
|
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
|
|
);
|
|
noteSearchResults.value = data.notes.map((n) => ({
|
|
id: n.id,
|
|
title: n.title,
|
|
}));
|
|
} catch {
|
|
noteSearchResults.value = [];
|
|
} finally {
|
|
noteSearchLoading.value = false;
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function selectNote(note: { id: number; title: string }) {
|
|
attachedNote.value = note;
|
|
showNotePicker.value = false;
|
|
}
|
|
|
|
function removeAttachedNote() {
|
|
attachedNote.value = null;
|
|
}
|
|
|
|
function focus() {
|
|
inputEl.value?.focus();
|
|
}
|
|
|
|
defineExpose({ focus });
|
|
</script>
|
|
|
|
<template>
|
|
<div class="dashboard-chat">
|
|
<!-- Attached note pill -->
|
|
<div v-if="attachedNote" class="attached-note">
|
|
<span class="attached-note-pill">
|
|
{{ attachedNote.title }}
|
|
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
|
|
×
|
|
</button>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="chat-input-bar">
|
|
<div class="note-picker-wrapper">
|
|
<button
|
|
class="btn-attach"
|
|
@click="toggleNotePicker"
|
|
:disabled="!store.chatReady || store.streaming"
|
|
title="Attach a note"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
|
|
<div v-if="showNotePicker" class="note-picker-dropdown">
|
|
<input
|
|
class="note-picker-search"
|
|
v-model="noteSearchQuery"
|
|
@input="onNoteSearchInput"
|
|
placeholder="Search notes..."
|
|
/>
|
|
<div class="note-picker-results">
|
|
<div
|
|
v-for="note in noteSearchResults"
|
|
:key="note.id"
|
|
class="note-picker-item"
|
|
@click="selectNote(note)"
|
|
>
|
|
{{ note.title || "Untitled" }}
|
|
</div>
|
|
<div v-if="noteSearchLoading" class="note-picker-empty">
|
|
Searching...
|
|
</div>
|
|
<div
|
|
v-else-if="noteSearchQuery && !noteSearchResults.length"
|
|
class="note-picker-empty"
|
|
>
|
|
No notes found
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<textarea
|
|
ref="inputEl"
|
|
v-model="messageInput"
|
|
@keydown="onInputKeydown"
|
|
@input="autoResize"
|
|
:placeholder="
|
|
!store.chatReady ? 'Chat unavailable'
|
|
: store.streaming ? 'Generating response…'
|
|
: 'Start a new chat… (Enter to send)'
|
|
"
|
|
:disabled="!store.chatReady || store.streaming"
|
|
rows="1"
|
|
></textarea>
|
|
|
|
<button
|
|
class="btn-send"
|
|
@click="onSubmit"
|
|
:disabled="!messageInput.trim() || !store.chatReady || store.streaming"
|
|
>
|
|
↑
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.dashboard-chat {
|
|
margin-top: 0.75rem;
|
|
}
|
|
|
|
.attached-note {
|
|
padding: 0.25rem 0;
|
|
}
|
|
.attached-note-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border-radius: 12px;
|
|
padding: 0.2rem 0.5rem;
|
|
font-size: 0.8rem;
|
|
}
|
|
.attached-note-remove {
|
|
background: none;
|
|
border: none;
|
|
color: rgba(255, 255, 255, 0.7);
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
line-height: 1;
|
|
padding: 0 0.15rem;
|
|
}
|
|
.attached-note-remove:hover {
|
|
color: #fff;
|
|
}
|
|
|
|
.chat-input-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
|
|
background: var(--color-input-bar-bg);
|
|
border-radius: 20px;
|
|
box-shadow: 0 2px 12px var(--color-shadow);
|
|
}
|
|
|
|
.chat-input-bar textarea {
|
|
flex: 1;
|
|
resize: none;
|
|
padding: 0.4rem 0.5rem;
|
|
border: none;
|
|
border-radius: 12px;
|
|
font-family: inherit;
|
|
font-size: 0.95rem;
|
|
background: transparent;
|
|
color: var(--color-input-bar-text);
|
|
outline: none;
|
|
max-height: 120px;
|
|
overflow-y: auto;
|
|
}
|
|
.chat-input-bar textarea::placeholder {
|
|
color: var(--color-input-bar-placeholder);
|
|
}
|
|
.chat-input-bar textarea:disabled {
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Note picker */
|
|
.note-picker-wrapper {
|
|
position: relative;
|
|
}
|
|
.btn-attach {
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
color: var(--color-input-bar-text);
|
|
opacity: 0.6;
|
|
padding: 0.25rem;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.btn-attach:hover {
|
|
opacity: 1;
|
|
}
|
|
.btn-attach:disabled {
|
|
opacity: 0.3;
|
|
cursor: default;
|
|
}
|
|
.note-picker-dropdown {
|
|
position: absolute;
|
|
bottom: calc(100% + 8px);
|
|
left: 0;
|
|
width: 280px;
|
|
background: var(--color-bg-card);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
box-shadow: 0 4px 16px var(--color-shadow);
|
|
z-index: 10;
|
|
overflow: hidden;
|
|
}
|
|
.note-picker-search {
|
|
width: 100%;
|
|
padding: 0.5rem 0.75rem;
|
|
border: none;
|
|
border-bottom: 1px solid var(--color-border);
|
|
background: transparent;
|
|
color: var(--color-text);
|
|
font-size: 0.9rem;
|
|
outline: none;
|
|
font-family: inherit;
|
|
box-sizing: border-box;
|
|
}
|
|
.note-picker-results {
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
.note-picker-item {
|
|
padding: 0.5rem 0.75rem;
|
|
cursor: pointer;
|
|
font-size: 0.9rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.note-picker-item:hover {
|
|
background: var(--color-bg-secondary);
|
|
}
|
|
.note-picker-empty {
|
|
padding: 0.5rem 0.75rem;
|
|
color: var(--color-text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.btn-send {
|
|
width: 34px;
|
|
min-width: 34px;
|
|
height: 34px;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--color-primary);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 50%;
|
|
cursor: pointer;
|
|
font-size: 1.1rem;
|
|
flex-shrink: 0;
|
|
}
|
|
.btn-send:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
</style>
|