feat(webui): add project workspaces and access controls (#4007)
* feat(webui): add project workspaces and access controls * feat(webui): add project workspaces and access controls * refactor(tools): centralize workspace access resolution * refactor(webui): remove unused workspace host state * fix(webui): hide estimated file edit label * fix(webui): clarify file edit deletion feedback * fix(webui): label deleted file activity * fix(webui): flatten file edit activity rows * fix(core): remove path-only patch deletion * fix(core): keep apply patch non-destructive * refactor(webui): trim workspace host plumbing * fix(tools): register exec with tools config
This commit is contained in:
+348
-316
@@ -7,10 +7,12 @@ import {
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Folder,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -22,6 +24,17 @@ import {
|
||||
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";
|
||||
|
||||
@@ -36,9 +49,14 @@ interface ChatListProps {
|
||||
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;
|
||||
@@ -46,6 +64,7 @@ interface ChatListProps {
|
||||
showTimestamps?: boolean;
|
||||
sort?: SidebarSortMode;
|
||||
showArchived?: boolean;
|
||||
defaultWorkspacePath?: string | null;
|
||||
actionMenuPortalContainer?: HTMLElement | null;
|
||||
loading?: boolean;
|
||||
emptyLabel?: string;
|
||||
@@ -59,9 +78,14 @@ export const ChatList = memo(function ChatList({
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
onToggleGroup,
|
||||
onRequestRenameProject,
|
||||
onNewChatInProject,
|
||||
pinnedKeys = [],
|
||||
archivedKeys = [],
|
||||
titleOverrides = {},
|
||||
projectNameOverrides = {},
|
||||
collapsedGroups = {},
|
||||
runningChatIds = [],
|
||||
completedChatIds = [],
|
||||
density = "comfortable",
|
||||
@@ -69,19 +93,21 @@ export const ChatList = memo(function ChatList({
|
||||
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(() => ({
|
||||
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(
|
||||
@@ -89,8 +115,10 @@ export const ChatList = memo(function ChatList({
|
||||
pinnedKeys,
|
||||
archivedKeys,
|
||||
titleOverrides,
|
||||
projectNameOverrides,
|
||||
showArchived,
|
||||
sort,
|
||||
defaultWorkspacePath,
|
||||
}),
|
||||
[
|
||||
archivedKeys,
|
||||
@@ -100,15 +128,21 @@ export const ChatList = memo(function ChatList({
|
||||
showArchived,
|
||||
sort,
|
||||
titleOverrides,
|
||||
projectNameOverrides,
|
||||
defaultWorkspacePath,
|
||||
],
|
||||
);
|
||||
const limitedGroups = useMemo(
|
||||
() => limitGroups(groups, visibleLimit, activeKey),
|
||||
[activeKey, groups, visibleLimit],
|
||||
() => limitGroups(groups, visibleLimit, activeKey, collapsedGroups),
|
||||
[activeKey, collapsedGroups, groups, visibleLimit],
|
||||
);
|
||||
const totalSessionCount = useMemo(
|
||||
() => groups.reduce((total, group) => total + group.sessions.length, 0),
|
||||
[groups],
|
||||
() => 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),
|
||||
@@ -143,131 +177,194 @@ export const ChatList = memo(function ChatList({
|
||||
const compact = density === "compact";
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain">
|
||||
<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) => (
|
||||
<section key={group.label} aria-label={group.label}>
|
||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
||||
{group.label}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{group.sessions.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 activityState = running.has(s.chatId)
|
||||
? "running"
|
||||
: completed.has(s.chatId)
|
||||
? "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",
|
||||
)}
|
||||
>
|
||||
<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 ? (
|
||||
<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
|
||||
{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(
|
||||
"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",
|
||||
"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",
|
||||
)}
|
||||
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" />
|
||||
<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",
|
||||
)}
|
||||
{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" />
|
||||
{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>
|
||||
) : (
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
<span className="block w-full truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
</section>
|
||||
))}
|
||||
{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
|
||||
@@ -277,7 +374,7 @@ export const ChatList = memo(function ChatList({
|
||||
Math.min(totalSessionCount, limit + VISIBLE_SESSIONS_INCREMENT),
|
||||
)
|
||||
}
|
||||
className="h-8 w-full rounded-full text-[12px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent/65 hover:text-sidebar-foreground"
|
||||
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>
|
||||
@@ -288,6 +385,133 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
}: {
|
||||
@@ -316,202 +540,10 @@ function SessionActivityIndicator({
|
||||
title={label}
|
||||
className="grid h-4 w-4 shrink-0 place-items-center"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 shadow-[0_0_0_3px_rgba(59,130,246,0.14)] dark:bg-blue-400 dark:shadow-[0_0_0_3px_rgba(96,165,250,0.18)]" />
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="h-4 w-4 shrink-0" aria-hidden="true" />;
|
||||
}
|
||||
|
||||
function groupSessions(
|
||||
sessions: ChatSummary[],
|
||||
labels: {
|
||||
pinned: string;
|
||||
all: string;
|
||||
today: string;
|
||||
yesterday: string;
|
||||
earlier: string;
|
||||
archived: string;
|
||||
fallbackTitle: string;
|
||||
},
|
||||
options: {
|
||||
pinnedKeys: string[];
|
||||
archivedKeys: string[];
|
||||
titleOverrides: Record<string, string>;
|
||||
showArchived: boolean;
|
||||
sort: SidebarSortMode;
|
||||
},
|
||||
): Array<{ label: string; sessions: ChatSummary[] }> {
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
|
||||
const buckets = new Map<string, ChatSummary[]>();
|
||||
const pinned = new Set(options.pinnedKeys);
|
||||
const archived = new Set(options.archivedKeys);
|
||||
|
||||
const pinnedSessions: ChatSummary[] = [];
|
||||
const archivedSessions: ChatSummary[] = [];
|
||||
const normalSessions: ChatSummary[] = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
if (archived.has(session.key)) {
|
||||
if (options.showArchived) archivedSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
if (pinned.has(session.key)) {
|
||||
pinnedSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
if (options.sort === "title_asc") {
|
||||
normalSessions.push(session);
|
||||
continue;
|
||||
}
|
||||
const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? "");
|
||||
const label = Number.isFinite(timestamp) && timestamp >= startOfToday
|
||||
? labels.today
|
||||
: Number.isFinite(timestamp) && timestamp >= startOfYesterday
|
||||
? labels.yesterday
|
||||
: labels.earlier;
|
||||
const bucket = buckets.get(label) ?? [];
|
||||
bucket.push(session);
|
||||
buckets.set(label, bucket);
|
||||
}
|
||||
|
||||
const groups = [labels.today, labels.yesterday, labels.earlier]
|
||||
.map((label) => ({
|
||||
label,
|
||||
sessions: sortSessions(
|
||||
buckets.get(label) ?? [],
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
),
|
||||
}))
|
||||
.filter((group) => group.sessions.length > 0);
|
||||
if (options.sort === "title_asc" && normalSessions.length) {
|
||||
groups.push({
|
||||
label: labels.all,
|
||||
sessions: sortSessions(
|
||||
normalSessions,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (pinnedSessions.length) {
|
||||
groups.unshift({
|
||||
label: labels.pinned,
|
||||
sessions: sortSessions(
|
||||
pinnedSessions,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
),
|
||||
});
|
||||
}
|
||||
if (archivedSessions.length) {
|
||||
groups.push({
|
||||
label: labels.archived,
|
||||
sessions: sortSessions(
|
||||
archivedSessions,
|
||||
options.sort,
|
||||
options.titleOverrides,
|
||||
),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function limitGroups(
|
||||
groups: Array<{ label: string; sessions: ChatSummary[] }>,
|
||||
limit: number,
|
||||
activeKey: string | null,
|
||||
): Array<{ label: string; sessions: ChatSummary[] }> {
|
||||
let remaining = Math.max(0, limit);
|
||||
let activeVisible = !activeKey;
|
||||
const out: Array<{ label: string; sessions: ChatSummary[] }> = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const visible = remaining > 0
|
||||
? group.sessions.slice(0, remaining)
|
||||
: [];
|
||||
remaining -= visible.length;
|
||||
if (activeKey && visible.some((session) => session.key === activeKey)) {
|
||||
activeVisible = true;
|
||||
}
|
||||
if (visible.length > 0) {
|
||||
out.push({ label: group.label, sessions: visible });
|
||||
}
|
||||
}
|
||||
|
||||
if (activeVisible || !activeKey) return out;
|
||||
|
||||
for (const group of groups) {
|
||||
const active = group.sessions.find((session) => session.key === activeKey);
|
||||
if (!active) continue;
|
||||
const existing = out.find((item) => item.label === group.label);
|
||||
if (existing) {
|
||||
existing.sessions = [...existing.sessions, active];
|
||||
} else {
|
||||
out.push({ label: group.label, sessions: [active] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function sortSessions(
|
||||
sessions: ChatSummary[],
|
||||
sort: SidebarSortMode,
|
||||
titleOverrides: Record<string, string>,
|
||||
): ChatSummary[] {
|
||||
const copy = [...sessions];
|
||||
copy.sort((a, b) => {
|
||||
if (sort === "title_asc") {
|
||||
const titleOrder = titleForSort(a, titleOverrides).localeCompare(
|
||||
titleForSort(b, titleOverrides),
|
||||
"en",
|
||||
{ numeric: true, sensitivity: "base" },
|
||||
);
|
||||
if (titleOrder !== 0) return titleOrder;
|
||||
return sessionTime(b, "updatedAt") - sessionTime(a, "updatedAt");
|
||||
}
|
||||
const aTime = sessionTime(a, sort === "created_desc" ? "createdAt" : "updatedAt");
|
||||
const bTime = sessionTime(b, sort === "created_desc" ? "createdAt" : "updatedAt");
|
||||
return bTime - aTime;
|
||||
});
|
||||
return copy;
|
||||
}
|
||||
|
||||
function titleForSort(
|
||||
session: ChatSummary,
|
||||
titleOverrides: Record<string, string>,
|
||||
): string {
|
||||
return (
|
||||
titleOverrides[session.key]?.trim() ||
|
||||
session.title?.trim() ||
|
||||
deriveTitle(session.preview, "new chat")
|
||||
).toLocaleLowerCase("en");
|
||||
}
|
||||
|
||||
function displayTitle(
|
||||
session: ChatSummary,
|
||||
titleOverrides: Record<string, string>,
|
||||
fallbackTitle: string,
|
||||
): string {
|
||||
return (
|
||||
titleOverrides[session.key]?.trim() ||
|
||||
session.title?.trim() ||
|
||||
deriveTitle(session.preview, fallbackTitle)
|
||||
);
|
||||
}
|
||||
|
||||
function sessionTime(
|
||||
session: ChatSummary,
|
||||
field: "createdAt" | "updatedAt",
|
||||
): number {
|
||||
const primary = Date.parse(session[field] ?? "");
|
||||
if (Number.isFinite(primary)) return primary;
|
||||
const fallback = Date.parse(session.updatedAt ?? session.createdAt ?? "");
|
||||
return Number.isFinite(fallback) ? fallback : 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user