merlyn/frontend/src/components/Sidebar/ActiveWorkspaces/ThreadContainer/index.jsx
Sean Hatfield d325b07182
Implement new home page redesign (#4931)
* remove legacy home page components, update home page to new layout

* update PromptInput component styles to match new designs, make quick action buttons functional

* home page chat creates new thread in last used workspace

* fix slash commands and agent popup on home page

* disable llm workspace selector action in home page

* add drag and drop file support to home page

* fix behavior of drag and drop on home page

* handle pasting attachments in home page

* update empty state of workspace chat to use new ui

* update empty workspace ui to match home page design, fix flickering loading states

* convert quick action buttons to component, add to empty state ws chat

* fix hover state light mode in quick actions

* add suggested messages subcomponent to empty ws/thread

* adjust width, rounded edges of prompt input

* only show quick actions for admin/manager role

* fix hover states for quick actions and suggested messages component

* make upload document quick action trigger parsed document upload

* fix mic behavior in homepage, ws chat, ws thread chat

* fix margin between prompt input and quick actions

* Simplify message presets by removing heading input (#4915)

* Remove heading input from message presets, merge legacy headings on edit

* filter out empty messages from state after saving

* mark form as dirty on input change

* styling

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* convert SuggestedMessages to component, render SuggestedMessages in home page to target ws

* fix broken handleMessageChange reference

* add translations for QuickActions

* lint

* fix home page chat submission broken by PromptInput onChange removal

* fix prompt input remount race condition, home page suggested message flicker

* remove unused handleSendSuggestedMessage from ChatHistory

* add greeting text to main-page translations, remove defaults

* fix file deletion in parsed files menu on home page

* add virtual thread sidebar state and workspace indicator on home page

* show workspace llm selector on home page when workspace exists

* show home page for all user roles with rbac quick actions

* fix positioning of agent and slash command popups

* remove workspace indicator from home page, match empty state spacing

* Normalize translations for home page redesign (#4986)

* normalize translations

* update translations with DMR

* accidentally changed es translation

* normalize translations for main-page.greeting

* update translations with DMR

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>

* update translations

* create new workspace in native language
Cleanup workspace page from empty state handling

* update quick action show logic

* fix send button

---------

Co-authored-by: Timothy Carambat <rambat1010@gmail.com>
2026-02-19 12:40:36 -08:00

250 lines
7.5 KiB
JavaScript

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 ThreadItem from "./ThreadItem";
import { useParams } from "react-router-dom";
export const THREAD_RENAME_EVENT = "renameThread";
export default function ThreadContainer({
workspace,
isVirtualThread = false,
}) {
const { threadSlug = null } = useParams();
const [threads, setThreads] = useState([]);
const [loading, setLoading] = useState(true);
const [ctrlPressed, setCtrlPressed] = useState(false);
useEffect(() => {
const chatHandler = (event) => {
const { threadSlug, newName } = event.detail;
setThreads((prevThreads) =>
prevThreads.map((thread) => {
if (thread.slug === threadSlug) {
return { ...thread, name: newName };
}
return thread;
})
);
};
window.addEventListener(THREAD_RENAME_EVENT, chatHandler);
return () => {
window.removeEventListener(THREAD_RENAME_EVENT, chatHandler);
};
}, []);
useEffect(() => {
async function fetchThreads() {
if (!workspace.slug) return;
const { threads } = await Workspace.threads.all(workspace.slug);
setLoading(false);
setThreads(threads);
}
fetchThreads();
}, [workspace.slug]);
// Enable toggling of bulk-deletion by holding meta-key (ctrl on win and cmd/fn on others)
useEffect(() => {
const handleKeyDown = (event) => {
if (["Control", "Meta"].includes(event.key)) {
setCtrlPressed(true);
}
};
const handleKeyUp = (event) => {
if (["Control", "Meta"].includes(event.key)) {
setCtrlPressed(false);
// when toggling, unset bulk progress so
// previously marked threads that were never deleted
// come back to life.
setThreads((prev) =>
prev.map((t) => {
return { ...t, deleted: false };
})
);
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
};
}, []);
const toggleForDeletion = (id) => {
setThreads((prev) =>
prev.map((t) => {
if (t.id !== id) return t;
return { ...t, deleted: !t.deleted };
})
);
};
const handleDeleteAll = async () => {
const slugs = threads.filter((t) => t.deleted === true).map((t) => t.slug);
await Workspace.threads.deleteBulk(workspace.slug, slugs);
setThreads((prev) => prev.filter((t) => !t.deleted));
// Only redirect if current thread is being deleted
if (slugs.includes(threadSlug)) {
window.location.href = paths.workspace.chat(workspace.slug);
}
};
function removeThread(threadId) {
setThreads((prev) =>
prev.map((_t) => {
if (_t.id !== threadId) return _t;
return { ..._t, deleted: true };
})
);
// Show thread was deleted, but then remove from threads entirely so it will
// not appear in bulk-selection.
setTimeout(() => {
setThreads((prev) => prev.filter((t) => !t.deleted));
}, 500);
}
function getActiveThreadIdx() {
if (isVirtualThread) return threads.length + 1;
const idx = threads.findIndex((t) => t?.slug === threadSlug);
return idx >= 0 ? idx + 1 : 0;
}
if (loading) {
return (
<div className="flex flex-col bg-pulse w-full h-10 items-center justify-center">
<p className="text-xs text-white animate-pulse">loading threads....</p>
</div>
);
}
const activeThreadIdx = getActiveThreadIdx();
return (
<div className="flex flex-col" role="list" aria-label="Threads">
<ThreadItem
idx={0}
activeIdx={activeThreadIdx}
isActive={activeThreadIdx === 0}
workspace={workspace}
thread={{ slug: null, name: "default" }}
hasNext={threads.length > 0 || isVirtualThread}
/>
{threads.map((thread, i) => (
<ThreadItem
key={thread.slug}
idx={i + 1}
ctrlPressed={ctrlPressed}
toggleMarkForDeletion={toggleForDeletion}
activeIdx={activeThreadIdx}
isActive={activeThreadIdx === i + 1}
workspace={workspace}
onRemove={removeThread}
thread={thread}
hasNext={i !== threads.length - 1 || isVirtualThread}
/>
))}
{isVirtualThread && (
<ThreadItem
idx={activeThreadIdx}
activeIdx={activeThreadIdx}
isActive={true}
workspace={workspace}
thread={{ slug: null, name: "*New Thread", virtual: true }}
hasNext={false}
/>
)}
<DeleteAllThreadButton
ctrlPressed={ctrlPressed}
threads={threads}
onDelete={handleDeleteAll}
/>
<NewThreadButton workspace={workspace} />
</div>
);
}
function NewThreadButton({ workspace }) {
const [loading, setLoading] = useState(false);
const onClick = async () => {
setLoading(true);
const { thread, error } = await Workspace.threads.new(workspace.slug);
if (!!error) {
showToast(`Could not create thread - ${error}`, "error", { clear: true });
setLoading(false);
return;
}
window.location.replace(
paths.workspace.thread(workspace.slug, thread.slug)
);
};
return (
<button
onClick={onClick}
className="w-full relative flex h-[40px] items-center border-none hover:bg-[var(--theme-sidebar-thread-selected)] light:hover:bg-slate-300 hover:light:bg-theme-sidebar-subitem-hover rounded-lg"
>
<div className="flex w-full gap-x-2 items-center pl-4">
<div className="bg-zinc-800 light:bg-slate-50 p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center">
{loading ? (
<CircleNotch
weight="bold"
size={14}
className="shrink-0 animate-spin text-white light:text-theme-text-primary"
/>
) : (
<Plus
weight="bold"
size={14}
className="shrink-0 text-white light:text-theme-text-primary"
/>
)}
</div>
{loading ? (
<p className="text-left text-white light:text-theme-text-primary text-sm">
Starting Thread...
</p>
) : (
<p className="text-left text-white light:text-theme-text-primary text-sm font-semibold">
New Thread
</p>
)}
</div>
</button>
);
}
function DeleteAllThreadButton({ ctrlPressed, threads, onDelete }) {
if (!ctrlPressed || threads.filter((t) => t.deleted).length === 0)
return null;
return (
<button
type="button"
onClick={onDelete}
className="w-full relative flex h-[40px] items-center border-none hover:bg-red-400/20 rounded-lg group"
>
<div className="flex w-full gap-x-2 items-center pl-4">
<div className="bg-transparent p-2 rounded-lg h-[24px] w-[24px] flex items-center justify-center">
<Trash
weight="bold"
size={14}
className="shrink-0 text-white light:text-red-500/50 group-hover:text-red-400"
/>
</div>
<p className="text-white light:text-theme-text-secondary text-left text-sm group-hover:text-red-400">
Delete Selected
</p>
</div>
</button>
);
}