feat(frontend.chat): thread preview and single active thread
This commit is contained in:
1 parent
8e94967a9f
commit
563f1871d2
2 files changed
+170
-54
No files matched your search
@@ -97,9 +97,18 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="message.type == 'thread-header'">
|
<template v-if="message.type == 'thread-header'">
|
||||||
<div class="content">
|
<div class="thread-header"
|
||||||
Thread {{message.threadId}} ({{message.messageCount}} messages)
|
:class="{ 'thread-active': message.active }"
|
||||||
<a href="#" @click.prevent="$emit('toggleThread', message.threadId, !message.expanded)">{{message.expanded ? "Hide" : "Show"}}</a>
|
@click="!message.active && message.threadId != null && $emit('open-thread', message.threadId)"
|
||||||
|
>
|
||||||
|
<div class="thread-title">
|
||||||
|
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
|
||||||
|
<span v-if="message.messageCount" class="thread-count">({{ message.messageCount }} messages)</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="message.preview" class="thread-preview">
|
||||||
|
<span class="thread-preview-sender">{{ message.preview.sender }}:</span>
|
||||||
|
{{ message.preview.content }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -182,4 +191,59 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
|
|||||||
.resource .content {
|
.resource .content {
|
||||||
font-family: monospace;
|
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;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
+103
-51
@@ -31,23 +31,24 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const expandedThreads = ref({}); // Object<int: bool>, to simulate a Set<int>
|
const isNewThread = ref(true); // "New Thread" UI state
|
||||||
const editingMessageId = ref(null);
|
const editingMessageId = ref(null);
|
||||||
const noteMode = ref(false);
|
const noteMode = ref(false);
|
||||||
const messageRunMap = ref({});
|
const messageRunMap = ref({});
|
||||||
|
|
||||||
const editInfo = computed(() => {
|
const editInfo = computed(() => {
|
||||||
|
const parts = [isNewThread.value ? 'New Thread' : `Thread ${currentThreadId.value}`]
|
||||||
if (editingMessageId.value != null) {
|
if (editingMessageId.value != null) {
|
||||||
if (noteMode.value) {
|
if (noteMode.value) {
|
||||||
return `Editing Note ${editingMessageId.value}`;
|
parts.push(`Editing Note ${editingMessageId.value}`);
|
||||||
} else {
|
} else {
|
||||||
return `Editing ${editingMessageId.value}`;
|
parts.push(`Editing ${editingMessageId.value}`);
|
||||||
}
|
}
|
||||||
} else if (noteMode.value) {
|
} else if (noteMode.value) {
|
||||||
return 'Editing Note';
|
parts.push('Editing Note');
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return parts.join(' - ');
|
||||||
});
|
});
|
||||||
|
|
||||||
let _isMounted = false;
|
let _isMounted = false;
|
||||||
@@ -61,6 +62,25 @@
|
|||||||
let _messageNonce = 1;
|
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 === //
|
// === State Management === //
|
||||||
|
|
||||||
async function loadChatState (newId) {
|
async function loadChatState (newId) {
|
||||||
@@ -98,6 +118,7 @@
|
|||||||
if (unsent) {
|
if (unsent) {
|
||||||
if (unsent.content == it.content) {
|
if (unsent.content == it.content) {
|
||||||
unsent.id = it.id;
|
unsent.id = it.id;
|
||||||
|
unsent.threadId = it.threadId;
|
||||||
addMessage(unsent);
|
addMessage(unsent);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
@@ -132,15 +153,14 @@
|
|||||||
threadId: it.threadId,
|
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) {
|
if (response.messages.length > 0) {
|
||||||
const lastMessage = response.messages[response.messages.length - 1];
|
const lastMessage = response.messages[response.messages.length - 1];
|
||||||
const lastThread = lastMessage.threadId;
|
if (lastMessage.threadId != null && !isMessageStale(lastMessage)) {
|
||||||
if (lastThread == null) {
|
activateThread(lastMessage.threadId);
|
||||||
startNewThread();
|
|
||||||
} else {
|
} else {
|
||||||
currentThreadId.value = lastMessage.threadId;
|
startNewThread();
|
||||||
expandThread(lastMessage.threadId, true);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
startNewThread();
|
startNewThread();
|
||||||
@@ -152,7 +172,7 @@
|
|||||||
// Reset UI State
|
// Reset UI State
|
||||||
editingMessageId.value = null;
|
editingMessageId.value = null;
|
||||||
noteMode.value = false;
|
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 () {
|
async function unloadChatState () {
|
||||||
@@ -209,6 +229,7 @@
|
|||||||
function addMessage (message, options={}) {
|
function addMessage (message, options={}) {
|
||||||
requestRescroll();
|
requestRescroll();
|
||||||
if (options.unsent) {
|
if (options.unsent) {
|
||||||
|
message.id = _messageNonce++;
|
||||||
unsentMessages.value.push(message);
|
unsentMessages.value.push(message);
|
||||||
} else {
|
} else {
|
||||||
messages.value.push(message);
|
messages.value.push(message);
|
||||||
@@ -223,17 +244,24 @@
|
|||||||
|
|
||||||
if (!content.trim()) return; // Don't send empty messages
|
if (!content.trim()) return; // Don't send empty messages
|
||||||
|
|
||||||
let threadId = currentThreadId.value;
|
|
||||||
let messageId = editingMessageId.value;
|
let messageId = editingMessageId.value;
|
||||||
let isNote = noteMode.value;
|
let isNote = noteMode.value;
|
||||||
|
|
||||||
if (messageId == null) {
|
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", {
|
backend.sendAction("send-chat-message", {
|
||||||
chatId, content, threadId,
|
chatId, content, threadId,
|
||||||
kind: isNote ? 'note' : null,
|
kind: isNote ? 'note' : null,
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
if (response['error']) {
|
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,
|
'kind': isNote ? 'note' : null,
|
||||||
}).then((response) => {
|
}).then((response) => {
|
||||||
if (response['error']) {
|
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;
|
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) {
|
function computeChatEntries (messages) {
|
||||||
let entries = [];
|
let entries = [];
|
||||||
let threadGroups = {}; // Map<int, message[]>
|
let threadGroups = {}; // Map<int, message[]>
|
||||||
@@ -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({
|
entries.push({
|
||||||
type: 'thread-header',
|
type: 'thread-header',
|
||||||
threadId,
|
threadId,
|
||||||
|
active: !!options.active,
|
||||||
messageCount: group.length,
|
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
|
// Second pass: Build the display list with correct ordering
|
||||||
for (let _msg of messages) {
|
for (let _msg of messages) {
|
||||||
// New message object with updated name
|
|
||||||
const message = Object.assign({}, _msg, {sender: senderName(_msg.sender)});
|
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) {
|
if (message.threadId == null) {
|
||||||
entries.push(message);
|
entries.push(message);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
let threadId = message.threadId;
|
||||||
let threadId = message.threadId;
|
let group = threadGroups[threadId];
|
||||||
let group = threadGroups[threadId];
|
|
||||||
|
|
||||||
// If the thread is expanded, add messages as they appear
|
if (threadId === activeThreadId) {
|
||||||
if (expandedThreads.value[threadId]) {
|
if (!activeHeaderAdded) {
|
||||||
// Add the thread header before the first message
|
addThreadHeader(threadId, group, {active: true});
|
||||||
if (message.id == group[0].id) {
|
activeHeaderAdded = true;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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
|
// "New Thread" UI state: placeholder where the new thread will start
|
||||||
const current = currentThreadId.value;
|
if (isNewThread.value) {
|
||||||
if (current != null && threadGroups[current] == null) {
|
addThreadHeader(null, []);
|
||||||
addThreadHeader(current, []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function expandThread (threadId, value) {
|
function activateThread (threadId) {
|
||||||
expandedThreads.value[threadId] = value;
|
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();
|
requestRescroll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startNewThread () {
|
function startNewThread () {
|
||||||
const res = await backend.sendAction("create-chat-thread", {chatId});
|
// Pure UI state — nothing is created upstream until a message is sent
|
||||||
console.log("Starting a new thread", res);
|
isNewThread.value = true;
|
||||||
if (res.threadId != null) {
|
currentThreadId.value = null;
|
||||||
currentThreadId.value = res.threadId;
|
requestRescroll();
|
||||||
expandThread(res.threadId, true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addRemoteResource () {
|
async function addRemoteResource () {
|
||||||
@@ -360,7 +411,8 @@
|
|||||||
})));
|
})));
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
|
|
||||||
const threadId = currentThreadId.value;
|
const threadId = await resolveSendThread();
|
||||||
|
if (threadId == null) return;
|
||||||
backend.sendAction("send-chat-message", {
|
backend.sendAction("send-chat-message", {
|
||||||
chatId, content: selected, threadId,
|
chatId, content: selected, threadId,
|
||||||
kind: 'resource', subkind: 'remote',
|
kind: 'resource', subkind: 'remote',
|
||||||
@@ -491,10 +543,10 @@
|
|||||||
<div class="messages-container" ref="chatContainerElem">
|
<div class="messages-container" ref="chatContainerElem">
|
||||||
<Message
|
<Message
|
||||||
v-for="msg in chatEntries"
|
v-for="msg in chatEntries"
|
||||||
:key="msg.id || msg.threadId"
|
:key="msg.id ?? ('thread-' + msg.threadId)"
|
||||||
:message="msg"
|
:message="msg"
|
||||||
@open-menu="chatEntryContextMenu"
|
@open-menu="chatEntryContextMenu"
|
||||||
@toggle-thread="expandThread"
|
@open-thread="activateThread"
|
||||||
|
|
||||||
:cancellable="messageRunMap[msg.id]"
|
:cancellable="messageRunMap[msg.id]"
|
||||||
@cancel="runs.cancelRun(messageRunMap[msg.id])"
|
@cancel="runs.cancelRun(messageRunMap[msg.id])"
|
||||||
|
|||||||
Reference in new issue
Block a user