import { useEffect, useMemo, useState, type ReactNode } from "react"; import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@openhandoff/shared"; import { groupHandoffStatus, type SandboxSessionEventRecord } from "@openhandoff/client"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { Button } from "baseui/button"; import { Input } from "baseui/input"; import { Modal, ModalBody, ModalFooter, ModalHeader } from "baseui/modal"; import { Select, type OnChangeParams, type Option, type Value } from "baseui/select"; import { Skeleton } from "baseui/skeleton"; import { Tag } from "baseui/tag"; import { Textarea } from "baseui/textarea"; import { StyledDivider } from "baseui/divider"; import { styled, useStyletron } from "baseui"; import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography"; import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal, Shuffle } from "lucide-react"; import { formatDiffStat } from "../features/handoffs/model"; import { buildTranscript, resolveSessionSelection } from "../features/sessions/model"; import { backendClient } from "../lib/backend"; interface WorkspaceDashboardProps { workspaceId: string; selectedHandoffId?: string; selectedRepoId?: string; } type RepoOverviewFilter = "active" | "archived" | "unmapped" | "all"; type StatusTagKind = "neutral" | "positive" | "warning" | "negative"; type SelectItem = Readonly<{ id: string; label: string; disabled?: boolean }>; const AppShell = styled("main", ({ $theme }) => ({ minHeight: "100dvh", backgroundColor: $theme.colors.backgroundPrimary, })); const DashboardGrid = styled("div", ({ $theme }) => ({ display: "grid", gap: "1px", minHeight: "100dvh", backgroundColor: $theme.colors.borderOpaque, gridTemplateColumns: "minmax(0, 1fr)", "@media screen and (min-width: 960px)": { gridTemplateColumns: "260px minmax(0, 1fr)", }, "@media screen and (min-width: 1480px)": { gridTemplateColumns: "260px minmax(0, 1fr) 280px", }, })); const Panel = styled("section", ({ $theme }) => ({ minHeight: 0, display: "flex", flexDirection: "column", backgroundColor: $theme.colors.backgroundSecondary, overflow: "hidden", })); const PanelHeader = styled("div", ({ $theme }) => ({ padding: "10px 12px", borderBottom: `1px solid ${$theme.colors.borderOpaque}`, display: "flex", flexDirection: "column", gap: "8px", })); const ScrollBody = styled("div", ({ $theme }) => ({ minHeight: 0, flex: 1, overflowY: "auto", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "8px", })); const DetailRail = styled("aside", ({ $theme }) => ({ minHeight: 0, display: "none", backgroundColor: $theme.colors.backgroundSecondary, overflow: "hidden", "@media screen and (min-width: 1480px)": { display: "flex", flexDirection: "column", }, })); const FILTER_OPTIONS: SelectItem[] = [ { id: "active", label: "Active + Unmapped" }, { id: "archived", label: "Archived Tasks" }, { id: "unmapped", label: "Unmapped Only" }, { id: "all", label: "All Branches" }, ]; const AGENT_OPTIONS: SelectItem[] = [ { id: "codex", label: "codex" }, { id: "claude", label: "claude" }, ]; function statusKind(status: HandoffSummary["status"]): StatusTagKind { const group = groupHandoffStatus(status); if (group === "running") return "positive"; if (group === "queued") return "warning"; if (group === "error") return "negative"; return "neutral"; } function normalizeAgent(agent: string | null): AgentType | undefined { if (agent === "claude" || agent === "codex") { return agent; } return undefined; } function formatTime(value: number): string { return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } function formatRelativeAge(value: number): string { const deltaSeconds = Math.max(0, Math.floor((Date.now() - value) / 1000)); if (deltaSeconds < 60) return `${deltaSeconds}s`; const minutes = Math.floor(deltaSeconds / 60); if (minutes < 60) return `${minutes}m`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); return `${days}d`; } function branchTestIdToken(value: string): string { const token = value .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); return token || "branch"; } function useSessionEvents( handoff: HandoffRecord | null, sessionId: string | null, ): ReturnType> { return useQuery({ queryKey: ["workspace", handoff?.workspaceId ?? "", "session", handoff?.handoffId ?? "", sessionId ?? ""], enabled: Boolean(handoff?.activeSandboxId && sessionId), refetchInterval: 2_500, queryFn: async () => { if (!handoff?.activeSandboxId || !sessionId) { return { items: [] }; } return backendClient.listSandboxSessionEvents(handoff.workspaceId, handoff.providerId, handoff.activeSandboxId, { sessionId, limit: 120, }); }, }); } function repoSummary(overview: RepoOverview | undefined): { total: number; mapped: number; unmapped: number; conflicts: number; needsRestack: number; openPrs: number; } { if (!overview) { return { total: 0, mapped: 0, unmapped: 0, conflicts: 0, needsRestack: 0, openPrs: 0, }; } let mapped = 0; let conflicts = 0; let needsRestack = 0; let openPrs = 0; for (const row of overview.branches) { if (row.handoffId) { mapped += 1; } if (row.conflictsWithMain) { conflicts += 1; } if (row.trackedInStack && row.parentBranch && row.hasUnpushed) { needsRestack += 1; } if (row.prNumber && row.prState !== "MERGED" && row.prState !== "CLOSED") { openPrs += 1; } } return { total: overview.branches.length, mapped, unmapped: Math.max(0, overview.branches.length - mapped), conflicts, needsRestack, openPrs, }; } function branchKind(row: RepoBranchRecord): StatusTagKind { if (row.conflictsWithMain) { return "negative"; } if (row.prState === "OPEN" || row.prState === "DRAFT") { return "warning"; } if (row.prState === "MERGED") { return "positive"; } return "neutral"; } function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean { if (filter === "archived") { return branch.handoffStatus === "archived"; } if (filter === "unmapped") { return branch.handoffId === null; } if (filter === "active") { return branch.handoffStatus !== "archived"; } return true; } function selectValue(option: Option | null | undefined): Value { return option ? [option] : []; } function optionId(value: Value): string | null { const id = value[0]?.id; if (typeof id === "string") return id; if (typeof id === "number") return String(id); return null; } function createOption(item: SelectItem): Option { return { id: item.id, label: item.label, disabled: item.disabled, }; } function inputTestIdOverrides(testId?: string) { return testId ? { Input: { props: { "data-testid": testId, }, }, } : undefined; } function textareaTestIdOverrides(testId?: string) { return testId ? { Input: { props: { "data-testid": testId, }, }, } : undefined; } function selectTestIdOverrides(testId?: string) { return testId ? { ControlContainer: { props: { "data-testid": testId, }, }, } : undefined; } function EmptyState({ children, testId }: { children: string; testId?: string }) { return (
{children}
); } function StatusPill({ children, kind }: { children: ReactNode; kind: StatusTagKind }) { return ( {children} ); } function MetaRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { return (
{label} {mono ? ( {value} ) : ( {value} )}
); } export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRepoId }: WorkspaceDashboardProps) { const [css, theme] = useStyletron(); const navigate = useNavigate(); const repoOverviewMode = typeof selectedRepoId === "string" && selectedRepoId.length > 0; const [draft, setDraft] = useState(""); const [activeSessionId, setActiveSessionId] = useState(null); const [createRepoId, setCreateRepoId] = useState(""); const [newTask, setNewTask] = useState(""); const [newTitle, setNewTitle] = useState(""); const [newBranchName, setNewBranchName] = useState(""); const [createOnBranch, setCreateOnBranch] = useState(null); const [addRepoOpen, setAddRepoOpen] = useState(false); const [createHandoffOpen, setCreateHandoffOpen] = useState(false); const [addRepoRemote, setAddRepoRemote] = useState(""); const [addRepoError, setAddRepoError] = useState(null); const [stackActionError, setStackActionError] = useState(null); const [stackActionMessage, setStackActionMessage] = useState(null); const [selectedOverviewBranch, setSelectedOverviewBranch] = useState(null); const [overviewFilter, setOverviewFilter] = useState("active"); const [reparentBranchName, setReparentBranchName] = useState(null); const [reparentParentBranch, setReparentParentBranch] = useState(""); const [newAgentType, setNewAgentType] = useState(() => { try { const raw = globalThis.localStorage?.getItem("hf.settings.agentType"); return raw === "claude" || raw === "codex" ? raw : "codex"; } catch { return "codex"; } }); const [createError, setCreateError] = useState(null); const handoffsQuery = useQuery({ queryKey: ["workspace", workspaceId, "handoffs"], queryFn: async () => backendClient.listHandoffs(workspaceId), refetchInterval: 2_500, }); const handoffDetailQuery = useQuery({ queryKey: ["workspace", workspaceId, "handoff-detail", selectedHandoffId], enabled: Boolean(selectedHandoffId && !repoOverviewMode), refetchInterval: 2_500, queryFn: async () => { if (!selectedHandoffId) { throw new Error("No task selected"); } return backendClient.getHandoff(workspaceId, selectedHandoffId); }, }); const reposQuery = useQuery({ queryKey: ["workspace", workspaceId, "repos"], queryFn: async () => backendClient.listRepos(workspaceId), refetchInterval: 10_000, }); const repos = reposQuery.data ?? []; const activeRepoId = selectedRepoId ?? createRepoId; const repoOverviewQuery = useQuery({ queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId], enabled: Boolean(repoOverviewMode && activeRepoId), refetchInterval: 5_000, queryFn: async () => { if (!activeRepoId) { throw new Error("No repo selected"); } return backendClient.getRepoOverview(workspaceId, activeRepoId); }, }); useEffect(() => { if (repoOverviewMode && selectedRepoId) { setCreateRepoId(selectedRepoId); return; } if (!createRepoId && repos.length > 0) { setCreateRepoId(repos[0]!.repoId); } }, [createRepoId, repoOverviewMode, repos, selectedRepoId]); useEffect(() => { try { globalThis.localStorage?.setItem("hf.settings.agentType", newAgentType); } catch { // ignore storage failures } }, [newAgentType]); const rows = handoffsQuery.data ?? []; const repoGroups = useMemo(() => { const byRepo = new Map(); for (const row of rows) { const bucket = byRepo.get(row.repoId); if (bucket) { bucket.push(row); } else { byRepo.set(row.repoId, [row]); } } return repos .map((repo) => { const handoffs = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt); const latestHandoffAt = handoffs[0]?.updatedAt ?? 0; return { repoId: repo.repoId, repoRemote: repo.remoteUrl, latestActivityAt: Math.max(repo.updatedAt, latestHandoffAt), handoffs, }; }) .sort((a, b) => { if (a.latestActivityAt !== b.latestActivityAt) { return b.latestActivityAt - a.latestActivityAt; } return a.repoRemote.localeCompare(b.repoRemote); }); }, [repos, rows]); const selectedSummary = useMemo(() => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null, [rows, selectedHandoffId]); const selectedForSession = repoOverviewMode ? null : (handoffDetailQuery.data ?? null); const activeSandbox = useMemo(() => { if (!selectedForSession) return null; const byActive = selectedForSession.activeSandboxId ? (selectedForSession.sandboxes.find((sandbox) => sandbox.sandboxId === selectedForSession.activeSandboxId) ?? null) : null; return byActive ?? selectedForSession.sandboxes[0] ?? null; }, [selectedForSession]); useEffect(() => { if (!repoOverviewMode && !selectedHandoffId && rows.length > 0) { void navigate({ to: "/workspaces/$workspaceId/handoffs/$handoffId", params: { workspaceId, handoffId: rows[0]!.handoffId, }, search: { sessionId: undefined }, replace: true, }); } }, [navigate, repoOverviewMode, rows, selectedHandoffId, workspaceId]); useEffect(() => { setActiveSessionId(null); setDraft(""); }, [selectedForSession?.handoffId]); const sessionsQuery = useQuery({ queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"], enabled: Boolean(activeSandbox?.sandboxId && selectedForSession), refetchInterval: 3_000, queryFn: async () => { if (!activeSandbox?.sandboxId || !selectedForSession) { return { items: [] }; } return backendClient.listSandboxSessions(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, { limit: 30, }); }, }); const sessionRows = sessionsQuery.data?.items ?? []; const sessionSelection = useMemo( () => resolveSessionSelection({ explicitSessionId: activeSessionId, handoffSessionId: selectedForSession?.activeSessionId ?? null, sessions: sessionRows, }), [activeSessionId, selectedForSession?.activeSessionId, sessionRows], ); const resolvedSessionId = sessionSelection.sessionId; const staleSessionId = sessionSelection.staleSessionId; const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId); const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId); const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { if (!selectedForSession || !activeSandbox?.sandboxId) { throw new Error("No sandbox is available for this task"); } return backendClient.createSandboxSession({ workspaceId, providerId: activeSandbox.providerId, sandboxId: activeSandbox.sandboxId, prompt: selectedForSession.task, cwd: activeSandbox.cwd ?? undefined, agent: normalizeAgent(selectedForSession.agentType), }); }; const createSession = useMutation({ mutationFn: async () => startSessionFromHandoff(), onSuccess: async (session) => { setActiveSessionId(session.id); await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]); }, }); const ensureSessionForPrompt = async (): Promise => { if (resolvedSessionId) { return resolvedSessionId; } const created = await startSessionFromHandoff(); setActiveSessionId(created.id); await sessionsQuery.refetch(); return created.id; }; const sendPrompt = useMutation({ mutationFn: async (prompt: string) => { if (!selectedForSession || !activeSandbox?.sandboxId) { throw new Error("No sandbox is available for this task"); } const sessionId = await ensureSessionForPrompt(); await backendClient.sendSandboxPrompt({ workspaceId, providerId: activeSandbox.providerId, sandboxId: activeSandbox.sandboxId, sessionId, prompt, }); }, onSuccess: async () => { setDraft(""); await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]); }, }); const transcript = buildTranscript(eventsQuery.data?.items ?? []); const canCreateHandoff = createRepoId.trim().length > 0 && newTask.trim().length > 0; const createHandoff = useMutation({ mutationFn: async () => { const repoId = createRepoId.trim(); const task = newTask.trim(); if (!repoId || !task) { throw new Error("Repository and task are required"); } const draftTitle = newTitle.trim(); const draftBranchName = newBranchName.trim(); return backendClient.createHandoff({ workspaceId, repoId, task, agentType: newAgentType, explicitTitle: draftTitle || undefined, explicitBranchName: createOnBranch ? undefined : draftBranchName || undefined, onBranch: createOnBranch ?? undefined, }); }, onSuccess: async (handoff) => { setCreateError(null); setNewTask(""); setNewTitle(""); setNewBranchName(""); setCreateOnBranch(null); setCreateHandoffOpen(false); await handoffsQuery.refetch(); await repoOverviewQuery.refetch(); await navigate({ to: "/workspaces/$workspaceId/handoffs/$handoffId", params: { workspaceId, handoffId: handoff.handoffId, }, search: { sessionId: undefined }, }); }, onError: (error) => { setCreateError(error instanceof Error ? error.message : String(error)); }, }); const addRepo = useMutation({ mutationFn: async (remoteUrl: string) => { const trimmed = remoteUrl.trim(); if (!trimmed) { throw new Error("Remote URL is required"); } return backendClient.addRepo(workspaceId, trimmed); }, onSuccess: async (created) => { setAddRepoError(null); setAddRepoRemote(""); setAddRepoOpen(false); await reposQuery.refetch(); setCreateRepoId(created.repoId); if (repoOverviewMode) { await navigate({ to: "/workspaces/$workspaceId/repos/$repoId", params: { workspaceId, repoId: created.repoId, }, }); } }, onError: (error) => { setAddRepoError(error instanceof Error ? error.message : String(error)); }, }); const runStackAction = useMutation({ mutationFn: async (input: { action: RepoStackAction; branchName?: string; parentBranch?: string }) => { if (!activeRepoId) { throw new Error("No repository selected"); } return backendClient.runRepoStackAction({ workspaceId, repoId: activeRepoId, action: input.action, branchName: input.branchName, parentBranch: input.parentBranch, }); }, onSuccess: async (result) => { if (result.executed) { setStackActionError(null); setStackActionMessage(result.message); } else { setStackActionMessage(null); setStackActionError(result.message); } await Promise.all([repoOverviewQuery.refetch(), handoffsQuery.refetch()]); }, onError: (error) => { setStackActionMessage(null); setStackActionError(error instanceof Error ? error.message : String(error)); }, }); const openCreateFromBranch = (repoId: string, branchName: string): void => { setCreateRepoId(repoId); setCreateOnBranch(branchName); setNewBranchName(""); setCreateError(null); if (!newTask.trim()) { setNewTask(`Continue work on ${branchName}`); } setCreateHandoffOpen(true); }; const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]); const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null; const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]); const selectedFilterOption = useMemo( () => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!), [overviewFilter], ); const sessionOptions = useMemo( () => sessionRows.map((session) => createOption({ id: session.id, label: `${session.id} (${session.status ?? "running"})` })), [sessionRows], ); const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null; const overview = repoOverviewQuery.data; const overviewStats = repoSummary(overview); const stackActionsEnabled = Boolean(overview?.stackAvailable) && !runStackAction.isPending; const filteredOverviewBranches = useMemo(() => { if (!overview?.branches?.length) { return []; } return overview.branches.filter((branch) => matchesOverviewFilter(branch, overviewFilter)); }, [overview, overviewFilter]); const selectedBranchOverview = useMemo(() => { if (!filteredOverviewBranches.length) { return null; } if (!selectedOverviewBranch) { return filteredOverviewBranches[0] ?? null; } return filteredOverviewBranches.find((row) => row.branchName === selectedOverviewBranch) ?? filteredOverviewBranches[0] ?? null; }, [filteredOverviewBranches, selectedOverviewBranch]); useEffect(() => { if (!filteredOverviewBranches.length) { setSelectedOverviewBranch(null); return; } if (!selectedOverviewBranch || !filteredOverviewBranches.some((row) => row.branchName === selectedOverviewBranch)) { setSelectedOverviewBranch(filteredOverviewBranches[0]?.branchName ?? null); } }, [filteredOverviewBranches, selectedOverviewBranch]); const handleReparentSubmit = (): void => { if (!reparentBranchName || !reparentParentBranch.trim()) { return; } setStackActionError(null); void runStackAction .mutateAsync({ action: "reparent_branch", branchName: reparentBranchName, parentBranch: reparentParentBranch.trim(), }) .then(() => { setReparentBranchName(null); setReparentParentBranch(""); }) .catch(() => { // mutation state is surfaced above }); }; const modalOverrides = useMemo( () => ({ Dialog: { style: { borderRadius: "0", backgroundColor: theme.colors.backgroundSecondary, border: `1px solid ${theme.colors.borderOpaque}`, boxShadow: "0 18px 40px rgba(0, 0, 0, 0.45)", }, }, Close: { style: { borderRadius: "0", }, }, }), [theme.colors.backgroundSecondary, theme.colors.borderOpaque], ); return (
Workspace
{workspaceId}
Tasks
{handoffsQuery.isLoading ? ( <> ) : null} {!handoffsQuery.isLoading && repoGroups.length === 0 ? ( No repos or tasks yet. Add a repo to start a workspace. ) : null} {repoGroups.map((group) => (
{group.repoRemote}
{group.handoffs .filter((handoff) => handoff.status !== "archived" || handoff.handoffId === selectedSummary?.handoffId) .map((handoff) => { const isActive = !repoOverviewMode && handoff.handoffId === selectedSummary?.handoffId; return ( {handoff.title ?? "Determining title..."}
{handoff.branchName ?? "Determining branch..."} {handoff.status}
); })}
))}
{repoOverviewMode ? ( <>
Repo Overview
{ const next = optionId(params.value); if (next) { setActiveSessionId(next); } }} overrides={selectTestIdOverrides("handoff-session-select")} />
) : null}
{eventsQuery.isLoading ? : null} {transcript.length === 0 && !eventsQuery.isLoading ? ( {groupHandoffStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage ? `Session failed: ${selectedForSession.statusMessage}` : !activeSandbox?.sandboxId ? selectedForSession.statusMessage ? `Sandbox unavailable: ${selectedForSession.statusMessage}` : "This task is still provisioning its sandbox." : staleSessionId ? `Session ${staleSessionId} is unavailable. Start a new session to continue.` : resolvedSessionId ? "No transcript events yet. Send a prompt to start this session." : "No active session for this task."} ) : null}
{transcript.map((entry) => (
{entry.sender} {formatTime(entry.createdAt)}
                                {entry.text}
                              
))}