Files
nanobot/webui/src/components/ChatList.tsx
T
Xubin RenandGitHub 3dcf511c84 feat(webui): refine output timeline and model controls (#4108)
* feat(webui): refine output timeline and composer queue

* feat(webui): add provider model picker

* fix(webui): polish model settings and heartbeat checks

* chore: keep heartbeat changes out of webui pr

* refactor(webui): isolate settings routes

* fix(providers): align minimax anthropic test

* fix(providers): keep minimax anthropic base sdk-compatible

* fix(providers): normalize anthropic base urls
2026-05-30 23:45:26 +08:00

550 lines
20 KiB
TypeScript

import {
memo,
useEffect,
useMemo,
useState,
} from "react";
import {
Archive,
ArchiveRestore,
Folder,
MoreHorizontal,
Pencil,
Pin,
PinOff,
Plus,
Trash2,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { deriveTitle, relativeTime } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
displayTitle,
groupSessions,
isCollapsedProject,
isFoldableChatsGroup,
isFoldedChatsGroup,
limitGroups,
visibleSessionsForGroup,
type ChatGroupLabels,
} from "@/lib/chat-groups";
import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
const INITIAL_VISIBLE_SESSIONS = 160;
const VISIBLE_SESSIONS_INCREMENT = 160;
interface ChatListProps {
sessions: ChatSummary[];
activeKey: string | null;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
onTogglePin: (key: string) => void;
onRequestRename: (key: string, label: string) => void;
onToggleArchive: (key: string) => void;
onToggleGroup?: (groupId: string) => void;
onRequestRenameProject?: (projectKey: string, label: string) => void;
onNewChatInProject?: (projectPath: string, projectName: string) => void;
pinnedKeys?: string[];
archivedKeys?: string[];
titleOverrides?: Record<string, string>;
projectNameOverrides?: Record<string, string>;
collapsedGroups?: Record<string, boolean>;
runningChatIds?: string[];
completedChatIds?: string[];
density?: SidebarDensity;
showPreviews?: boolean;
showTimestamps?: boolean;
sort?: SidebarSortMode;
showArchived?: boolean;
defaultWorkspacePath?: string | null;
actionMenuPortalContainer?: HTMLElement | null;
loading?: boolean;
emptyLabel?: string;
}
export const ChatList = memo(function ChatList({
sessions,
activeKey,
onSelect,
onRequestDelete,
onTogglePin,
onRequestRename,
onToggleArchive,
onToggleGroup,
onRequestRenameProject,
onNewChatInProject,
pinnedKeys = [],
archivedKeys = [],
titleOverrides = {},
projectNameOverrides = {},
collapsedGroups = {},
runningChatIds = [],
completedChatIds = [],
density = "comfortable",
showPreviews = false,
showTimestamps = false,
sort = "updated_desc",
showArchived = false,
defaultWorkspacePath,
actionMenuPortalContainer,
loading,
emptyLabel,
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
archived: t("chat.groups.archived"),
projects: t("chat.groups.projects"),
fallbackTitle: t("chat.newChat"),
}), [t]);
const groups = useMemo(
() => groupSessions(sessions, labels, {
pinnedKeys,
archivedKeys,
titleOverrides,
projectNameOverrides,
showArchived,
sort,
defaultWorkspacePath,
}),
[
archivedKeys,
labels,
pinnedKeys,
sessions,
showArchived,
sort,
titleOverrides,
projectNameOverrides,
defaultWorkspacePath,
],
);
const limitedGroups = useMemo(
() => limitGroups(groups, visibleLimit, activeKey, collapsedGroups),
[activeKey, collapsedGroups, groups, visibleLimit],
);
const totalSessionCount = useMemo(
() => groups.reduce(
(total, group) =>
total + (isCollapsedProject(group, collapsedGroups) ? 0 : group.sessions.length),
0,
),
[collapsedGroups, groups],
);
const visibleSessionCount = useMemo(
() => limitedGroups.reduce((total, group) => total + group.sessions.length, 0),
[limitedGroups],
);
const hiddenSessionCount = Math.max(0, totalSessionCount - visibleSessionCount);
useEffect(() => {
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
{t("chat.loading")}
</div>
);
}
if (sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{emptyLabel ?? t("chat.noSessions")}
</div>
);
}
const pinned = new Set(pinnedKeys);
const archived = new Set(archivedKeys);
const running = new Set(runningChatIds);
const completed = new Set(completedChatIds);
const compact = density === "compact";
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div className="min-w-0 space-y-3 px-2 py-1.5">
{limitedGroups.map((group, index) => {
const foldableChatsGroup = isFoldableChatsGroup(group);
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
const visibleSessions = visibleSessionsForGroup(
group,
activeKey,
collapsedGroups,
);
const hiddenInGroup = Math.max(0, group.sessions.length - visibleSessions.length);
const canToggleFold = group.sessions.length > COLLAPSED_CHATS_VISIBLE_COUNT;
return (
<section key={group.id} aria-label={group.label}>
{group.kind === "project"
&& limitedGroups[index - 1]?.kind !== "project" ? (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{labels.projects}
</div>
) : null}
{group.kind === "project" ? (
<ProjectGroupHeader
label={group.label}
path={group.projectPath}
collapsed={Boolean(collapsedGroups[group.id])}
onToggle={() => onToggleGroup?.(group.id)}
onRequestRename={
group.projectKey && onRequestRenameProject
? () => onRequestRenameProject(group.projectKey ?? "", group.label)
: undefined
}
onNewChat={
group.projectPath && onNewChatInProject
? () => onNewChatInProject(group.projectPath ?? "", group.label)
: undefined
}
actionMenuPortalContainer={actionMenuPortalContainer}
updatedAt={showTimestamps ? group.updatedAt : null}
/>
) : (
<ChatsGroupHeader label={group.label} />
)}
{group.kind === "project" && collapsedGroups[group.id] ? null : (
<ul className="space-y-0.5">
{visibleSessions.map((s) => {
const active = s.key === activeKey;
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
const generatedTitle = s.title?.trim() || "";
const title = displayTitle(s, titleOverrides, t("chat.newChat"));
const tooltipTitle =
titleOverrides[s.key]?.trim() ||
generatedTitle ||
deriveTitle(s.preview, fallbackTitle);
const isPinned = pinned.has(s.key);
const isArchived = archived.has(s.key);
const preview = s.preview.trim();
const showPreview = showPreviews && preview && preview !== title;
const timestamp = showTimestamps
? relativeTime(s.updatedAt ?? s.createdAt)
: "";
const projectMode = group.kind === "project";
const activityState = running.has(s.chatId)
? "running"
: completed.has(s.chatId) && !active
? "complete"
: null;
return (
<li key={s.key} className="min-w-0">
<div
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
compact ? "min-h-7" : "min-h-8",
active
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
)}
>
<button
type="button"
onClick={() => onSelect(s.key)}
title={tooltipTitle}
className={cn(
"min-w-0 flex-1 overflow-hidden text-left",
compact ? "py-1" : "py-1.5",
projectMode && "pl-7",
)}
>
{projectMode ? (
<span className="flex w-full min-w-0 items-baseline gap-2">
<span className="min-w-0 flex-1 truncate font-medium leading-5">
{title}
</span>
{timestamp ? (
<span className="shrink-0 text-[11.5px] font-medium text-muted-foreground/58">
{timestamp}
</span>
) : null}
</span>
) : (
<span className="block w-full truncate font-medium leading-5">
{title}
</span>
)}
{showPreview ? (
<span className="block w-full truncate text-[11.5px] leading-4 text-muted-foreground/72">
{preview}
</span>
) : null}
{timestamp && !projectMode ? (
<span className="block w-full truncate text-[11px] leading-4 text-muted-foreground/58">
{timestamp}
</span>
) : null}
</button>
<SessionActivityIndicator state={activityState} />
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/75 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => onTogglePin(s.key)}
>
{isPinned ? (
<PinOff className="mr-2 h-4 w-4" />
) : (
<Pin className="mr-2 h-4 w-4" />
)}
{isPinned ? t("chat.unpin") : t("chat.pin")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onRequestRename(s.key, title)}
>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onToggleArchive(s.key)}
>
{isArchived ? (
<ArchiveRestore className="mr-2 h-4 w-4" />
) : (
<Archive className="mr-2 h-4 w-4" />
)}
{isArchived ? t("chat.unarchive") : t("chat.archive")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
)}
{foldableChatsGroup && canToggleFold ? (
<ChatsFoldFooter
folded={foldedChatsGroup}
hiddenCount={hiddenInGroup}
onToggle={() => onToggleGroup?.(group.id)}
/>
) : null}
</section>
);
})}
{hiddenSessionCount > 0 ? (
<div className="px-2 pb-2 pt-1">
<button
type="button"
onClick={() =>
setVisibleLimit((limit) =>
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
)
}
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/65 hover:text-muted-foreground"
>
{t("chat.showMore", { count: hiddenSessionCount })}
</button>
</div>
) : null}
</div>
</div>
);
});
function ProjectGroupHeader({
label,
path,
collapsed,
onToggle,
onRequestRename,
onNewChat,
actionMenuPortalContainer,
updatedAt,
}: {
label: string;
path?: string;
collapsed: boolean;
onToggle: () => void;
onRequestRename?: () => void;
onNewChat?: () => void;
actionMenuPortalContainer?: HTMLElement | null;
updatedAt?: string | null;
}) {
const { t } = useTranslation();
return (
<div
title={path}
className="group flex min-w-0 items-center gap-1 px-1 pb-1 pt-1 text-[12px] font-medium text-muted-foreground/78"
>
<button
type="button"
aria-expanded={!collapsed}
onClick={onToggle}
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg px-1.5 py-1 text-left transition-colors hover:bg-sidebar-accent/45 hover:text-sidebar-foreground"
>
<Folder className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
{updatedAt ? (
<span className="shrink-0 text-[11px] text-muted-foreground/55">
{relativeTime(updatedAt)}
</span>
) : null}
{onRequestRename ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
aria-label={t("chat.actions", { title: label })}
onClick={(event) => event.stopPropagation()}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
portalContainer={actionMenuPortalContainer}
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem onSelect={onRequestRename}>
<Pencil className="mr-2 h-4 w-4" />
{t("chat.rename")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
{onNewChat ? (
<button
type="button"
aria-label={t("chat.newInProject", { project: label })}
title={t("chat.newInProject", { project: label })}
onClick={(event) => {
event.stopPropagation();
onNewChat();
}}
className={cn(
"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-40 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100 focus-visible:opacity-100",
)}
>
<Plus className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
}
function ChatsGroupHeader({ label }: { label: string }) {
return (
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
{label}
</div>
);
}
function ChatsFoldFooter({
folded,
hiddenCount,
onToggle,
}: {
folded: boolean;
hiddenCount: number;
onToggle: () => void;
}) {
const { t, i18n } = useTranslation();
const collapsedFallback = i18n.resolvedLanguage?.startsWith("zh")
? `已折叠 ${hiddenCount} 个对话`
: `${hiddenCount} hidden chats`;
return (
<div className="px-2 pb-1 pt-1">
<button
type="button"
onClick={onToggle}
className="h-7 w-full rounded-xl text-left text-[12px] font-medium text-muted-foreground/65 transition-colors hover:bg-sidebar-accent/50 hover:text-muted-foreground"
>
<span className="px-2">
{folded
? t("chat.collapsed", {
count: hiddenCount,
defaultValue: collapsedFallback,
})
: t("chat.showLess")}
</span>
</button>
</div>
);
}
function SessionActivityIndicator({
state,
}: {
state: "running" | "complete" | null;
}) {
const { t } = useTranslation();
if (state === "running") {
const label = t("chat.activity.running");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-3 w-3 animate-spin rounded-full border border-blue-500/25 border-t-blue-500 [animation-duration:1.4s] motion-reduce:animate-none dark:border-blue-400/25 dark:border-t-blue-400" />
</span>
);
}
if (state === "complete") {
const label = t("chat.activity.complete");
return (
<span
aria-label={label}
title={label}
className="grid h-4 w-4 shrink-0 place-items-center"
>
<span className="h-2 w-2 rounded-full bg-blue-500 dark:bg-blue-400" />
</span>
);
}
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />;
}