import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "@tanstack/react-router"; import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; import { ChevronDown, ChevronRight, ChevronUp, CloudUpload, CreditCard, GitPullRequestDraft, ListChecks, LogOut, PanelLeft, Plus, Settings, User, } from "lucide-react"; import { formatRelativeAge, type Task, type ProjectSection } from "./view-model"; import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui"; import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app"; import { useFoundryTokens } from "../../app/theme"; import type { FoundryTokens } from "../../styles/tokens"; const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; function projectInitial(label: string): string { const parts = label.split("/"); const name = parts[parts.length - 1] ?? label; return name.charAt(0).toUpperCase(); } function projectIconColor(label: string): string { let hash = 0; for (let i = 0; i < label.length; i++) { hash = (hash * 31 + label.charCodeAt(i)) | 0; } return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!; } export const Sidebar = memo(function Sidebar({ projects, newTaskRepos, selectedNewTaskRepoId, activeId, onSelect, onCreate, onSelectNewTaskRepo, onMarkUnread, onRenameTask, onRenameBranch, onReorderProjects, taskOrderByProject, onReorderTasks, onToggleSidebar, }: { projects: ProjectSection[]; newTaskRepos: Array<{ id: string; label: string }>; selectedNewTaskRepoId: string; activeId: string; onSelect: (id: string) => void; onCreate: () => void; onSelectNewTaskRepo: (repoId: string) => void; onMarkUnread: (id: string) => void; onRenameTask: (id: string) => void; onRenameBranch: (id: string) => void; onReorderProjects: (fromIndex: number, toIndex: number) => void; taskOrderByProject: Record; onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void; onToggleSidebar?: () => void; }) { const [css] = useStyletron(); const t = useFoundryTokens(); const contextMenu = useContextMenu(); const [collapsedProjects, setCollapsedProjects] = useState>({}); const [hoveredProjectId, setHoveredProjectId] = useState(null); // Mouse-based drag and drop state type DragState = | { type: "project"; fromIdx: number; overIdx: number | null } | { type: "task"; projectId: string; fromIdx: number; overIdx: number | null } | null; const [drag, setDrag] = useState(null); const dragRef = useRef(null); const startYRef = useRef(0); const didDragRef = useRef(false); // Attach global mousemove/mouseup when dragging useEffect(() => { if (!drag) return; const onMove = (e: MouseEvent) => { // Detect which element is under the cursor using data attributes const el = document.elementFromPoint(e.clientX, e.clientY); if (!el) return; const projectEl = (el as HTMLElement).closest?.("[data-project-idx]") as HTMLElement | null; const taskEl = (el as HTMLElement).closest?.("[data-task-idx]") as HTMLElement | null; if (drag.type === "project" && projectEl) { const overIdx = Number(projectEl.dataset.projectIdx); if (overIdx !== drag.overIdx) { setDrag({ ...drag, overIdx }); dragRef.current = { ...drag, overIdx }; } } else if (drag.type === "task" && taskEl) { const overProjectId = taskEl.dataset.taskProjectId ?? ""; const overIdx = Number(taskEl.dataset.taskIdx); if (overProjectId === drag.projectId && overIdx !== drag.overIdx) { setDrag({ ...drag, overIdx }); dragRef.current = { ...drag, overIdx }; } } // Mark that we actually moved (to distinguish from clicks) if (Math.abs(e.clientY - startYRef.current) > 4) { didDragRef.current = true; } }; const onUp = () => { const d = dragRef.current; if (d && didDragRef.current && d.overIdx !== null && d.fromIdx !== d.overIdx) { if (d.type === "project") { onReorderProjects(d.fromIdx, d.overIdx); } else { onReorderTasks(d.projectId, d.fromIdx, d.overIdx); } } dragRef.current = null; didDragRef.current = false; setDrag(null); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); return () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; }, [drag, onReorderProjects, onReorderTasks]); const [createMenuOpen, setCreateMenuOpen] = useState(false); const createMenuRef = useRef(null); useEffect(() => { if (!createMenuOpen) return; function handleClick(event: MouseEvent) { if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) { setCreateMenuOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [createMenuOpen]); return ( {import.meta.env.VITE_DESKTOP ? (
{onToggleSidebar ? (
{ if (event.key === "Enter" || event.key === " ") onToggleSidebar(); }} className={css({ width: "26px", height: "26px", borderRadius: "6px", color: t.textTertiary, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, ":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover }, })} >
) : null}
) : null} Tasks {!import.meta.env.VITE_DESKTOP && onToggleSidebar ? (
{ if (event.key === "Enter" || event.key === " ") onToggleSidebar(); }} className={css({ width: "26px", height: "26px", borderRadius: "6px", color: t.textTertiary, cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0, ":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover }, })} >
) : null}
{ if (newTaskRepos.length === 0) return; if (newTaskRepos.length === 1) { onSelectNewTaskRepo(newTaskRepos[0]!.id); onCreate(); } else { setCreateMenuOpen((prev) => !prev); } }} onKeyDown={(event) => { if (newTaskRepos.length === 0) return; if (event.key === "Enter" || event.key === " ") { if (newTaskRepos.length === 1) { onSelectNewTaskRepo(newTaskRepos[0]!.id); onCreate(); } else { setCreateMenuOpen((prev) => !prev); } } }} className={css({ width: "26px", height: "26px", borderRadius: "8px", backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover, color: t.textPrimary, cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed", display: "flex", alignItems: "center", justifyContent: "center", transition: "background 200ms ease", flexShrink: 0, opacity: newTaskRepos.length > 0 ? 1 : 0.6, ":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined, })} >
{createMenuOpen && newTaskRepos.length > 1 ? (
{newTaskRepos.map((repo) => ( ))}
) : null}
{projects.map((project, projectIndex) => { const isCollapsed = collapsedProjects[project.id] === true; const isProjectDropTarget = drag?.type === "project" && drag.overIdx === projectIndex && drag.fromIdx !== projectIndex; const isBeingDragged = drag?.type === "project" && drag.fromIdx === projectIndex && didDragRef.current; const orderedTaskIds = taskOrderByProject[project.id]; const orderedTasks = orderedTaskIds ? (() => { const byId = new Map(project.tasks.map((t) => [t.id, t])); const sorted = orderedTaskIds.map((id) => byId.get(id)).filter(Boolean) as typeof project.tasks; for (const t of project.tasks) { if (!orderedTaskIds.includes(t.id)) sorted.push(t); } return sorted; })() : project.tasks; return (
setHoveredProjectId(project.id)} onMouseLeave={() => setHoveredProjectId((cur) => (cur === project.id ? null : cur))} onMouseDown={(event) => { if (event.button !== 0) return; startYRef.current = event.clientY; didDragRef.current = false; setHoveredProjectId(null); const state: DragState = { type: "project", fromIdx: projectIndex, overIdx: null }; dragRef.current = state; setDrag(state); }} onClick={() => { if (!didDragRef.current) { setCollapsedProjects((current) => ({ ...current, [project.id]: !current[project.id], })); } }} data-project-header className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 8px 4px", gap: "8px", cursor: "grab", userSelect: "none", })} >
{projectInitial(project.label)} {isCollapsed ? : }
{project.label}
{isCollapsed ? {formatRelativeAge(project.updatedAtMs)} : null}
{!isCollapsed && orderedTasks.map((task, taskIndex) => { const isActive = task.id === activeId; const isDim = task.status === "archived"; const isRunning = task.tabs.some((tab) => tab.status === "running"); const hasUnread = task.tabs.some((tab) => tab.unread); const isDraft = task.pullRequest == null || task.pullRequest.status === "draft"; const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0); const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0); const hasDiffs = totalAdded > 0 || totalRemoved > 0; const isTaskDropTarget = drag?.type === "task" && drag.projectId === project.id && drag.overIdx === taskIndex && drag.fromIdx !== taskIndex; const isTaskBeingDragged = drag?.type === "task" && drag.projectId === project.id && drag.fromIdx === taskIndex && didDragRef.current; return (
{ if (event.button !== 0) return; // Only start task drag if not already in a project drag if (dragRef.current) return; event.stopPropagation(); startYRef.current = event.clientY; didDragRef.current = false; const state: DragState = { type: "task", projectId: project.id, fromIdx: taskIndex, overIdx: null }; dragRef.current = state; setDrag(state); }} onClick={() => { if (!didDragRef.current) { onSelect(task.id); } }} onContextMenu={(event) => contextMenu.open(event, [ { label: "Rename task", onClick: () => onRenameTask(task.id) }, { label: "Rename branch", onClick: () => onRenameBranch(task.id) }, { label: "Mark as unread", onClick: () => onMarkUnread(task.id) }, ]) } className={css({ padding: "8px 12px", borderRadius: "8px", position: "relative", backgroundColor: isActive ? t.interactiveHover : "transparent", opacity: isTaskBeingDragged ? 0.4 : 1, cursor: "pointer", transition: "all 150ms ease", "::before": { content: '""', position: "absolute", top: "-2px", left: 0, right: 0, height: "2px", backgroundColor: isTaskDropTarget ? t.textPrimary : "transparent", transition: "background-color 100ms ease", }, ":hover": { backgroundColor: t.interactiveHover, }, })} >
{task.title} {task.pullRequest != null ? ( #{task.pullRequest.number} {task.pullRequest.status === "draft" ? : null} ) : ( )} {hasDiffs ? (
+{totalAdded} -{totalRemoved}
) : null} {formatRelativeAge(task.updatedAtMs)}
); })} {/* Bottom drop zone for dragging to end of task list */} {!isCollapsed && (
)}
); })} {/* Bottom drop zone for dragging project to end of list */}
{contextMenu.menu ? : null} ); }); const menuButtonStyle = (highlight: boolean, tokens: FoundryTokens) => ({ display: "flex", alignItems: "center", gap: "10px", width: "100%", padding: "8px 12px", borderRadius: "6px", border: "none", background: highlight ? tokens.interactiveHover : "transparent", color: tokens.textSecondary, cursor: "pointer", fontSize: "13px", fontWeight: 400 as const, textAlign: "left" as const, transition: "background 120ms ease, color 120ms ease", }) satisfies React.CSSProperties; function SidebarFooter() { const [css] = useStyletron(); const t = useFoundryTokens(); const navigate = useNavigate(); const client = useMockAppClient(); const snapshot = useMockAppSnapshot(); const organization = activeMockOrganization(snapshot); const [open, setOpen] = useState(false); const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false); const containerRef = useRef(null); const flyoutTimerRef = useRef | null>(null); const workspaceTriggerRef = useRef(null); const flyoutRef = useRef(null); const [flyoutPos, setFlyoutPos] = useState<{ top: number; left: number } | null>(null); useLayoutEffect(() => { if (workspaceFlyoutOpen && workspaceTriggerRef.current) { const rect = workspaceTriggerRef.current.getBoundingClientRect(); setFlyoutPos({ top: rect.top, left: rect.right + 4 }); } }, [workspaceFlyoutOpen]); useEffect(() => { if (!open) return; function handleClick(event: MouseEvent) { const target = event.target as Node; const inContainer = containerRef.current?.contains(target); const inFlyout = flyoutRef.current?.contains(target); if (!inContainer && !inFlyout) { setOpen(false); setWorkspaceFlyoutOpen(false); } } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, [open]); const switchToOrg = useCallback( (org: (typeof snapshot.organizations)[number]) => { setOpen(false); setWorkspaceFlyoutOpen(false); void (async () => { await client.selectOrganization(org.id); await navigate({ to: `/workspaces/${org.workspaceId}` as never }); })(); }, [client, navigate], ); const openFlyout = useCallback(() => { if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current); setWorkspaceFlyoutOpen(true); }, []); const closeFlyout = useCallback(() => { flyoutTimerRef.current = setTimeout(() => setWorkspaceFlyoutOpen(false), 150); }, []); const menuItems: Array<{ icon: React.ReactNode; label: string; danger?: boolean; onClick: () => void }> = []; if (organization) { menuItems.push( { icon: , label: "Settings", onClick: () => { setOpen(false); void navigate({ to: "/organizations/$organizationId/settings" as never, params: { organizationId: organization.id } as never }); }, }, { icon: , label: "Billing", onClick: () => { setOpen(false); void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: organization.id } as never }); }, }, ); } menuItems.push( { icon: , label: "Account", onClick: () => { setOpen(false); void navigate({ to: "/account" as never }); }, }, { icon: , label: "Sign Out", danger: true, onClick: () => { setOpen(false); void (async () => { await client.signOut(); await navigate({ to: "/signin" }); })(); }, }, ); const popoverStyle = css({ borderRadius: "10px", border: `1px solid ${t.borderDefault}`, backgroundColor: t.surfaceElevated, boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`, padding: "4px", display: "flex", flexDirection: "column", gap: "2px", }); return (
{open ? (
{/* Workspace flyout trigger */} {organization ? (
) : null} {/* Workspace flyout portal */} {workspaceFlyoutOpen && organization && flyoutPos ? createPortal(
{ openFlyout(); }} onMouseLeave={() => { closeFlyout(); }} >
{eligibleOrganizations(snapshot).map((org) => { const isActive = organization.id === org.id; return ( ); })}
, document.body, ) : null} {menuItems.map((item) => ( ))}
) : null}
); }