554 lines
17 KiB
TypeScript
554 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { UnicodeSpinner } from "../unicode-spinner";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "../ui/dropdown-menu";
|
|
|
|
type WebSession = {
|
|
id: string;
|
|
title: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
messageCount: number;
|
|
};
|
|
|
|
export type SidebarSubagentInfo = {
|
|
childSessionKey: string;
|
|
runId: string;
|
|
task: string;
|
|
label?: string;
|
|
parentSessionId: string;
|
|
status?: "running" | "completed" | "error";
|
|
};
|
|
|
|
type ChatSessionsSidebarProps = {
|
|
sessions: WebSession[];
|
|
activeSessionId: string | null;
|
|
/** Title of the currently active session (shown in the header). */
|
|
activeSessionTitle?: string;
|
|
/** Session IDs with an actively running agent stream. */
|
|
streamingSessionIds?: Set<string>;
|
|
/** Subagents spawned by chat sessions. */
|
|
subagents?: SidebarSubagentInfo[];
|
|
/** Currently selected subagent session key (if viewing a subagent). */
|
|
activeSubagentKey?: string | null;
|
|
onSelectSession: (sessionId: string) => void;
|
|
onNewSession: () => void;
|
|
/** Called when a subagent is selected in the sidebar. */
|
|
onSelectSubagent?: (sessionKey: string) => void;
|
|
/** When true, renders as a mobile overlay drawer instead of a static sidebar. */
|
|
mobile?: boolean;
|
|
/** Close the mobile drawer. */
|
|
onClose?: () => void;
|
|
/** Fixed width in px when not mobile (overrides default 260). */
|
|
width?: number;
|
|
/** Called when the user deletes a session from the sidebar menu. */
|
|
onDeleteSession?: (sessionId: string) => void;
|
|
/** Called when the user renames a session from the sidebar menu. */
|
|
onRenameSession?: (sessionId: string, newTitle: string) => void;
|
|
/** Called when the user clicks the collapse/hide sidebar button. */
|
|
onCollapse?: () => void;
|
|
/** When true, show a loader instead of empty state (e.g. initial sessions fetch). */
|
|
loading?: boolean;
|
|
};
|
|
|
|
/** Format a timestamp into a human-readable relative time string. */
|
|
function timeAgo(ts: number): string {
|
|
const now = Date.now();
|
|
const diff = now - ts;
|
|
const seconds = Math.floor(diff / 1000);
|
|
if (seconds < 60) {return "just now";}
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) {return `${minutes}m ago`;}
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) {return `${hours}h ago`;}
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) {return `${days}d ago`;}
|
|
const months = Math.floor(days / 30);
|
|
if (months < 12) {return `${months}mo ago`;}
|
|
return `${Math.floor(months / 12)}y ago`;
|
|
}
|
|
|
|
function PlusIcon() {
|
|
return (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M5 12h14" />
|
|
<path d="M12 5v14" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SubagentIcon() {
|
|
return (
|
|
<svg
|
|
width="11"
|
|
height="11"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M16 3h5v5" />
|
|
<path d="m21 3-7 7" />
|
|
<path d="M21 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ChatBubbleIcon() {
|
|
return (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function MoreHorizontalIcon() {
|
|
return (
|
|
<svg
|
|
width="14"
|
|
height="14"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="1" />
|
|
<circle cx="5" cy="12" r="1" />
|
|
<circle cx="19" cy="12" r="1" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function ChatSessionsSidebar({
|
|
sessions,
|
|
activeSessionId,
|
|
activeSessionTitle: _activeSessionTitle,
|
|
streamingSessionIds,
|
|
subagents,
|
|
activeSubagentKey,
|
|
onSelectSession,
|
|
onNewSession,
|
|
onSelectSubagent,
|
|
onDeleteSession,
|
|
onRenameSession,
|
|
onCollapse,
|
|
mobile,
|
|
onClose,
|
|
width: widthProp,
|
|
loading = false,
|
|
}: ChatSessionsSidebarProps) {
|
|
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
|
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState("");
|
|
|
|
const handleSelect = useCallback(
|
|
(id: string) => {
|
|
onSelectSession(id);
|
|
onClose?.();
|
|
},
|
|
[onSelectSession, onClose],
|
|
);
|
|
|
|
const handleSelectSubagentItem = useCallback(
|
|
(sessionKey: string) => {
|
|
onSelectSubagent?.(sessionKey);
|
|
onClose?.();
|
|
},
|
|
[onSelectSubagent, onClose],
|
|
);
|
|
|
|
const handleDeleteSession = useCallback(
|
|
(sessionId: string) => {
|
|
onDeleteSession?.(sessionId);
|
|
},
|
|
[onDeleteSession],
|
|
);
|
|
|
|
const handleStartRename = useCallback((sessionId: string, currentTitle: string) => {
|
|
setRenamingId(sessionId);
|
|
setRenameValue(currentTitle || "");
|
|
}, []);
|
|
|
|
const handleCommitRename = useCallback(() => {
|
|
if (renamingId && renameValue.trim()) {
|
|
onRenameSession?.(renamingId, renameValue.trim());
|
|
}
|
|
setRenamingId(null);
|
|
setRenameValue("");
|
|
}, [renamingId, renameValue, onRenameSession]);
|
|
|
|
// Index subagents by parent session ID
|
|
const subagentsByParent = useMemo(() => {
|
|
const map = new Map<string, SidebarSubagentInfo[]>();
|
|
if (!subagents) {return map;}
|
|
for (const sa of subagents) {
|
|
let list = map.get(sa.parentSessionId);
|
|
if (!list) {
|
|
list = [];
|
|
map.set(sa.parentSessionId, list);
|
|
}
|
|
list.push(sa);
|
|
}
|
|
return map;
|
|
}, [subagents]);
|
|
|
|
// Group sessions: today, yesterday, this week, this month, older
|
|
const grouped = groupSessions(sessions);
|
|
|
|
const width = mobile ? "280px" : (widthProp ?? 260);
|
|
const headerHeight = 40; // px — match padding so list content clears the overlay
|
|
const sidebar = (
|
|
<aside
|
|
className={`flex flex-col h-full shrink-0 ${mobile ? "drawer-right" : "border-l"}`}
|
|
style={{
|
|
width: typeof width === "number" ? `${width}px` : width,
|
|
minWidth: typeof width === "number" ? `${width}px` : width,
|
|
borderColor: "var(--color-border)",
|
|
background: "var(--color-sidebar-bg)",
|
|
}}
|
|
>
|
|
{/* Scrollable list fills the sidebar; header overlays the top with blur */}
|
|
<div className="flex-1 min-h-0 relative">
|
|
{/* Session list — scrolls under the header */}
|
|
<div
|
|
className="absolute inset-0 overflow-y-auto"
|
|
style={{ paddingTop: headerHeight }}
|
|
>
|
|
{loading && sessions.length === 0 ? (
|
|
<div className="px-4 py-8 flex flex-col items-center justify-center min-h-[120px]">
|
|
<UnicodeSpinner
|
|
name="braille"
|
|
className="text-xl mb-2"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
/>
|
|
<p
|
|
className="text-xs"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Loading…
|
|
</p>
|
|
</div>
|
|
) : sessions.length === 0 ? (
|
|
<div className="px-4 py-8 text-center">
|
|
<div
|
|
className="mx-auto w-10 h-10 rounded-xl flex items-center justify-center mb-3"
|
|
style={{
|
|
background: "var(--color-surface-hover)",
|
|
color: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
<ChatBubbleIcon />
|
|
</div>
|
|
<p
|
|
className="text-xs"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
No conversations yet.
|
|
<br />
|
|
Start a new chat to begin.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="px-2 py-1">
|
|
{grouped.map((group) => (
|
|
<div key={group.label}>
|
|
<div
|
|
className="px-2 pt-3 pb-1 text-[10px] font-medium uppercase tracking-wider"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{group.label}
|
|
</div>
|
|
{group.sessions.map((session) => {
|
|
const isActive = session.id === activeSessionId && !activeSubagentKey;
|
|
const isHovered = session.id === hoveredId;
|
|
const showMore = isHovered;
|
|
const isStreamingSession = streamingSessionIds?.has(session.id) ?? false;
|
|
const sessionSubagents = subagentsByParent.get(session.id);
|
|
return (
|
|
<div
|
|
key={session.id}
|
|
className="group relative"
|
|
onMouseEnter={() => setHoveredId(session.id)}
|
|
onMouseLeave={() => setHoveredId(null)}
|
|
>
|
|
<div
|
|
className="flex items-stretch w-full rounded-lg"
|
|
style={{
|
|
background: isActive
|
|
? "var(--color-chat-sidebar-active-bg)"
|
|
: isHovered
|
|
? "var(--color-surface-hover)"
|
|
: "transparent",
|
|
}}
|
|
>
|
|
{renamingId === session.id ? (
|
|
<form
|
|
className="flex-1 min-w-0 px-2 py-1.5"
|
|
onSubmit={(e) => { e.preventDefault(); handleCommitRename(); }}
|
|
>
|
|
<input
|
|
type="text"
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={handleCommitRename}
|
|
onKeyDown={(e) => { if (e.key === "Escape") { setRenamingId(null); setRenameValue(""); } }}
|
|
autoFocus
|
|
className="w-full text-xs font-medium px-1 py-0.5 rounded outline-none border"
|
|
style={{ color: "var(--color-text)", background: "var(--color-surface)", borderColor: "var(--color-border)" }}
|
|
/>
|
|
</form>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => handleSelect(session.id)}
|
|
className="flex-1 min-w-0 text-left px-2 py-2 rounded-l-lg transition-colors cursor-pointer"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
{isStreamingSession && (
|
|
<UnicodeSpinner
|
|
name="braille"
|
|
className="text-[10px] flex-shrink-0"
|
|
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
|
/>
|
|
)}
|
|
<div
|
|
className="text-xs font-medium truncate"
|
|
style={{
|
|
color: isActive
|
|
? "var(--color-chat-sidebar-active-text)"
|
|
: "var(--color-text)",
|
|
}}
|
|
>
|
|
{session.title || "Untitled chat"}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-0.5" style={{ paddingLeft: isStreamingSession ? "calc(0.375rem + 6px)" : undefined }}>
|
|
|
|
<span
|
|
className="text-[10px]"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{timeAgo(session.updatedAt)}
|
|
</span>
|
|
{session.messageCount > 0 && (
|
|
<span
|
|
className="text-[10px]"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{session.messageCount} msg{session.messageCount !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
)}
|
|
{onDeleteSession && (
|
|
<div className={`shrink-0 flex items-center pr-1 transition-opacity ${showMore ? "opacity-100" : "opacity-0"}`}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="flex items-center justify-center w-6 h-6 rounded-md"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="More options"
|
|
aria-label="More options"
|
|
>
|
|
<MoreHorizontalIcon />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" side="bottom">
|
|
<DropdownMenuItem
|
|
onSelect={() => handleStartRename(session.id, session.title)}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" /></svg>
|
|
Rename
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
onSelect={() => handleDeleteSession(session.id)}
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18" /><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" /><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" /></svg>
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Subagent sub-items */}
|
|
{sessionSubagents && sessionSubagents.length > 0 && (
|
|
<div className="ml-4 border-l" style={{ borderColor: "var(--color-border)" }}>
|
|
{sessionSubagents.map((sa) => {
|
|
const isSubActive = activeSubagentKey === sa.childSessionKey;
|
|
const isSubRunning = sa.status === "running";
|
|
const subLabel = sa.label || sa.task;
|
|
const truncated = subLabel.length > 40 ? subLabel.slice(0, 40) + "..." : subLabel;
|
|
return (
|
|
<button
|
|
key={sa.childSessionKey}
|
|
type="button"
|
|
onClick={() => handleSelectSubagentItem(sa.childSessionKey)}
|
|
className="w-full text-left pl-3 pr-2 py-1.5 rounded-r-lg transition-colors cursor-pointer"
|
|
style={{
|
|
background: isSubActive
|
|
? "var(--color-chat-sidebar-active-bg)"
|
|
: "transparent",
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
{isSubRunning && (
|
|
<UnicodeSpinner
|
|
name="braille"
|
|
className="text-[9px] flex-shrink-0"
|
|
style={{ color: "var(--color-chat-sidebar-muted)" }}
|
|
/>
|
|
)}
|
|
<SubagentIcon />
|
|
<span
|
|
className="text-[11px] truncate"
|
|
style={{
|
|
color: isSubActive
|
|
? "var(--color-chat-sidebar-active-text)"
|
|
: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
{truncated}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Header overlay: backdrop blur + 80% bg; list scrolls under it */}
|
|
<div
|
|
className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between border-b px-4 py-2 backdrop-blur-md"
|
|
style={{
|
|
height: headerHeight,
|
|
borderColor: "var(--color-border)",
|
|
background: "color-mix(in srgb, var(--color-sidebar-bg) 80%, transparent)",
|
|
}}
|
|
>
|
|
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
|
{onCollapse && (
|
|
<button
|
|
type="button"
|
|
onClick={onCollapse}
|
|
className="p-1 rounded-md shrink-0 transition-colors hover:bg-black/5"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Hide chat sidebar (⌘⇧B)"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
<path d="M15 3v18" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
<span
|
|
className="text-xs font-medium truncate block"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
Chats
|
|
</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onNewSession}
|
|
className="flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-medium transition-colors cursor-pointer shrink-0 ml-1.5"
|
|
style={{
|
|
color: "var(--color-chat-sidebar-active-text)",
|
|
background: "var(--color-chat-sidebar-active-bg)",
|
|
}}
|
|
title="New chat"
|
|
>
|
|
<PlusIcon />
|
|
New
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
|
|
if (!mobile) { return sidebar; }
|
|
|
|
return (
|
|
<div className="drawer-backdrop" onClick={() => void onClose?.()}>
|
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
|
<div onClick={(e) => e.stopPropagation()} className="fixed inset-y-0 right-0 z-50">
|
|
{sidebar}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Grouping helpers ──
|
|
|
|
type SessionGroup = {
|
|
label: string;
|
|
sessions: WebSession[];
|
|
};
|
|
|
|
function groupSessions(sessions: WebSession[]): SessionGroup[] {
|
|
const now = new Date();
|
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const yesterdayStart = todayStart - 86400000;
|
|
const weekStart = todayStart - 7 * 86400000;
|
|
const monthStart = todayStart - 30 * 86400000;
|
|
|
|
const today: WebSession[] = [];
|
|
const yesterday: WebSession[] = [];
|
|
const thisWeek: WebSession[] = [];
|
|
const thisMonth: WebSession[] = [];
|
|
const older: WebSession[] = [];
|
|
|
|
for (const s of sessions) {
|
|
const t = s.updatedAt;
|
|
if (t >= todayStart) {today.push(s);}
|
|
else if (t >= yesterdayStart) {yesterday.push(s);}
|
|
else if (t >= weekStart) {thisWeek.push(s);}
|
|
else if (t >= monthStart) {thisMonth.push(s);}
|
|
else {older.push(s);}
|
|
}
|
|
|
|
const groups: SessionGroup[] = [];
|
|
if (today.length > 0) {groups.push({ label: "Today", sessions: today });}
|
|
if (yesterday.length > 0) {groups.push({ label: "Yesterday", sessions: yesterday });}
|
|
if (thisWeek.length > 0) {groups.push({ label: "This Week", sessions: thisWeek });}
|
|
if (thisMonth.length > 0) {groups.push({ label: "This Month", sessions: thisMonth });}
|
|
if (older.length > 0) {groups.push({ label: "Older", sessions: older });}
|
|
return groups;
|
|
}
|