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",