agent-orchestrator/frontend/src/renderer/components/Sidebar.tsx

1212 lines
45 KiB
TypeScript

import * as Dialog from "@radix-ui/react-dialog";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useParams, useRouterState } from "@tanstack/react-router";
import {
ChevronRight,
CheckCircle2,
Folder,
FolderPlus,
GitPullRequest,
LayoutDashboard,
Moon,
MoreVertical,
Pencil,
Plus,
Search,
Settings,
Sun,
Trash2,
X,
XCircle,
} from "lucide-react";
import { useRef, useState, type ReactNode } from "react";
import type { ImportFolderScan } from "../../preload";
import {
attentionZone,
newestActiveOrchestrator,
sessionIsActive,
type ProjectKind,
type WorkspaceSession,
type WorkspaceSummary,
workerSessions,
} from "../types/workspace";
import { aoBridge } from "../lib/bridge";
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
import { renameSession } from "../lib/rename-session";
import { useEventsConnection } from "../hooks/useEventsConnection";
import { useResizable } from "../hooks/useResizable";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
import {
Sidebar as SidebarRoot,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarTrigger,
useSidebar,
} from "./ui/sidebar";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { OrchestratorIcon } from "./icons";
import aoLogo from "../assets/ao-logo.png";
import { cn } from "../lib/utils";
import { useUiStore } from "../stores/ui-store";
import { CreateProjectAgentSheet, type CreateProjectAgentSelection } from "./CreateProjectAgentSheet";
import { Button } from "./ui/button";
// The macOS hiddenInset traffic lights and the fixed TitlebarNav overlay live
// in the full-width topbar's left inset (_shell renders the bar above the
// sidebar row); the sidebar itself starts below the 56px header, so its border
// never crosses the titlebar strip.
const isMac = typeof navigator !== "undefined" && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;
// Shared styling for the per-project hover action buttons (dashboard,
// orchestrator, kebab): a 20px square icon button that tints on hover, matching
// the old SidebarMenuAction footprint.
const HOVER_ACTION_CLASS =
"grid size-5 shrink-0 place-items-center rounded-md text-passive transition-colors hover:bg-interactive-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-interactive-hover data-[state=open]:text-foreground [&_svg]:size-[15px]";
// Mirrors the daemon's display-name cap (maxDisplayNameLen) and the spawn
// `--name` flag, so inline edits never round-trip a value the API would reject.
const MAX_DISPLAY_NAME_LEN = 20;
type SidebarProps = {
daemonStatus: { state: string; message?: string };
underTopbar?: boolean;
workspaceError?: string;
workspaces: WorkspaceSummary[];
onCreateProject: (input: { path: string; asWorkspace?: boolean } & CreateProjectAgentSelection) => Promise<void>;
onRemoveProject: (projectId: string) => Promise<void>;
};
// Selection state comes from the URL: which project/session is active is the
// route params, and clicks navigate rather than mutate a store.
function useSelection() {
const navigate = useNavigate();
const params = useParams({ strict: false }) as { projectId?: string; sessionId?: string };
const pathname = useRouterState({ select: (state) => state.location.pathname });
return {
isHome: pathname === "/",
activeProjectId: params.projectId,
activeSessionId: params.sessionId,
goHome: () => void navigate({ to: "/" }),
goPrs: () => void navigate({ to: "/prs" }),
goGlobalSettings: () => void navigate({ to: "/settings" }),
goSettings: (projectId: string) => void navigate({ to: "/projects/$projectId/settings", params: { projectId } }),
goProject: (projectId: string) => void navigate({ to: "/projects/$projectId", params: { projectId } }),
goSession: (projectId: string, sessionId: string) =>
void navigate({ to: "/projects/$projectId/sessions/$sessionId", params: { projectId, sessionId } }),
};
}
// 6px session dot: mirrors the board's status language so the sidebar can be
// scanned without opening the project board.
function SessionDot({ session }: { session: WorkspaceSession }) {
const zone = attentionZone(session);
return (
<span
aria-hidden="true"
className={cn(
"mt-px h-1.5 w-1.5 shrink-0 rounded-full",
zone === "working" && "animate-status-pulse bg-working",
zone === "action" && (session.status === "ci_failed" ? "bg-error" : "bg-warning"),
zone === "pending" && "bg-passive",
zone === "merge" && "bg-success",
zone === "done" && "bg-passive",
)}
/>
);
}
// Built on shadcn's sidebar primitives (components/ui/sidebar): the provider in
// _shell owns open state (synced to the ui-store) and `collapsible="icon"`
// replaces the old hand-rolled CollapsedRail — the same tree restyles itself
// via group-data-[collapsible=icon] into the 48px letter rail.
export function Sidebar({
daemonStatus,
underTopbar = true,
workspaceError,
workspaces,
onCreateProject,
onRemoveProject,
}: SidebarProps) {
const selection = useSelection();
const eventsConnection = useEventsConnection();
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const theme = useUiStore((s) => s.theme);
const toggleTheme = useUiStore((s) => s.toggleTheme);
// Disclosure state: projects are expanded by default; a project id present in
// this set is collapsed (sessions hidden).
const [collapsedIds, setCollapsedIds] = useState<ReadonlySet<string>>(() => new Set());
const toggleCollapsed = (id: string) =>
setCollapsedIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
// Fetch the running app version to derive the build channel. Channel is
// identity: derived from the version string, not the update-channel setting
// (the setting can be changed mid-session; the binary cannot).
const { data: appVersion } = useQuery({
queryKey: ["app-version"],
queryFn: () => aoBridge.app.getVersion(),
staleTime: Infinity,
});
const isNightly = typeof appVersion === "string" && appVersion.includes("-nightly.");
// agent-orchestrator's sidebar resize: drag the right edge (200-420px,
// persisted), double-click to reset to 240px. Drives --ao-sidebar-w on :root,
// which the provider forwards into shadcn's --sidebar-width.
const { onPointerDown: onResizePointerDown, onDoubleClick: onResizeDoubleClick } = useResizable({
cssVar: "--ao-sidebar-w",
storageKey: "ao-sidebar-w",
defaultWidth: 240,
min: 200,
max: 420,
edge: "right",
});
return (
// The container is fixed-positioned by the shadcn primitive; offset it
// below the 56px shell topbar so the bar runs edge-to-edge above it
// (same override as shadcn's header-above-sidebar block).
<SidebarRoot
collapsible="icon"
className={cn("border-border", underTopbar ? "top-14 h-[calc(100svh-3.5rem)]!" : "top-0 h-svh!")}
>
<SidebarHeader className="gap-0 p-0 pl-2.5 pr-[7px] pt-3.5 group-data-[collapsible=icon]:px-1.5">
{/* Brand (project-sidebar__brand); in the icon rail it becomes the old
36px board button wrapping the 22px accent mark. */}
<div className="flex shrink-0 items-center gap-2.5 px-2 pb-[18px] group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:pb-2">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="Orchestrator board"
className={cn(
"grid h-[22px] w-[22px] shrink-0 place-items-center",
"group-data-[collapsible=icon]:size-9 group-data-[collapsible=icon]:rounded-lg",
selection.isHome
? "group-data-[collapsible=icon]:bg-interactive-active"
: "group-data-[collapsible=icon]:hover:bg-interactive-hover",
)}
onClick={selection.goHome}
type="button"
>
<img src={aoLogo} alt="" aria-hidden="true" className="h-[22px] w-[22px] rounded-[6px] object-cover" />
</button>
</TooltipTrigger>
<TooltipContent side="right" hidden={state !== "collapsed"}>
Orchestrator board
</TooltipContent>
</Tooltip>
<span className="min-w-0 flex-1 truncate text-[14px] font-bold tracking-[-0.015em] text-foreground group-data-[collapsible=icon]:hidden">
Agent Orchestrator
</span>
{isNightly && (
<span
className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none group-data-[collapsible=icon]:hidden"
style={{
color: "var(--purple)",
background: "color-mix(in srgb, var(--purple) 12%, transparent)",
}}
>
nightly
</span>
)}
{/* On macOS the toggle lives in the titlebar cluster instead. */}
{!isMac && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger className="size-[18px] shrink-0 rounded-[4px] p-0 text-passive hover:bg-interactive-hover hover:text-foreground group-data-[collapsible=icon]:hidden [&_svg]:size-[15px]" />
</TooltipTrigger>
<TooltipContent>Collapse sidebar · B</TooltipContent>
</Tooltip>
)}
</div>
</SidebarHeader>
<SidebarContent className="gap-0 pl-2.5 pr-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5">
<SidebarGroup className="p-0">
{/* Section label (project-sidebar__nav-label) */}
<div className="flex shrink-0 items-center justify-between px-2 pb-2 group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel className="h-auto rounded-none p-0 text-[10.5px] font-semibold uppercase tracking-[0.09em] text-passive">
Projects
</SidebarGroupLabel>
<CreateProjectButton onCreateProject={onCreateProject} />
</div>
{/* Tree (project-sidebar__tree) */}
<SidebarGroupContent>
{workspaceError ? (
<div className="px-2 py-3 group-data-[collapsible=icon]:hidden">
<p className="text-[12px] text-foreground">Could not load projects.</p>
<p className="mt-1 text-[11px] text-passive">{workspaceError}</p>
</div>
) : workspaces.length === 0 ? (
<div className="px-2 py-3 group-data-[collapsible=icon]:hidden">
<p className="text-[12px] text-passive">No projects yet.</p>
<p className="mt-1 text-[11px] text-passive">
Click <span className="text-foreground">+</span> above to register a repo or workspace.
</p>
</div>
) : (
<SidebarMenu className="gap-0 group-data-[collapsible=icon]:gap-1">
{workspaces.map((workspace) => (
<ProjectItem
key={workspace.id}
workspace={workspace}
expanded={!collapsedIds.has(workspace.id)}
selection={selection}
onToggle={() => toggleCollapsed(workspace.id)}
onRemoveProject={onRemoveProject}
/>
))}
{isCollapsed && <CreateProjectListItem onCreateProject={onCreateProject} />}
</SidebarMenu>
)}
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
{/* Footer (project-sidebar__footer) — single Settings menu. Divergence
(user-requested 2026-06-10): the trigger stretches the full row width
(flex-1) with a uniform 7px footer inset on all sides (reference uses
12px top, 0 bottom, content-hugging button). The icon rail keeps the
icon-only settings action plus expand toggle (off macOS). */}
<SidebarFooter className="mt-auto gap-0 border-t border-border p-[7px] group-data-[collapsible=icon]:items-center group-data-[collapsible=icon]:px-1.5 group-data-[collapsible=icon]:pb-0 group-data-[collapsible=icon]:pt-2">
<div className="relative flex w-full items-center group-data-[collapsible=icon]:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label="Settings"
className="flex flex-1 items-center justify-start gap-2.5 rounded-md p-2 text-[13px] font-medium text-passive transition-colors hover:bg-interactive-hover hover:text-foreground data-[state=open]:bg-interactive-hover data-[state=open]:text-foreground [&_svg]:size-[15px] [&_svg]:text-passive"
type="button"
>
<Settings aria-hidden="true" />
<span className="tracking-[-0.01em]">Settings</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-0"
side="top"
>
<DropdownMenuItem onSelect={toggleTheme}>
{theme === "dark" ? <Sun aria-hidden="true" /> : <Moon aria-hidden="true" />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={selection.goPrs}>
<GitPullRequest aria-hidden="true" />
Pull requests
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Search aria-hidden="true" />
Search
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{selection.activeProjectId && (
<DropdownMenuItem onSelect={() => selection.goSettings(selection.activeProjectId!)}>
<Settings aria-hidden="true" />
Project settings
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={selection.goGlobalSettings}>
<Settings aria-hidden="true" />
Global settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={`Daemon ${daemonStatus.state}`}
className={cn(
"absolute right-1.5 top-1/2 h-1.5 w-1.5 -translate-y-1/2 rounded-full",
daemonStatus.state === "ready" && eventsConnection !== "disconnected" ? "bg-success" : "bg-amber",
)}
/>
</TooltipTrigger>
<TooltipContent side="top">
daemon {daemonStatus.state}
{eventsConnection === "disconnected" && " · events offline"}
</TooltipContent>
</Tooltip>
</div>
<div className="hidden flex-col items-center gap-1 pb-3.5 group-data-[collapsible=icon]:flex">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
aria-label="Settings"
className="grid size-9 place-items-center rounded-lg text-passive transition-colors hover:bg-interactive-hover hover:text-foreground [&_svg]:size-4"
type="button"
>
<Settings aria-hidden="true" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">Settings</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="min-w-0" side="top">
<DropdownMenuItem onSelect={toggleTheme}>
{theme === "dark" ? <Sun aria-hidden="true" /> : <Moon aria-hidden="true" />}
{theme === "dark" ? "Light mode" : "Dark mode"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={selection.goPrs}>
<GitPullRequest aria-hidden="true" />
Pull requests
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Search aria-hidden="true" />
Search
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
{selection.activeProjectId && (
<DropdownMenuItem onSelect={() => selection.goSettings(selection.activeProjectId!)}>
<Settings aria-hidden="true" />
Project settings
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={selection.goGlobalSettings}>
<Settings aria-hidden="true" />
Global settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{!isMac && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger className="size-9 rounded-lg text-passive hover:bg-interactive-hover hover:text-foreground [&_svg]:size-4" />
</TooltipTrigger>
<TooltipContent side="right">Expand sidebar · B</TooltipContent>
</Tooltip>
)}
</div>
</SidebarFooter>
<div
className="resize-handle resize-handle--right group-data-[collapsible=icon]:hidden"
onPointerDown={onResizePointerDown}
onDoubleClick={onResizeDoubleClick}
style={noDragStyle}
/>
</SidebarRoot>
);
}
type Selection = ReturnType<typeof useSelection>;
function ProjectItem({
workspace,
expanded,
selection,
onToggle,
onRemoveProject,
}: {
workspace: WorkspaceSummary;
expanded: boolean;
selection: Selection;
onToggle: () => void;
onRemoveProject: (projectId: string) => Promise<void>;
}) {
const projectActive = selection.activeProjectId === workspace.id && !selection.activeSessionId;
const queryClient = useQueryClient();
const [removeError, setRemoveError] = useState<string | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const [isSpawning, setIsSpawning] = useState(false);
const restartingProjectIds = useUiStore((state) => state.restartingProjectIds);
const isProjectRestarting = restartingProjectIds.has(workspace.id);
// Live workers only: merged/terminated sessions leave the sidebar and stay
// reachable through the board's Done / Terminated bar (SessionsBoard).
const sessions = workerSessions(workspace.sessions).filter(sessionIsActive);
// The project's live orchestrator (if any) backs the hover Orchestrator
// button: navigate to it when present, otherwise spawn one first.
const orchestrator = newestActiveOrchestrator(workspace.sessions);
// Mirrors ShellTopbar's launcher: attach to the running orchestrator, or
// spawn one via the daemon and follow it once the workspace refetches.
const openOrchestrator = async () => {
if (isProjectRestarting) return;
if (orchestrator) {
selection.goSession(workspace.id, orchestrator.id);
return;
}
setIsSpawning(true);
try {
const sessionId = await spawnOrchestrator(workspace.id, "sidebar");
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
selection.goSession(workspace.id, sessionId);
} catch (err) {
console.error("Failed to spawn orchestrator:", err);
} finally {
setIsSpawning(false);
}
};
const onProjectClick = () => {
if (!expanded) {
onToggle();
selection.goProject(workspace.id);
} else if (projectActive) {
onToggle();
} else {
selection.goProject(workspace.id);
}
};
const removeProject = async () => {
setRemoveError(null);
const confirmed = window.confirm(
`Remove project ${workspace.name}? This stops its live sessions and removes it from the sidebar, but keeps the repository folder and stored history on disk.`,
);
if (!confirmed) return;
setIsRemoving(true);
try {
await onRemoveProject(workspace.id);
// The route for a removed project no longer resolves; fall back home.
if (selection.activeProjectId === workspace.id) selection.goHome();
} catch (err) {
const message = err instanceof Error ? err.message : "Could not remove project";
setRemoveError(message);
window.alert(message);
} finally {
setIsRemoving(false);
}
};
return (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
{/* project-sidebar__proj-row */}
<SidebarMenuButton
aria-current={projectActive ? "page" : undefined}
aria-expanded={expanded}
isActive={projectActive}
onClick={onProjectClick}
tooltip={workspace.name}
className={cn(
"relative h-9 gap-[9px] rounded-[5px] px-1.5 py-0 text-[13px] font-medium text-muted-foreground transition-[background-color,padding,color]",
"before:absolute before:top-2 before:bottom-2 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"hover:bg-interactive-hover hover:text-foreground active:bg-interactive-hover active:text-foreground",
"data-[active=true]:bg-interactive-active data-[active=true]:font-semibold data-[active=true]:text-foreground data-[active=true]:before:bg-accent",
// Always reserve room for the action cluster (dashboard,
// orchestrator, kebab) — icons are always visible, not hover-gated.
"pr-[84px]",
// Icon rail: the old 36px letter tile.
"group-data-[collapsible=icon]:size-9! group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:rounded-lg group-data-[collapsible=icon]:p-0! group-data-[collapsible=icon]:font-semibold",
)}
>
<ChevronRight
className={cn(
"h-[9px]! w-[9px]! shrink-0 text-passive transition-transform group-data-[collapsible=icon]:hidden",
expanded && "rotate-90",
)}
strokeWidth={2.5}
aria-hidden="true"
/>
<span className="hidden group-data-[collapsible=icon]:block">{workspace.name.charAt(0).toUpperCase()}</span>
<span className="min-w-0 flex-1 truncate group-data-[collapsible=icon]:hidden">{workspace.name}</span>
<span className="hidden h-4 min-w-4 shrink-0 place-items-center rounded bg-interactive-hover px-1 font-mono text-[10px] leading-none text-passive">
{sessions.length}
</span>
</SidebarMenuButton>
{/* Per-project actions: dashboard board, orchestrator, and a kebab
menu. Always visible (not hover-gated) to avoid CSS :hover group
propagation issues in Electron's Chromium. Hidden in the icon rail. */}
<div
className={cn(
"absolute top-0 right-1 z-10 flex h-9 items-center gap-px",
"group-data-[collapsible=icon]:hidden",
)}
>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Open ${workspace.name} dashboard`}
className={HOVER_ACTION_CLASS}
onClick={() => selection.goProject(workspace.id)}
type="button"
>
<LayoutDashboard aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>Dashboard</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={orchestrator ? `Open ${workspace.name} orchestrator` : `Spawn ${workspace.name} orchestrator`}
className={HOVER_ACTION_CLASS}
disabled={isSpawning || isProjectRestarting}
onClick={() => void openOrchestrator()}
type="button"
>
<OrchestratorIcon aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>
{isProjectRestarting
? "Restarting…"
: isSpawning
? "Spawning…"
: orchestrator
? "Orchestrator"
: "Spawn orchestrator"}
</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button aria-label={`Project actions for ${workspace.name}`} className={HOVER_ACTION_CLASS} type="button">
<MoreVertical aria-hidden="true" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start" className="min-w-44">
<DropdownMenuItem onSelect={() => selection.goSettings(workspace.id)}>
<Settings aria-hidden="true" />
Project settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive [&_svg]:text-destructive"
disabled={isRemoving}
onSelect={() => void removeProject()}
>
<Trash2 aria-hidden="true" />
Remove project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{removeError && (
<span className="sr-only" role="status">
{removeError}
</span>
)}
{/* project-sidebar__sessions: indented under the project parent so worker
sessions read as children without adding a persistent guide rail. */}
{expanded && sessions.length > 0 && (
<SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l-0 px-0 py-1 pl-2.5">
{sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
active={selection.activeSessionId === session.id}
onOpen={() => selection.goSession(workspace.id, session.id)}
/>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
}
// One worker-session row. Reads as a link by default; a hover-revealed pencil
// flips the label into an inline input (Enter/blur saves, Escape cancels) that
// persists through the daemon rename endpoint, so the new name survives reload.
function SessionRow({ session, active, onOpen }: { session: WorkspaceSession; active: boolean; onOpen: () => void }) {
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(session.title);
// Escape must not be swallowed by the blur-to-save path: the keydown handler
// blurs the input, so it flags a cancel here for onBlur to honour.
const cancelledRef = useRef(false);
const startEditing = () => {
setDraft(session.title);
setIsEditing(true);
};
const commit = async () => {
if (cancelledRef.current) {
cancelledRef.current = false;
setIsEditing(false);
return;
}
setIsEditing(false);
const name = draft.trim();
if (!name || name === session.title) return;
try {
await renameSession(session.id, name);
await queryClient.invalidateQueries({ queryKey: workspaceQueryKey });
} catch (err) {
console.error("Failed to rename session:", err);
}
};
if (isEditing) {
return (
<SidebarMenuSubItem>
<div className="relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5">
<SessionDot session={session} />
<input
aria-label={`Rename ${session.title}`}
autoFocus
className="min-w-0 flex-1 rounded-[3px] border border-accent bg-transparent px-1 py-px text-[12px] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-accent"
maxLength={MAX_DISPLAY_NAME_LEN}
onBlur={() => void commit()}
onChange={(e) => setDraft(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
e.currentTarget.blur();
} else if (e.key === "Escape") {
e.preventDefault();
cancelledRef.current = true;
e.currentTarget.blur();
}
}}
value={draft}
/>
</div>
</SidebarMenuSubItem>
);
}
return (
<SidebarMenuSubItem>
<button
aria-current={active ? "page" : undefined}
aria-label={`Open ${session.title}`}
className={cn(
"relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-7 text-left outline-hidden transition-[color]",
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
active && "text-foreground before:bg-accent",
)}
onClick={onOpen}
type="button"
>
<SessionDot session={session} />
<span className="min-w-0 flex-1">
<span className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}>
{session.title}
</span>
</span>
</button>
{/* Pencil reveals on row hover/focus (named group on SidebarMenuSubItem);
it sits beside the row button rather than nested inside it. */}
<button
aria-label={`Rename ${session.title}`}
className={cn(
HOVER_ACTION_CLASS,
"absolute top-1/2 right-1 -translate-y-1/2 opacity-0",
"group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100",
)}
onClick={startEditing}
type="button"
>
<Pencil aria-hidden="true" />
</button>
</SidebarMenuSubItem>
);
}
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-[18px] w-[18px] place-items-center rounded-[4px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)}
</CreateProjectFlow>
);
}
function CreateProjectListItem({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
return (
<CreateProjectFlow onCreateProject={onCreateProject}>
{({ disabled, choosePath, label }) => (
<SidebarMenuItem className="mb-px group-data-[collapsible=icon]:mb-0">
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label="New project"
className="grid h-9 w-full place-items-center rounded-[5px] text-passive transition-colors hover:bg-interactive-hover hover:text-muted-foreground"
disabled={disabled}
onClick={choosePath}
type="button"
>
<Plus className="h-[13px] w-[13px]" aria-hidden="true" />
</button>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
</SidebarMenuItem>
)}
</CreateProjectFlow>
);
}
function CreateProjectFlow({
children,
onCreateProject,
}: Pick<SidebarProps, "onCreateProject"> & {
children: (state: { choosePath: () => void; disabled: boolean; label: string }) => ReactNode;
}) {
const [error, setError] = useState<string | null>(null);
const [modePickerOpen, setModePickerOpen] = useState(false);
const [folderPickerOpen, setFolderPickerOpen] = useState(false);
const [selectedKind, setSelectedKind] = useState<ProjectKind>("single_repo");
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [validationScan, setValidationScan] = useState<ImportFolderScan | null>(null);
const [isChoosingPath, setIsChoosingPath] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const openFolderStep = (kind: ProjectKind) => {
setError(null);
setValidationScan(null);
setSelectedKind(kind);
setModePickerOpen(false);
window.requestAnimationFrame(() => setFolderPickerOpen(true));
};
const choosePath = async () => {
setError(null);
setIsChoosingPath(true);
try {
const path = await aoBridge.app.chooseDirectory(
selectedKind === "workspace" ? "Choose a workspace folder" : "Choose a project repository",
);
if (path) {
setValidationScan(null);
setSelectedPath(path);
setFolderPickerOpen(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Could not add project");
} finally {
setIsChoosingPath(false);
}
};
const createProject = async (selection: CreateProjectAgentSelection) => {
if (!selectedPath) return;
setError(null);
setIsCreating(true);
try {
await onCreateProject({ path: selectedPath, asWorkspace: selectedKind === "workspace", ...selection });
setSelectedPath(null);
} catch (err) {
const message = err instanceof Error ? err.message : "Could not add project";
setError(message);
try {
const scan = await aoBridge.app.scanImportFolder({
path: selectedPath,
mode: selectedKind === "workspace" ? "workspace" : "project",
});
setValidationScan(scan);
} catch {
setValidationScan({ path: selectedPath, repos: [] });
}
setSelectedPath(null);
setFolderPickerOpen(true);
} finally {
setIsCreating(false);
}
};
const label = isChoosingPath ? "Opening..." : isCreating ? "Creating..." : "New project";
return (
<>
{children({ choosePath: () => setModePickerOpen(true), disabled: isChoosingPath || isCreating, label })}
<CreateProjectModeDialog
disabled={isChoosingPath || isCreating}
open={modePickerOpen}
onOpenChange={(open) => !isChoosingPath && !isCreating && setModePickerOpen(open)}
onSelect={openFolderStep}
/>
<CreateProjectFolderDialog
disabled={isChoosingPath || isCreating}
error={error}
kind={selectedKind}
open={folderPickerOpen}
scan={validationScan}
onBack={() => {
setError(null);
setValidationScan(null);
setFolderPickerOpen(false);
window.requestAnimationFrame(() => setModePickerOpen(true));
}}
onChooseFolder={() => void choosePath()}
onOpenChange={(open) => {
if (!isChoosingPath && !isCreating) {
setFolderPickerOpen(open);
if (!open) {
setError(null);
setValidationScan(null);
}
}
}}
/>
<CreateProjectAgentSheet
error={error}
isCreating={isCreating}
kind={selectedKind}
onOpenChange={(open) => {
if (!open) {
setSelectedPath(null);
if (!folderPickerOpen) setError(null);
}
}}
onSubmit={createProject}
open={selectedPath !== null}
path={selectedPath}
/>
{error && (
<span className="sr-only" role="status">
{error}
</span>
)}
</>
);
}
function CreateProjectModeDialog({
disabled,
onOpenChange,
onSelect,
open,
}: {
disabled: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (kind: ProjectKind) => void;
open: boolean;
}) {
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 data-[state=open]:animate-overlay-in" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 flex max-h-[min(720px,calc(100svh-24px))] w-[min(680px,calc(100vw-24px))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex shrink-0 items-start justify-between gap-4 px-4 pb-3 pt-4 sm:px-6 sm:pb-4 sm:pt-5">
<div className="min-w-0">
<Dialog.Title className="text-[18px] font-semibold text-foreground">
Import to Agent Orchestrator
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] font-medium text-muted-foreground">
What are you importing?
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Close new project dialog"
disabled={disabled}
>
<X className="size-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
<div className="grid min-h-0 gap-3 overflow-y-auto px-4 pb-4 sm:grid-cols-2 sm:px-6 sm:pb-6">
<ProjectModeButton
description="Several Git repos that live under one parent folder."
disabled={disabled}
kind="workspace"
onClick={() => onSelect("workspace")}
/>
<ProjectModeButton
description="A single Git repository — one codebase, tracked in one repo."
disabled={disabled}
kind="single_repo"
onClick={() => onSelect("single_repo")}
/>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function ProjectModeButton({
description,
disabled,
kind,
onClick,
}: {
description: string;
disabled: boolean;
kind: ProjectKind;
onClick: () => void;
}) {
const isWorkspace = kind === "workspace";
return (
<button
type="button"
aria-label={isWorkspace ? "Workspace" : "Project"}
className="flex min-h-[176px] w-full flex-col justify-end rounded-lg border border-border bg-card px-4 py-4 text-left transition-colors hover:bg-background focus-visible:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 sm:min-h-[220px] sm:px-5 sm:py-5"
disabled={disabled}
onClick={onClick}
>
<span className="mb-3 flex min-h-[70px] w-full items-center justify-center sm:mb-4 sm:min-h-[92px]">
{isWorkspace ? (
<span className="mx-auto w-[min(210px,100%)] rounded-lg border border-dashed border-border px-3 py-3">
<span className="mx-auto mb-2 flex w-[min(160px,100%)] items-center gap-2 font-mono text-[11px] font-semibold text-muted-foreground">
<Folder className="size-3.5" aria-hidden="true" />
my-workspace/
</span>
{["web-app", "api-server", "shared-libs"].map((repo) => (
<span
key={repo}
className="mx-auto mb-1.5 flex w-[min(170px,100%)] items-center gap-2 rounded-md bg-background px-2.5 py-1.5 font-mono text-[12px] font-semibold text-foreground last:mb-0"
>
<span className="size-1.5 rounded-full bg-success" aria-hidden="true" />
{repo}
</span>
))}
</span>
) : (
<span className="mx-auto max-w-full rounded-lg border border-border bg-background px-4 py-3 font-mono text-[12px] font-semibold text-foreground sm:px-5 sm:py-3.5 sm:text-[13px]">
<span className="mr-2 inline-block size-1.5 rounded-full bg-success" aria-hidden="true" />
web-app <span className="px-2 text-muted-foreground">·</span>
<span className="text-muted-foreground">main</span>
</span>
)}
</span>
<span className="block text-[15px] font-semibold text-foreground sm:text-[16px]">
{isWorkspace ? "Workspace" : "Project"}
</span>
<span className="mt-2 block text-[12px] leading-5 text-muted-foreground sm:min-h-[40px] sm:text-[13px]">
{description}
</span>
<span className="mt-3 font-mono text-[12px] font-semibold text-passive">
<span className="mr-2 text-passive"></span>
{isWorkspace ? "Multiple repositories" : "One repository"}
</span>
</button>
);
}
function CreateProjectFolderDialog({
disabled,
error,
kind,
onBack,
onChooseFolder,
onOpenChange,
open,
scan,
}: {
disabled: boolean;
error: string | null;
kind: ProjectKind;
onBack: () => void;
onChooseFolder: () => void;
onOpenChange: (open: boolean) => void;
open: boolean;
scan: ImportFolderScan | null;
}) {
const isWorkspace = kind === "workspace";
const failedRepos = scan?.repos.filter((repo) => !repo.hasRemote) ?? [];
const hasScan = scan !== null;
return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/55 data-[state=open]:animate-overlay-in" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 flex max-h-[min(640px,calc(100svh-24px))] w-[min(640px,calc(100vw-24px))] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border border-border bg-popover p-0 text-popover-foreground shadow-xl data-[state=open]:animate-modal-in">
<div className="flex shrink-0 items-start gap-3 border-b border-border px-4 py-4 sm:gap-4 sm:px-6 sm:py-5">
<button
type="button"
className="grid size-8 shrink-0 place-items-center rounded-lg border border-border text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Back to import type"
disabled={disabled}
onClick={onBack}
>
<ChevronRight className="size-4 rotate-180" aria-hidden="true" />
</button>
<div className="min-w-0 flex-1">
<Dialog.Title className="text-[18px] font-semibold text-foreground">
{isWorkspace ? "Import workspace" : "Import project"}
</Dialog.Title>
<Dialog.Description className="mt-1 max-w-[520px] text-[13px] font-medium leading-5 text-muted-foreground">
{isWorkspace
? "Pick a folder that contains your Git repositories. Each repo inside it joins the workspace."
: "Import a single Git repository as one project."}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-surface hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
aria-label="Close import dialog"
disabled={disabled}
>
<X className="size-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
<div className="min-h-0 overflow-y-auto px-4 py-4 sm:px-6 sm:py-6">
{hasScan ? (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border border-border bg-background px-4 py-3">
<Folder className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-[14px] font-semibold text-foreground">
{displayImportPath(scan.path)}
</div>
<div className="mt-0.5 text-[12px] text-muted-foreground">
{isWorkspace ? "Workspace root" : "Project folder"}
</div>
</div>
<Button type="button" variant="outline" disabled={disabled} onClick={onChooseFolder}>
Change
</Button>
</div>
{error && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10">
<div className="border-b border-destructive/30 px-4 py-3 font-mono text-[12px] font-semibold uppercase tracking-[0.12em] text-destructive">
<span className="mr-2 inline-block size-2 rounded-full bg-destructive" aria-hidden="true" />
Validation failed · {isWorkspace ? "workspace" : "project"} not registered
</div>
{failedRepos.length > 0 ? (
failedRepos.map((repo) => <ImportRepoRow key={repo.path} repo={repo} failed />)
) : (
<div className="px-4 py-3 text-[12px] leading-5 text-destructive">{error}</div>
)}
</div>
)}
{scan.repos
.filter((repo) => repo.hasRemote)
.map((repo) => (
<div key={repo.path} className="rounded-lg border border-border bg-background">
<ImportRepoRow repo={repo} />
</div>
))}
{scan.repos.length === 0 && (
<div className="rounded-lg border border-border bg-background px-4 py-4 text-[12px] text-muted-foreground">
No repositories detected in this folder.
</div>
)}
</div>
) : (
<button
type="button"
className="flex min-h-[132px] w-full flex-col items-center justify-center rounded-lg border border-dashed border-border bg-background px-4 py-5 text-center transition-colors hover:bg-surface disabled:pointer-events-none disabled:opacity-50 sm:min-h-[160px] sm:px-5 sm:py-6"
disabled={disabled}
onClick={onChooseFolder}
>
<span className="mb-4 grid size-11 place-items-center rounded-xl bg-card text-muted-foreground">
<FolderPlus className="size-5" aria-hidden="true" />
</span>
<span className="text-[15px] font-semibold text-foreground">
{isWorkspace ? "Choose a folder" : "Choose a project folder"}
</span>
<span className="mt-2 max-w-full text-pretty text-[12px] text-muted-foreground sm:text-[13px]">
{isWorkspace
? "Opens your system file picker — pick the folder that holds your repos"
: "Opens your system file picker — select one repo folder"}
</span>
</button>
)}
{error && (
<div
className={cn(
"mt-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12px] leading-5 text-destructive",
hasScan && "sr-only",
)}
>
{error}
</div>
)}
</div>
<div className="flex shrink-0 flex-col gap-3 border-t border-border px-4 py-4 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="text-[12px] font-medium text-muted-foreground">
{hasScan && failedRepos.length > 0
? `Resolve ${failedRepos.length} failed ${failedRepos.length === 1 ? "repository" : "repositories"} to continue`
: isWorkspace
? "No repositories to import"
: "No project selected"}
</p>
<div className="flex flex-wrap items-center justify-end gap-2 sm:gap-3">
<Button type="button" variant="outline" disabled={disabled} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" variant="primary" disabled>
{isWorkspace ? "Import workspace" : "Import project"}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
function ImportRepoRow({ failed = false, repo }: { failed?: boolean; repo: ImportFolderScan["repos"][number] }) {
return (
<div className="flex items-center gap-3 px-4 py-3">
{failed ? (
<XCircle className="size-5 shrink-0 text-destructive" aria-hidden="true" />
) : (
<CheckCircle2 className="size-5 shrink-0 text-success" aria-hidden="true" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-semibold text-foreground">{repo.name}</div>
<div className="mt-0.5 truncate font-mono text-[12px] text-muted-foreground">
{displayImportPath(repo.path)}
</div>
</div>
<div
className={cn(
"hidden max-w-[260px] shrink-0 truncate text-right font-mono text-[12px] sm:block",
failed ? "text-muted-foreground" : "text-muted-foreground",
)}
>
{repo.hasRemote ? `${repo.branch} ${remoteDisplay(repo.remote)}` : "No remote configured"}
</div>
</div>
);
}
function displayImportPath(value: string) {
return value.replace(/^\/Users\/[^/]+/, "~");
}
function remoteDisplay(remote: string) {
const ssh = remote.match(/^[^@]+@([^:]+):(.+)$/);
if (ssh?.[1] && ssh[2]) return `${ssh[1]}/${ssh[2].replace(/\.git$/, "")}`;
try {
const url = new URL(remote);
return `${url.host}${url.pathname.replace(/\.git$/, "")}`;
} catch {
return remote.replace(/^https?:\/\//, "").replace(/\.git$/, "");
}
}