deer-flow/frontend/src/components/workspace/recent-chat-list.tsx
Wenchao An e2f19d8335
feat(plugins): full-stack plugin APIs and bookmarks (#5647)
* feat(plugins): add full-stack contributions and bookmarks example

* ci(plugins): provision bookmark gateway for browser tests

* fix(plugins): authenticate module downloads through configured backend

* fix(plugins): isolate contributions and localize extension UI

* fix(plugins): preserve bookmark agent routing and contain async callbacks

* fix(plugins): pin durable batch workers to app extension snapshots
2026-09-22 11:18:57 +08:00

583 lines
20 KiB
TypeScript

"use client";
import {
Archive,
Download,
FileJson,
FileText,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Share2,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { ConversationExtensionActions } from "@/components/workspace/conversation-extension-actions";
import { getAPIClient } from "@/core/api";
import { useAuth } from "@/core/auth/AuthProvider";
import { hasPermission, PERMISSIONS } from "@/core/auth/permissions";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { useProjects } from "@/core/projects";
import { useLocalSettings } from "@/core/settings";
import { isStaticWebsiteOnly } from "@/core/static-mode";
import { exportThread, type ThreadExportFormat } from "@/core/threads/export";
import {
useInfiniteThreads,
useMoveThreadToProject,
usePinThread,
useRenameThread,
} from "@/core/threads/hooks";
import {
flattenThreadBranches,
type ThreadBranchEntry,
} from "@/core/threads/thread-branch-tree";
import { buildThreadListModel } from "@/core/threads/thread-list-model";
import type { AgentThread, AgentThreadState } from "@/core/threads/types";
import {
channelSourceOfThread,
isThreadPinned,
pathOfThread,
projectIdOfThread,
titleOfThread,
} from "@/core/threads/utils";
import { env } from "@/env";
import { isIMEComposing } from "@/lib/ime";
import { MoveToProjectMenu, NewProjectDialog } from "./move-to-project-menu";
import { ThreadChannelIcon } from "./thread-channel-source";
import { useThreadDeleteDialog } from "./thread-delete-dialog";
import { VirtualThreadList } from "./thread-list-virtualizer";
import { useThreadArchiveAction } from "./use-thread-archive-action";
/**
* A single thread row in the sidebar: link + hover action menu (pin, rename,
* share, export, delete) + rename dialog. Shared by the flat recent-chat list
* and the grouped-by-project rendering in `ProjectsSection`.
*/
export function ThreadSidebarItem({
thread,
isActive,
branchEntry,
recentThreadId,
}: {
thread: AgentThread;
isActive: boolean;
branchEntry?: ThreadBranchEntry | undefined;
recentThreadId?: string | undefined;
}) {
const { t } = useI18n();
const { user } = useAuth();
const canDeleteThreads = hasPermission(user, PERMISSIONS.THREADS_DELETE);
const requestDelete = useThreadDeleteDialog();
const { mutate: renameThread } = useRenameThread();
const { mutate: updatePinnedThread } = usePinThread();
// The move mutation is owned here (not inside `MoveToProjectMenu`) because
// selecting a project closes the dropdown and unmounts the menu — a
// per-mutate `onError` registered there would be dropped before a failed
// request settles, failing silently. This row persists, so a hook-level
// `onError` always fires. Same ownership-hoisting precedent as the
// `NewProjectDialog` below.
const { mutate: moveThreadToProject } = useMoveThreadToProject({
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.projects.moveFailed,
);
},
});
const handleMoveProject = useCallback(
(projectId: string | null) => {
moveThreadToProject({ threadId: thread.thread_id, projectId });
},
[moveThreadToProject, thread.thread_id],
);
const archiveAction = useThreadArchiveAction();
const [renameDialogOpen, setRenameDialogOpen] = useState(false);
const [renameValue, setRenameValue] = useState("");
const [newProjectDialogOpen, setNewProjectDialogOpen] = useState(false);
const handleRenameSubmit = useCallback(() => {
if (renameValue.trim()) {
renameThread(
{ threadId: thread.thread_id, title: renameValue.trim() },
{
onSuccess: () => {
setRenameDialogOpen(false);
setRenameValue("");
},
onError: (error) => {
toast.error(
error instanceof Error && error.message
? error.message
: t.common.renameFailed,
);
},
},
);
}
}, [renameThread, thread.thread_id, renameValue, t.common.renameFailed]);
const handleTogglePin = useCallback(() => {
updatePinnedThread(
{
threadId: thread.thread_id,
pinned: !isThreadPinned(thread),
},
{
onError: (err) => {
toast.error(
err instanceof Error ? err.message : t.chats.pinChatFailed,
);
},
},
);
}, [t.chats.pinChatFailed, thread, updatePinnedThread]);
const handleShare = useCallback(async () => {
// Always use Vercel URL for sharing so others can access
const VERCEL_URL = "https://deer-flow-v2.vercel.app";
const isLocalhost =
window.location.hostname === "localhost" ||
window.location.hostname === "127.0.0.1";
// On localhost: use Vercel URL; On production: use current origin
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
try {
const didCopy = await writeTextToClipboard(shareUrl);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
toast.success(t.clipboard.linkCopied);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
}
}, [t, thread]);
const handleExport = useCallback(
async (format: ThreadExportFormat) => {
try {
const apiClient = getAPIClient();
const state = await apiClient.threads.getState<AgentThreadState>(
thread.thread_id,
);
const messages = state.values?.messages ?? [];
if (messages.length === 0) {
toast.error(t.conversation.noMessages);
return;
}
exportThread(thread, messages, format);
toast.success(t.common.exportSuccess);
} catch {
toast.error(t.common.exportFailed);
}
},
[t, thread],
);
const channelSource = channelSourceOfThread(thread);
const pinned = isThreadPinned(thread);
const parentTitle = branchEntry?.parentThread
? titleOfThread(branchEntry.parentThread)
: null;
const title = titleOfThread(thread);
const branchLabel = parentTitle
? t.chats.branchLabel(title, parentTitle)
: undefined;
return (
<SidebarMenuItem className="group/side-menu-item">
<SidebarMenuButton isActive={isActive} asChild>
<Link
aria-label={branchLabel}
className="text-muted-foreground min-w-0 whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
data-branch-depth={
branchEntry && branchEntry.depth > 0 ? branchEntry.depth : undefined
}
data-branch-parent-id={branchEntry?.parentThread?.thread_id}
href={pathOfThread(thread)}
title={branchLabel}
>
{branchEntry && branchEntry.depth > 0 && (
<span
aria-hidden="true"
className="text-muted-foreground/70 shrink-0 font-mono text-[10px] leading-none"
data-testid="thread-branch-stem"
style={{
marginLeft: `${Math.min(branchEntry.depth - 1, 1) * 8}px`,
}}
>
{branchEntry.isLastSibling ? "└─" : "├─"}
</span>
)}
<ThreadChannelIcon source={channelSource} />
{pinned && (
<Pin
aria-hidden="true"
className="text-muted-foreground size-3.5 shrink-0"
/>
)}
<span className="min-w-0 truncate">{title}</span>
{channelSource && (
<span
className="bg-muted text-muted-foreground ml-auto inline-flex h-5 max-w-14 shrink-0 items-center rounded-md px-1.5 text-[10px] font-medium"
title={`${channelSource.label} channel`}
>
<span className="truncate">{channelSource.label}</span>
</span>
)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background after:left-0!"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<ConversationExtensionActions
context={{ thread }}
placement="menu"
/>
<DropdownMenuItem onSelect={handleTogglePin}>
{pinned ? (
<PinOff className="text-muted-foreground" />
) : (
<Pin className="text-muted-foreground" />
)}
<span>{pinned ? t.chats.unpinChat : t.chats.pinChat}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setRenameValue(titleOfThread(thread));
setRenameDialogOpen(true);
}}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleShare()}>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => void handleExport("markdown")}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleExport("json")}>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
disabled={archiveAction.isPending}
onSelect={() => archiveAction.setArchived(thread.thread_id, true)}
>
<Archive className="text-muted-foreground" />
<span>{t.chats.archiveChat}</span>
</DropdownMenuItem>
<MoveToProjectMenu
thread={thread}
onNewProject={() => setNewProjectDialogOpen(true)}
onMoveProject={handleMoveProject}
/>
{canDeleteThreads && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => requestDelete({ thread, recentThreadId })}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Rename Dialog */}
<Dialog open={renameDialogOpen} onOpenChange={setRenameDialogOpen}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{t.common.rename}</DialogTitle>
</DialogHeader>
<div className="py-4">
<Input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
placeholder={t.common.rename}
onKeyDown={(e) => {
if (e.key === "Enter" && !isIMEComposing(e)) {
e.preventDefault();
handleRenameSubmit();
}
}}
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRenameDialogOpen(false)}
>
{t.common.cancel}
</Button>
<Button onClick={handleRenameSubmit}>{t.common.save}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* New Project Dialog (mounted outside the DropdownMenu so it survives
the menu closing when "New project…" is selected) */}
<NewProjectDialog
thread={thread}
open={newProjectDialogOpen}
onOpenChange={setNewProjectDialogOpen}
/>
</SidebarMenuItem>
);
}
export function RecentChatList() {
const { t } = useI18n();
const pathname = usePathname();
const { thread_id: threadIdFromPath } = useParams<{
thread_id: string;
agent_name?: string;
}>();
const [settings] = useLocalSettings();
const groupByProject = settings.projectsDisplayMode === "grouped";
// Static-demo mode never renders project groups (ProjectsSection is hidden
// and project queries stay off), so a persisted "grouped" preference must
// behave as flat — otherwise assigned threads would vanish from this list.
const grouped = groupByProject && !isStaticWebsiteOnly();
const {
data: infiniteThreads,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteThreads({
archived:
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" ? undefined : false,
});
// Project discovery doubles as the exclusion oracle for grouped mode: an
// assigned thread may leave this flat list ONLY when its project group can
// actually render in `ProjectsSection`. Any query error (or not-yet-loaded
// data) yields `null` → exclude nothing (fail-visible), and a thread whose
// project id is unknown to both lists stays here too. TanStack dedupes
// these shared queries with `GroupedProjectList`.
// The discovery queries only feed that grouped-mode filter; in the default
// flat mode the results are read by nobody, so keep the two project round
// trips off the page load (`GroupedProjectList` fetches these same keys
// when grouped mode is on, and TanStack dedupes the observers).
const activeProjectsQuery = useProjects("active", { enabled: grouped });
const archivedProjectsQuery = useProjects("archived", { enabled: grouped });
const knownProjectIds = useMemo(() => {
const activeProjects = activeProjectsQuery.data;
const archivedProjects = archivedProjectsQuery.data;
if (
activeProjectsQuery.isError ||
archivedProjectsQuery.isError ||
!activeProjects ||
!archivedProjects
) {
return null;
}
return new Set(
[...activeProjects, ...archivedProjects].map((project) => project.id),
);
}, [
activeProjectsQuery.data,
activeProjectsQuery.isError,
archivedProjectsQuery.data,
archivedProjectsQuery.isError,
]);
const threadListModel = useMemo(
() => buildThreadListModel(infiniteThreads?.pages ?? []),
[infiniteThreads?.pages],
);
const { threads } = threadListModel;
const displayedThreads = useMemo(() => {
if (
!threadIdFromPath ||
threadListModel.displayedThreads.some(
(thread) => thread.thread_id === threadIdFromPath,
)
) {
return threadListModel.displayedThreads;
}
const activeThread = threadListModel.byId.get(threadIdFromPath);
return activeThread
? [...threadListModel.displayedThreads, activeThread]
: threadListModel.displayedThreads;
}, [threadIdFromPath, threadListModel]);
// In grouped mode, project-assigned threads render under their project
// header in `ProjectsSection`; this list keeps only unassigned threads —
// plus any assigned thread whose project cannot render a group (unknown id,
// or project discovery failed/loading), so no chat silently vanishes.
const visibleThreads = useMemo(
() =>
grouped && knownProjectIds
? displayedThreads.filter((thread) => {
const projectId = projectIdOfThread(thread);
return projectId === null || !knownProjectIds.has(projectId);
})
: displayedThreads,
[grouped, displayedThreads, knownProjectIds],
);
const branchList = useMemo(() => {
const entries = flattenThreadBranches(visibleThreads);
return {
entriesById: new Map(
entries.map((entry) => [entry.thread.thread_id, entry]),
),
threads: entries.map((entry) => entry.thread),
};
}, [visibleThreads]);
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const element = sentinelRef.current;
if (!element || !hasNextPage || !threadListModel.canLoadMore) {
return;
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
{ rootMargin: "120px 0px 120px 0px" },
);
observer.observe(element);
return () => observer.disconnect();
}, [
fetchNextPage,
hasNextPage,
isFetchingNextPage,
threadListModel.canLoadMore,
]);
// Grouped mode moves project threads under `ProjectsSection`, so this list
// can be empty while older pages (possibly holding unassigned threads) still
// exist on the server. Keep the pagination controls mounted then; flat mode
// keeps the original "empty list renders nothing" behavior.
if (
visibleThreads.length === 0 &&
!(grouped && hasNextPage && threadListModel.canLoadMore)
) {
return null;
}
return (
<SidebarGroup>
<SidebarGroupLabel>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true"
? t.sidebar.recentChats
: t.sidebar.demoChats}
</SidebarGroupLabel>
<SidebarGroupContent className="group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0">
<SidebarMenu>
{/* Keep pagination at the old list boundary when this switches to virtual rows. */}
<div
className="flex w-full flex-col gap-1"
style={{ overflowAnchor: "none" }}
>
<VirtualThreadList
estimateSize={36}
gap={4}
items={branchList.threads}
scrollParentSelector='[data-sidebar="content"]'
renderItem={(thread) => (
<ThreadSidebarItem
key={thread.thread_id}
thread={thread}
isActive={pathOfThread(thread) === pathname}
branchEntry={branchList.entriesById.get(thread.thread_id)}
recentThreadId={threads[0]?.thread_id}
/>
)}
/>
{hasNextPage && threadListModel.canLoadMore && (
<>
<Button
variant="ghost"
size="sm"
className="mx-2 my-1 w-[calc(100%-1rem)] justify-center text-xs"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
data-testid="recent-chat-list-load-more"
>
{isFetchingNextPage
? t.chats.loadingMore
: t.chats.loadOlderChats}
</Button>
<div
ref={sentinelRef}
aria-hidden="true"
className="h-px w-full"
data-testid="recent-chat-list-sentinel"
/>
</>
)}
</div>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}