import { SessionOptions } from "./session-options"; const EMPTY_REPOS: SessionSidebarRepo[] = []; import type { ComponentPropsWithRef, MouseEvent, ReactElement, ReactNode } from "react"; import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import * as format from "../../lib/format"; import { AnimatePresence, m as motion, useReducedMotion } from "motion/react"; import { SessionItem } from "../session-item/session-item"; import { SessionItemLoading } from "../session-item/session-item-loading"; import type { MoveTarget, SidebarRow } from "../session-item/session-item.types"; import { middle } from "../../lib/truncate"; import { cn } from "../../lib/utils"; import { Avatar, AvatarFallback, AvatarImage } from "../../ui/avatar"; import { Button } from "../../ui/button"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "../../ui/context-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../ui/dropdown-menu"; import { Skeleton } from "../../ui/skeleton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../../ui/tooltip"; import { isSidebarRowSelected } from "./selection"; import { IconArchive, IconCheck, IconChevronDown, IconPlus, IconRows, IconX } from "../../icons"; export type SessionSidebarGroupId = | "pinned" | "mine" | "team" | "inactive" | "archived" | `project:${string}` /** A teammate's channels by owner, with `:archived` for their archived ones. */ | `team:${string}`; export type SessionSidebarGroup = { id: SessionSidebarGroupId; label: string; rows: SidebarRow[]; /** Preserve caller ordering for ranked or hierarchical groups. */ sort?: "creation" | "none"; collapsed?: boolean; onNewSession?: () => void; }; export type SessionSidebarRepo = { id: string; kind?: "project" | "all"; name: string; org: string; avatar?: string; }; export type SessionSidebarProps = Omit, "children" | "onSelect"> & { projectName?: string; repos?: SessionSidebarRepo[]; selectedRepoId?: SessionSidebarRepo["id"]; groups: SessionSidebarGroup[]; lobby?: SidebarRow; loading?: boolean; selectedUid?: SidebarRow["uid"]; onRepoChange?: (repo: SessionSidebarRepo) => void; onRepoRemove?: (repo: SessionSidebarRepo) => void; onSelect?: (row: SidebarRow) => void; onPin?: (row: SidebarRow) => void; onUnpin?: (row: SidebarRow) => void; onArchive?: (row: SidebarRow) => void; /** When provided, the channels header's context menu offers "Archive inactive". */ onArchiveInactive?: () => void; onFork?: (row: SidebarRow) => void; onInfo?: (row: SidebarRow) => void; onLeave?: (row: SidebarRow) => void; onDelete?: (row: SidebarRow) => void; onRebuildLobby?: (row: SidebarRow) => void; onRename?: (row: SidebarRow) => void; /** Rows whose capabilities allow moving list these destinations under "Move to:". */ onMoveTargets?: (row: SidebarRow) => Promise; onMove?: (row: SidebarRow, target: MoveTarget) => Promise; onToggleGroup?: (id: SessionSidebarGroupId) => void; onNewSession?: () => void; /** When provided, a "…" menu button appears offering "Continue a pull request…". */ onContinuePr?: () => void; /** When provided, the "…" menu offers "Start from an issue…". */ onStartIssue?: () => void; onAddRepo?: () => void; groupHeaderClassName?: string; empty?: ReactNode; }; const CHEVRON_TRANSITION = { duration: 0.15, ease: "easeOut" as const }; const CHEVRON_TRANSITION_REDUCED = { duration: 0, ease: "easeOut" as const }; const GROUP_TRANSITION = { duration: 0.18, ease: "easeOut" as const }; const GROUP_TRANSITION_REDUCED = { duration: 0, ease: "easeOut" as const }; const PANE_TRANSITION = { type: "spring" as const, duration: 0.28, bounce: 0 }; const PANE_TRANSITION_REDUCED = { duration: 0 }; const REPO_NAME_FONT = "12px Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; function count(groups: SessionSidebarGroup[]) { return groups.reduce((total, group) => total + group.rows.length, 0); } function byCreation(a: SidebarRow, b: SidebarRow) { return b.createdAt - a.createdAt || b.uid.localeCompare(a.uid); } function rowKey(row: SidebarRow) { return row.renderKey ?? row.uid; } function fallback(name: string) { return name.slice(0, 2).toUpperCase(); } function byOrg(repos: SessionSidebarRepo[]) { let groups = new Map(); for (let repo of repos) { if (repo.kind === "all") continue; let list = groups.get(repo.org); if (list) list.push(repo); else groups.set(repo.org, [repo]); } return [...groups]; } function RepoAvatar({ repo, className }: { repo: SessionSidebarRepo; className?: string }) { if (repo.kind === "all") return ; let name = repo.org || repo.name; let source = repo.avatar || (repo.org ? format.avatar(repo.org) : undefined); return ( {source && } {fallback(name)} ); } function RepoName({ name }: { name: string }) { let root = useRef(null); let [label, setLabel] = useState(name); useLayoutEffect(() => { function update() { let outer = root.current; if (!outer) return; let font = typeof getComputedStyle === "undefined" ? REPO_NAME_FONT : getComputedStyle(outer).font || REPO_NAME_FONT; setLabel(middle(name, outer.clientWidth, font)); } update(); let observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(update); if (root.current) observer?.observe(root.current); return () => { observer?.disconnect(); }; }, [name]); return ( {label} ); } function RepoRow({ repo, selected, onRepoChange, onRepoRemove }: { repo: SessionSidebarRepo; selected: boolean; onRepoChange?: (repo: SessionSidebarRepo) => void; onRepoRemove?: (repo: SessionSidebarRepo) => void; }) { function remove(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); onRepoRemove?.(repo); } return ( onRepoChange?.(repo)} onKeyDown={event => { if (event.target !== event.currentTarget || !onRepoRemove) return; if (event.key !== "Delete" && event.key !== "Backspace") return; event.preventDefault(); event.stopPropagation(); onRepoRemove(repo); }} > {selected && ( )} {onRepoRemove && ( event.stopPropagation()} onMouseUp={(event) => event.stopPropagation()} onKeyDown={event => { if (event.key === "Enter" || event.key === " ") event.stopPropagation(); }} onKeyUp={event => { if (event.key === "Enter" || event.key === " ") event.stopPropagation(); }} onClick={remove} /> } > Remove project )} ); } /** The shared project menu used by the session sidebar and project dashboard. */ export function ProjectPicker( { projectName, repos = EMPTY_REPOS, selectedRepoId, onRepoChange, onRepoRemove, onAddRepo, trigger, children, }: { projectName?: string; repos?: SessionSidebarRepo[]; selectedRepoId?: SessionSidebarRepo["id"]; onRepoChange?: (repo: SessionSidebarRepo) => void; onRepoRemove?: (repo: SessionSidebarRepo) => void; onAddRepo?: () => void; trigger?: ReactElement; children?: ReactNode; }, ) { let selected = repos.find((repo) => repo.id === selectedRepoId) ?? repos[0]; let orgGroups = useMemo(() => byOrg(repos), [repos]); let all = repos.find((repo) => repo.kind === "all"); if (!selected) { return
{projectName ?? "Projects"}
; } let name = selected.org ? `${selected.org}/${selected.name}` : selected.name; return ( )} > {children || ( <> {selected.name} )} {all && ( <> )} {onAddRepo && ( <> Open folder… {orgGroups.length > 0 && } )} {orgGroups.map(([org, list], index) => ( {index > 0 && } {org || "Projects"} {list.map((repo) => ( ))} ))} ); } function GroupHeaderContent({ group, collapsed }: { group: SessionSidebarGroup; collapsed: boolean; }) { let reduced = useReducedMotion(); return ( <> {group.id === "archived" && ( )} {group.label} ); } function GroupRows({ rows, pinned, archived, selectedUid, onSelect, onPin, onUnpin, onArchive, onFork, onInfo, onLeave, onDelete, onRename, onMoveTargets, onMove, }: { rows: SidebarRow[]; pinned: boolean; archived: boolean; selectedUid?: SidebarRow["uid"]; onSelect?: (row: SidebarRow) => void; onPin?: (row: SidebarRow) => void; onUnpin?: (row: SidebarRow) => void; onArchive?: (row: SidebarRow) => void; onFork?: (row: SidebarRow) => void; onInfo?: (row: SidebarRow) => void; onLeave?: (row: SidebarRow) => void; onDelete?: (row: SidebarRow) => void; onRename?: (row: SidebarRow) => void; onMoveTargets?: (row: SidebarRow) => Promise; onMove?: (row: SidebarRow, target: MoveTarget) => Promise; }) { let selected = (row: SidebarRow) => isSidebarRowSelected(row, selectedUid); return (
{rows.map((row) => row.lifecycle === "creating" ? ( ) : ( onSelect?.(row)} /> ) )}
); } function Group({ group, selectedUid, onSelect, onPin, onUnpin, onArchive, onArchiveInactive, onFork, onInfo, onLeave, onDelete, onToggleGroup, onNewSession, onContinuePr, onStartIssue, groupHeaderClassName, showHeader = true, full = false, onRename, onMoveTargets, onMove, }: { group: SessionSidebarGroup; selectedUid?: SidebarRow["uid"]; onSelect?: (row: SidebarRow) => void; onPin?: (row: SidebarRow) => void; onUnpin?: (row: SidebarRow) => void; onArchive?: (row: SidebarRow) => void; onFork?: (row: SidebarRow) => void; onInfo?: (row: SidebarRow) => void; onLeave?: (row: SidebarRow) => void; onDelete?: (row: SidebarRow) => void; onRename?: (row: SidebarRow) => void; onMoveTargets?: (row: SidebarRow) => Promise; onMove?: (row: SidebarRow, target: MoveTarget) => Promise; onArchiveInactive?: () => void; onToggleGroup?: (id: SessionSidebarGroupId) => void; onNewSession?: () => void; onContinuePr?: () => void; onStartIssue?: () => void; groupHeaderClassName?: string; showHeader?: boolean; full?: boolean; }) { let pinned = group.id === "pinned"; let archived = group.id === "archived"; let rows = useMemo( () => pinned || group.sort === "none" || group.rows.some(row => row.depth !== undefined) ? group.rows : [...group.rows].sort(byCreation), [group.rows, group.sort, pinned], ); let onPinRow = archived ? undefined : onPin; let collapsed = showHeader && Boolean(group.collapsed); let content = useId(); let reduced = useReducedMotion(); let transition = reduced ? GROUP_TRANSITION_REDUCED : GROUP_TRANSITION; let header = useRef(null); let panel = useRef(null); let action = group.onNewSession || (group.id === "mine" ? onNewSession : undefined); let actionLabel = group.id.startsWith("project:") ? `New channel in ${group.label}` : "New channel"; let archiveInactive = group.id === "mine" ? onArchiveInactive : undefined; let create = useCallback(() => { if (collapsed) onToggleGroup?.(group.id); action?.(); }, [action, collapsed, group.id, onToggleGroup]); let actionButtonClassName = "size-5.5 border border-transparent bg-clip-padding text-muted-foreground shadow-none hover:border-[var(--edge)] hover:bg-background/50 hover:text-sidebar-foreground hover:shadow-[0_1px_2px_rgb(0_0_0/0.08)] hover:backdrop-blur-[1px] focus-visible:border-[var(--edge)] focus-visible:bg-background/50 focus-visible:text-sidebar-foreground focus-visible:shadow-[0_1px_2px_rgb(0_0_0/0.08)] focus-visible:backdrop-blur-[1px] dark:hover:border-black/20 dark:hover:bg-background/50 dark:focus-visible:border-black/20 dark:focus-visible:bg-background/50"; useEffect(() => { let button = header.current; if (!showHeader || !archived || collapsed) { if (button) delete button.dataset.shadow; return; } let root = panel.current; if (!root) return; let update = () => { if (!button) return; if (root.scrollTop > 0) button.dataset.shadow = "true"; else delete button.dataset.shadow; }; update(); root.addEventListener("scroll", update, { passive: true }); return () => root.removeEventListener("scroll", update); }, [archived, collapsed, rows.length, showHeader]); let body = ( ); let panelClassName = cn( "overflow-hidden", archived && "scrollbar-muted scroll-fade min-h-0 flex-1 scrollbar-thumb-[color-mix(in_oklch,var(--color-sidebar),black_28%)] dark:scrollbar-thumb-[color-mix(in_oklch,var(--color-sidebar),white_15%)]", ); return ( {showHeader && ( } > {action && (
} /> {actionLabel} {group.id === "mine" && ( )}
)}
Archive inactive
)} {!collapsed && ( {body} )}
); } function LoadingRows() { return (
); } function LoadingGroup({ rows, labelWidth }: { rows: number; labelWidth: string }) { let placeholder = "bg-sidebar-accent/80 dark:bg-sidebar-accent"; return (
{Array.from( { length: rows }, (_, index) => (
), )}
); } function DefaultEmpty({ onNewSession }: Pick) { return (
No channels yet
); } function ArchiveButton({ onClick }: { onClick: () => void }) { return ( ); } function ArchivedHeader({ onBack }: { onBack: () => void }) { return (
Archived
); } export function ProjectSidebar({ projectName, repos, selectedRepoId, onRepoChange, onRepoRemove, onAddRepo, children, className, ...props }: & ComponentPropsWithRef<"aside"> & Pick< SessionSidebarProps, "projectName" | "repos" | "selectedRepoId" | "onRepoChange" | "onRepoRemove" | "onAddRepo" >) { return ( ); } function partition(groups: SessionSidebarGroup[], create: boolean) { let total = count(groups); let archived = groups.find((group) => group.id === "archived" && group.rows.length > 0); let visible = groups.filter((group) => group.id !== "archived" && (group.rows.length > 0 || group.onNewSession || (group.id === "mine" && create)) ); let hasCreateAction = visible.some((group) => group.onNewSession || (group.id === "mine" && create) ); let visibleKey = visible.map((group) => `${group.id}:${group.collapsed ? 1 : 0}:${group.rows.length}` ).join("|"); return { total, archived, visible, hasCreateAction, visibleKey }; } type GroupActions = Omit, "group">; function SessionRows({ loading, total, hasCreateAction, empty, lobby, onRebuildLobby, selectLobby, visible, actions, }: Pick & { total: number; hasCreateAction: boolean; selectLobby: () => void; visible: SessionSidebarGroup[]; actions: GroupActions; }) { let { onNewSession, selectedUid } = actions; return (loading ? : total === 0 && !hasCreateAction ? (empty ?? ) : ( <> {lobby && (
)} {visible.map((group) => )} )); } export function SessionSidebar( { projectName, repos, selectedRepoId, groups, lobby, loading, selectedUid, onRepoChange, onRepoRemove, onSelect, onPin, onUnpin, onArchive, onArchiveInactive, onFork, onInfo, onLeave, onDelete, onRebuildLobby, onRename, onMoveTargets, onMove, onToggleGroup, onNewSession, onContinuePr, onStartIssue, onAddRepo, groupHeaderClassName, empty, className, ref, ...props }: SessionSidebarProps, ) { let { total: rows, archived, visible, hasCreateAction, visibleKey } = partition( groups, !!onNewSession, ); let total = rows + (lobby ? 1 : 0); let hasArchived = Boolean(archived); let key = `${lobby ? lobby.uid : ""}|${visibleKey}`; let viewport = useRef(null); let reduced = useReducedMotion(); let [view, setView] = useState<"active" | "archived">("active"); let selectLobby = useCallback(() => { if (lobby) onSelect?.(lobby); }, [lobby, onSelect]); if (!hasArchived && view === "archived") setView("active"); useEffect(() => { let root = viewport.current; if (!root) return; let update = () => { let rootRect = root.getBoundingClientRect(); let groups = root.querySelectorAll(":scope > section[data-group]"); for (let group of groups) { let header = group.querySelector(":scope > [data-group-header]"); let button = header?.querySelector("button[aria-expanded]"); if (!header || !button || button.getAttribute("aria-expanded") !== "true") { if (header) delete header.dataset.shadow; continue; } let rect = group.getBoundingClientRect(); let top = rect.top - rootRect.top + root.scrollTop; let next = root.scrollTop > top + 1 && root.scrollTop < top + group.offsetHeight - 1; if (next) header.dataset.shadow = "true"; else delete header.dataset.shadow; } }; update(); root.addEventListener("scroll", update, { passive: true }); window.addEventListener("resize", update, { passive: true }); return () => { root.removeEventListener("scroll", update); window.removeEventListener("resize", update); }; }, [key]); let actions: GroupActions = { selectedUid, onSelect, onPin, onUnpin, onArchive, onArchiveInactive, onFork, onInfo, onLeave, onDelete, onRename, onMoveTargets, onMove, onToggleGroup, onNewSession, onContinuePr, onStartIssue, groupHeaderClassName, }; return (
{!loading && total > 0 && archived && (
setView("archived")} />
)}
{archived && ( <> setView("active")} /> )}
); }