From 563f1871d2a54f002355452e5dbb3d1a6138daa6 Mon Sep 17 00:00:00 2001 From: Shivering Ocelot Date: Fri, 4 Sep 2026 01:11:06 -0500 Subject: [PATCH] feat(frontend.chat): thread preview and single active thread --- frontend/src/components/Message.vue | 70 ++++++++++++- frontend/src/views/Chat.vue | 156 ++++++++++++++++++---------- 2 files changed, 171 insertions(+), 55 deletions(-) diff --git a/frontend/src/components/Message.vue b/frontend/src/components/Message.vue index 36b63ac..a76bf22 100644 --- a/frontend/src/components/Message.vue +++ b/frontend/src/components/Message.vue @@ -97,9 +97,18 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte @@ -182,4 +191,59 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte .resource .content { font-family: monospace; } + +.thread-header { + align-self: center; + width: fit-content; + max-width: 40em; + padding: 5px 16px; + border-radius: 12px; + border: 1px dashed var(--color-border); + background-color: var(--color-background-soft); + font-size: 80%; + text-align: center; + cursor: pointer; + user-select: none; +} + +.thread-header:hover { + border-style: solid; +} + +.thread-header .thread-title { + font-weight: bold; + opacity: 70%; +} + +.thread-header .thread-count { + font-weight: normal; + opacity: 80%; +} + +.thread-header .thread-preview { + font-style: italic; + opacity: 60%; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.thread-header .thread-preview-sender { + font-style: normal; + font-weight: bold; +} + +.thread-header.thread-active { + border-style: solid; + cursor: default; +} + +.thread-header.thread-active .thread-title { + opacity: 100%; +} + +.thread-header:hover { + border-style: solid; +} \ No newline at end of file diff --git a/frontend/src/views/Chat.vue b/frontend/src/views/Chat.vue index d7d9704..50ac630 100644 --- a/frontend/src/views/Chat.vue +++ b/frontend/src/views/Chat.vue @@ -31,23 +31,24 @@ }); // UI State - const expandedThreads = ref({}); // Object, to simulate a Set + const isNewThread = ref(true); // "New Thread" UI state const editingMessageId = ref(null); const noteMode = ref(false); const messageRunMap = ref({}); const editInfo = computed(() => { + const parts = [isNewThread.value ? 'New Thread' : `Thread ${currentThreadId.value}`] if (editingMessageId.value != null) { if (noteMode.value) { - return `Editing Note ${editingMessageId.value}`; + parts.push(`Editing Note ${editingMessageId.value}`); } else { - return `Editing ${editingMessageId.value}`; + parts.push(`Editing ${editingMessageId.value}`); } } else if (noteMode.value) { - return 'Editing Note'; - } else { - return null; + parts.push('Editing Note'); } + + return parts.join(' - '); }); let _isMounted = false; @@ -61,6 +62,25 @@ let _messageNonce = 1; + // === Thread Helpers === // + + const THREAD_STALE_MS = 10 * 60 * 1000; + + function messageTime (message) { + // Accept the common backend timestamp field names and formats + const t = message.timestamp ?? message.time ?? message.createdAt; + if (t == null) return null; + const ms = typeof t == 'number' ? (t < 1e12 ? t * 1000 : t) : Date.parse(t); + return isNaN(ms) ? null : ms; + } + + function isMessageStale (message) { + const time = messageTime(message); + // No timestamp available -> treat as stale; a new thread is the safer default + return time == null || (Date.now() - time) > THREAD_STALE_MS; + } + + // === State Management === // async function loadChatState (newId) { @@ -98,6 +118,7 @@ if (unsent) { if (unsent.content == it.content) { unsent.id = it.id; + unsent.threadId = it.threadId; addMessage(unsent); return; } else { @@ -132,15 +153,14 @@ threadId: it.threadId, })); - // Always send messages in a thread, determine which one to start with + // Continue the last thread only if it is recent (under 10 minutes old); + // otherwise start in "New Thread" state if (response.messages.length > 0) { const lastMessage = response.messages[response.messages.length - 1]; - const lastThread = lastMessage.threadId; - if (lastThread == null) { - startNewThread(); + if (lastMessage.threadId != null && !isMessageStale(lastMessage)) { + activateThread(lastMessage.threadId); } else { - currentThreadId.value = lastMessage.threadId; - expandThread(lastMessage.threadId, true); + startNewThread(); } } else { startNewThread(); @@ -152,7 +172,7 @@ // Reset UI State editingMessageId.value = null; noteMode.value = false; - // don't reset expandedThreads, it's nice to keep for the session + // thread state was set above; no need to reset } async function unloadChatState () { @@ -209,6 +229,7 @@ function addMessage (message, options={}) { requestRescroll(); if (options.unsent) { + message.id = _messageNonce++; unsentMessages.value.push(message); } else { messages.value.push(message); @@ -223,17 +244,24 @@ if (!content.trim()) return; // Don't send empty messages - let threadId = currentThreadId.value; let messageId = editingMessageId.value; let isNote = noteMode.value; if (messageId == null) { + // "New Thread" is a UI state: the thread is only created upstream + // here, once there is a message to put into it + let threadId = await resolveSendThread(); + if (threadId == null) { + elem.innerText = content; // let the user retry + return; + } + backend.sendAction("send-chat-message", { chatId, content, threadId, kind: isNote ? 'note' : null, }).then((response) => { if (response['error']) { - print("Error sending chat message: ${response['error']}"); + ui.alert("Error sending chat message: " + response['error']); } }); @@ -252,7 +280,7 @@ 'kind': isNote ? 'note' : null, }).then((response) => { if (response['error']) { - print("Error updating message: ${response['error']}"); + ui.alert("Error updating message: " + response['error']); } }); @@ -263,6 +291,21 @@ if (isNote) noteMode.value = false; } + async function resolveSendThread () { + // Returns the threadId to send into, creating the thread upstream + // if we're in "New Thread" state + if (!isNewThread.value) return currentThreadId.value; + + const res = await backend.sendAction("create-chat-thread", {chatId}); + if (res.threadId == null) { + ui.alert("Error creating thread" + (res.error ? `: ${res.error}` : "")); + return null; + } + currentThreadId.value = res.threadId; + isNewThread.value = false; + return res.threadId; + } + function computeChatEntries (messages) { let entries = []; let threadGroups = {}; // Map @@ -276,12 +319,20 @@ } } - function addThreadHeader (threadId, group) { + function addThreadHeader (threadId, group, options={}) { + let preview = null; + const last = group[group.length - 1]; + if (last && !options.active) { + let content = String(last.content || '').split('\n')[0].trim(); + if (content.length > 100) content = content.slice(0, 100) + '…'; + preview = {sender: senderName(last.sender), content}; + } entries.push({ type: 'thread-header', threadId, + active: !!options.active, messageCount: group.length, - expanded: Boolean(expandedThreads.value[threadId]), + preview, }); } @@ -294,56 +345,56 @@ } } + // Only the active thread is ever open; every other thread collapses + // into a single preview header + const activeThreadId = isNewThread.value ? null : currentThreadId.value; + let activeHeaderAdded = false; + // Second pass: Build the display list with correct ordering for (let _msg of messages) { - // New message object with updated name const message = Object.assign({}, _msg, {sender: senderName(_msg.sender)}); - // Non-threaded messages, notes, and resources go directly to the entry list if (message.threadId == null) { entries.push(message); + continue; + } - } else { - let threadId = message.threadId; - let group = threadGroups[threadId]; - - // If the thread is expanded, add messages as they appear - if (expandedThreads.value[threadId]) { - // Add the thread header before the first message - if (message.id == group[0].id) { - addThreadHeader(threadId, group); - } - entries.push(message); - } else { - // Add collapsed thread header in place of the last message - if (message.id == group[group.length-1].id) { - addThreadHeader(threadId, group); - } + let threadId = message.threadId; + let group = threadGroups[threadId]; + + if (threadId === activeThreadId) { + if (!activeHeaderAdded) { + addThreadHeader(threadId, group, {active: true}); + activeHeaderAdded = true; } + entries.push(message); + } else if (message.id == group[group.length - 1].id) { + // Collapsed: one preview card in place of the thread's last message + addThreadHeader(threadId, group); } } - // Add current thread mannually if it has no messages - const current = currentThreadId.value; - if (current != null && threadGroups[current] == null) { - addThreadHeader(current, []); + // "New Thread" UI state: placeholder where the new thread will start + if (isNewThread.value) { + addThreadHeader(null, []); } return entries; } - function expandThread (threadId, value) { - expandedThreads.value[threadId] = value; + function activateThread (threadId) { + if (threadId == null) return; // "New Thread" placeholder isn't clickable + // Continuing a thread: it becomes the one open thread + currentThreadId.value = threadId; + isNewThread.value = false; requestRescroll(); } - async function startNewThread () { - const res = await backend.sendAction("create-chat-thread", {chatId}); - console.log("Starting a new thread", res); - if (res.threadId != null) { - currentThreadId.value = res.threadId; - expandThread(res.threadId, true); - } + function startNewThread () { + // Pure UI state — nothing is created upstream until a message is sent + isNewThread.value = true; + currentThreadId.value = null; + requestRescroll(); } async function addRemoteResource () { @@ -360,7 +411,8 @@ }))); if (!selected) return; - const threadId = currentThreadId.value; + const threadId = await resolveSendThread(); + if (threadId == null) return; backend.sendAction("send-chat-message", { chatId, content: selected, threadId, kind: 'resource', subkind: 'remote', @@ -491,10 +543,10 @@