diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx index be9b6762f17..14665cd5ebb 100644 --- a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx +++ b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/ThreadItem/index.jsx @@ -22,6 +22,7 @@ export default function ThreadItem({ toggleMarkForDeletion, hasNext, ctrlPressed = false, + wasBumped = false, }) { const { slug, threadSlug = null } = useParams(); const optionsContainer = useRef(null); @@ -32,7 +33,7 @@ export default function ThreadItem({ return (
{/* Curved line Element and leader if required */} diff --git a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx index f9c0ea4edb7..25a4bcd8470 100644 --- a/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx +++ b/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx @@ -2,16 +2,19 @@ import Workspace from "@/models/workspace"; import paths from "@/utils/paths"; import showToast from "@/utils/toast"; import { Plus, CircleNotch, Trash } from "@phosphor-icons/react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import ThreadItem from "./ThreadItem"; import { useParams } from "react-router-dom"; export const THREAD_RENAME_EVENT = "renameThread"; +export const THREAD_ACTIVITY_EVENT = "threadActivity"; export default function ThreadContainer({ workspace }) { const { threadSlug = null } = useParams(); const [threads, setThreads] = useState([]); const [loading, setLoading] = useState(true); const [ctrlPressed, setCtrlPressed] = useState(false); + const [bumpedThreadSlug, setBumpedThreadSlug] = useState(null); + const timeoutRef = useRef(null); useEffect(() => { const chatHandler = (event) => { @@ -33,6 +36,50 @@ export default function ThreadContainer({ workspace }) { }; }, []); + useEffect(() => { + const activityHandler = (event) => { + const { threadSlug: activeSlug } = event.detail; + if (!activeSlug) return; + + setThreads((prevThreads) => { + const idx = prevThreads.findIndex((t) => t.slug === activeSlug); + if (idx <= 0) return prevThreads; + + // Move thread to top + const thread = prevThreads[idx]; + const reordered = [ + thread, + ...prevThreads.slice(0, idx), + ...prevThreads.slice(idx + 1), + ]; + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + setBumpedThreadSlug(activeSlug); + + // Wait for animation before resetting + timeoutRef.current = setTimeout(() => { + setBumpedThreadSlug(null); + timeoutRef.current = null; + }, 800); + + return reordered; + }); + }; + + window.addEventListener(THREAD_ACTIVITY_EVENT, activityHandler); + return () => { + window.removeEventListener(THREAD_ACTIVITY_EVENT, activityHandler); + + // Cleanup timeout + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + useEffect(() => { async function fetchThreads() { if (!workspace.slug) return; @@ -144,6 +191,7 @@ export default function ThreadContainer({ workspace }) { onRemove={removeThread} thread={thread} hasNext={i !== threads.length - 1} + wasBumped={thread.slug === bumpedThreadSlug} /> ))} ); } + +export function dispatchThreadActivityEvent(threadSlug) { + if (!threadSlug) return; + window.dispatchEvent( + new CustomEvent(THREAD_ACTIVITY_EVENT, { + detail: { threadSlug }, + }) + ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx index ee57186f5d3..8e31153d1e1 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/index.jsx @@ -16,6 +16,7 @@ import handleSocketResponse, { AGENT_SESSION_END, AGENT_SESSION_START, } from "@/utils/chat/agent"; +import { dispatchThreadActivityEvent } from "@/components/Sidebar/ActiveWorkspaces/ThreadContainer"; import DnDFileUploaderWrapper from "./DnDWrapper"; import SpeechRecognition, { useSpeechRecognition, @@ -217,7 +218,8 @@ export default function ChatContainer({ workspace, knownHistory = [] }) { setChatHistory, remHistory, _chatHistory, - setSocketId + setSocketId, + threadSlug ), attachments, }); @@ -275,6 +277,10 @@ export default function ChatContainer({ workspace, knownHistory = [] }) { }); setWebsocket(socket); window.dispatchEvent(new CustomEvent(AGENT_SESSION_START)); + + // Move thread to top + dispatchThreadActivityEvent(threadSlug); + window.dispatchEvent(new CustomEvent(CLEAR_ATTACHMENTS_EVENT)); } catch (e) { setChatHistory((prev) => [ diff --git a/frontend/src/index.css b/frontend/src/index.css index ece72b16af2..a8e0872a816 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1120,6 +1120,26 @@ does not extend the close button beyond the viewport. */ animation: thoughtTransition 0.5s ease-out forwards; } +/* Thread bump animation when thread moves to top of list */ +@keyframes threadBump { + 0% { + opacity: 0.75; + transform: translateY(-3px); + } + 70% { + opacity: 0.95; + transform: translateY(0px); + } + 100% { + opacity: 1; + transform: translateY(0); + } +} + +.thread-bump-animation { + animation: threadBump 0.6s ease-out forwards; +} + .checklist-completed { -webkit-animation: fadein 0.3s linear forwards; animation: fadein 0.3s linear forwards; diff --git a/frontend/src/utils/chat/index.js b/frontend/src/utils/chat/index.js index def11bd6774..e5942747fea 100644 --- a/frontend/src/utils/chat/index.js +++ b/frontend/src/utils/chat/index.js @@ -1,4 +1,7 @@ -import { THREAD_RENAME_EVENT } from "@/components/Sidebar/ActiveWorkspaces/ThreadContainer"; +import { + THREAD_RENAME_EVENT, + dispatchThreadActivityEvent, +} from "@/components/Sidebar/ActiveWorkspaces/ThreadContainer"; import { emitAssistantMessageCompleteEvent } from "@/components/contexts/TTSProvider"; export const ABORT_STREAM_EVENT = "abort-chat-stream"; @@ -9,7 +12,8 @@ export default function handleChat( setChatHistory, remHistory, _chatHistory, - setWebsocket + setWebsocket, + threadSlug = null ) { const { uuid, @@ -83,6 +87,9 @@ export default function handleChat( metrics, }); emitAssistantMessageCompleteEvent(chatId); + + // Move thread to top + dispatchThreadActivityEvent(threadSlug); } else if ( type === "textResponseChunk" || type === "finalizeResponseStream" @@ -108,6 +115,9 @@ export default function handleChat( emitAssistantMessageCompleteEvent(chatId); setLoadingResponse(false); + + // Move thread to top + dispatchThreadActivityEvent(threadSlug); } else { updatedHistory = { ...existingHistory, @@ -161,6 +171,9 @@ export default function handleChat( if (action === "reset_chat") { // Chat was reset, keep reset message and clear everything else. setChatHistory([_chatHistory.pop()]); + + // Move thread to top + dispatchThreadActivityEvent(threadSlug); } // If thread was updated automatically based on chat prompt diff --git a/server/endpoints/workspaceThreads.js b/server/endpoints/workspaceThreads.js index a34616cfa7f..ee2f91955b8 100644 --- a/server/endpoints/workspaceThreads.js +++ b/server/endpoints/workspaceThreads.js @@ -69,10 +69,14 @@ function workspaceThreadEndpoints(app) { try { const user = await userFromSession(request, response); const workspace = response.locals.workspace; - const threads = await WorkspaceThread.where({ - workspace_id: workspace.id, - user_id: user?.id || null, - }); + const threads = await WorkspaceThread.where( + { + workspace_id: workspace.id, + user_id: user?.id || null, + }, + null, + { lastUpdatedAt: "desc" } + ); response.status(200).json({ threads }); } catch (e) { console.error(e.message, e); diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js index e48807be71d..41fd23e2689 100644 --- a/server/models/workspaceChats.js +++ b/server/models/workspaceChats.js @@ -23,6 +23,15 @@ const WorkspaceChats = { include, }, }); + + // Update thread timestamp for ordering + if (threadId) { + await prisma.workspace_threads.update({ + where: { id: threadId }, + data: { lastUpdatedAt: new Date() }, + }); + } + return { chat, message: null }; } catch (error) { console.error(error.message); diff --git a/server/models/workspaceThread.js b/server/models/workspaceThread.js index 58e895a1f32..07a5ed0d16a 100644 --- a/server/models/workspaceThread.js +++ b/server/models/workspaceThread.js @@ -120,6 +120,19 @@ const WorkspaceThread = { } }, + // Updates timestamp for thread when activity occurs + touchActivity: async function (threadId) { + if (!threadId) return; + try { + await prisma.workspace_threads.update({ + where: { id: threadId }, + data: { lastUpdatedAt: new Date() }, + }); + } catch (error) { + console.error(error.message); + } + }, + // Will fire on first message (included or not) for a thread and rename the thread with the newName prop. autoRenameThread: async function ({ workspace = null, diff --git a/server/utils/chats/commands/reset.js b/server/utils/chats/commands/reset.js index f2bd4562c8e..c0914990673 100644 --- a/server/utils/chats/commands/reset.js +++ b/server/utils/chats/commands/reset.js @@ -1,4 +1,5 @@ const { WorkspaceChats } = require("../../../models/workspaceChats"); +const { WorkspaceThread } = require("../../../models/workspaceThread"); async function resetMemory( workspace, @@ -16,6 +17,8 @@ async function resetMemory( ) : await WorkspaceChats.markHistoryInvalid(workspace.id, user); + if (thread?.id) await WorkspaceThread.touchActivity(thread.id); + return { uuid: msgUUID, type: "textResponse",