sandbox-agent/factory/packages/frontend/src/components/workspace-dashboard.tsx
Nicholas Kissel 32008797da
Foundry UI polish: real org data, project icons, model picker (#233)
* feat: modernize chat UI and rename handoff to task

- Remove agent message bubbles, keep user bubbles (right-aligned)
- Rename "Handoffs" to "Tasks" with ListChecks icon in sidebar
- Move model picker inside composer, add renderFooter to ChatComposer SDK
- Make project sections collapsible with hover-only chevrons
- Remove divider between chat and composer
- Update model picker chevron to flip on open/close
- Replace all user-visible "handoff" strings with "task" across frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: real org mock data, model picker styling, project icons, task minutes indicator

- Replace fake acme/* mock data with real rivet-dev GitHub org repos and PRs
- Fix model picker popover: dark gray surface with backdrop blur instead of pure black
- Add colored letter icons to project section headers (swap to chevron on hover)
- Add "847 min used" indicator in transcript header
- Rename browser tab title from OpenHandoff to Foundry
- Reduce transcript header title font weight to 500

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:49:48 -07:00

1937 lines
73 KiB
TypeScript

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<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
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 (
<div
data-testid={testId}
style={{
padding: "12px",
borderRadius: "0",
border: "1px dashed rgba(166, 176, 191, 0.24)",
background: "rgba(255, 255, 255, 0.02)",
}}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{children}
</ParagraphSmall>
</div>
);
}
function StatusPill({ children, kind }: { children: ReactNode; kind: StatusTagKind }) {
return (
<Tag
closeable={false}
kind={kind}
hierarchy="secondary"
size="small"
overrides={{
Root: {
style: {
borderRadius: "2px",
minHeight: "20px",
fontFamily: '"IBM Plex Mono", "SFMono-Regular", monospace',
letterSpacing: "0.02em",
},
},
}}
>
{children}
</Tag>
);
}
function MetaRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
gap: "12px",
alignItems: "flex-start",
}}
>
<LabelXSmall color="contentSecondary">{label}</LabelXSmall>
{mono ? (
<MonoLabelSmall marginTop="0" marginBottom="0" overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}>
{value}
</MonoLabelSmall>
) : (
<LabelSmall marginTop="0" marginBottom="0" overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}>
{value}
</LabelSmall>
)}
</div>
);
}
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<string | null>(null);
const [createRepoId, setCreateRepoId] = useState("");
const [newTask, setNewTask] = useState("");
const [newTitle, setNewTitle] = useState("");
const [newBranchName, setNewBranchName] = useState("");
const [createOnBranch, setCreateOnBranch] = useState<string | null>(null);
const [addRepoOpen, setAddRepoOpen] = useState(false);
const [createHandoffOpen, setCreateHandoffOpen] = useState(false);
const [addRepoRemote, setAddRepoRemote] = useState("");
const [addRepoError, setAddRepoError] = useState<string | null>(null);
const [stackActionError, setStackActionError] = useState<string | null>(null);
const [stackActionMessage, setStackActionMessage] = useState<string | null>(null);
const [selectedOverviewBranch, setSelectedOverviewBranch] = useState<string | null>(null);
const [overviewFilter, setOverviewFilter] = useState<RepoOverviewFilter>("active");
const [reparentBranchName, setReparentBranchName] = useState<string | null>(null);
const [reparentParentBranch, setReparentParentBranch] = useState("");
const [newAgentType, setNewAgentType] = useState<AgentType>(() => {
try {
const raw = globalThis.localStorage?.getItem("hf.settings.agentType");
return raw === "claude" || raw === "codex" ? raw : "codex";
} catch {
return "codex";
}
});
const [createError, setCreateError] = useState<string | null>(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<string, HandoffSummary[]>();
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<string> => {
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 (
<AppShell>
<DashboardGrid>
<Panel>
<PanelHeader>
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.sizing.scale400,
})}
>
<div
className={css({
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: "2px",
})}
>
<LabelXSmall color="contentTertiary">Workspace</LabelXSmall>
<div
className={css({
display: "flex",
alignItems: "center",
gap: theme.sizing.scale300,
})}
>
<FolderGit2 size={14} />
<HeadingXSmall marginTop="0" marginBottom="0">
{workspaceId}
</HeadingXSmall>
</div>
</div>
<Button
size="compact"
kind="secondary"
onClick={() => {
setAddRepoError(null);
setAddRepoOpen(true);
}}
data-testid="repo-add-open"
>
Add Repo
</Button>
</div>
<div
className={css({
paddingTop: theme.sizing.scale200,
borderTop: `1px solid ${theme.colors.borderOpaque}`,
})}
>
<LabelXSmall color="contentSecondary">Tasks</LabelXSmall>
</div>
</PanelHeader>
<ScrollBody>
{handoffsQuery.isLoading ? (
<>
<Skeleton rows={3} height="72px" />
</>
) : null}
{!handoffsQuery.isLoading && repoGroups.length === 0 ? (
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
) : null}
{repoGroups.map((group) => (
<section
key={group.repoId}
className={css({
marginLeft: "-12px",
marginRight: "-12px",
paddingBottom: "8px",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
})}
>
<Link
to="/workspaces/$workspaceId/repos/$repoId"
params={{ workspaceId, repoId: group.repoId }}
className={css({
display: "block",
textDecoration: "none",
fontSize: theme.typography.LabelSmall.fontSize,
fontWeight: 600,
lineHeight: "1.35",
color: theme.colors.contentSecondary,
padding: "10px 12px 8px",
wordBreak: "break-word",
":hover": {
color: theme.colors.contentPrimary,
backgroundColor: "rgba(255, 255, 255, 0.02)",
},
})}
data-testid={group.repoId === activeRepoId ? "repo-overview-open" : `repo-overview-open-${group.repoId}`}
>
{group.repoRemote}
</Link>
<div
className={css({
display: "flex",
flexDirection: "column",
gap: "0",
})}
>
{group.handoffs
.filter((handoff) => handoff.status !== "archived" || handoff.handoffId === selectedSummary?.handoffId)
.map((handoff) => {
const isActive = !repoOverviewMode && handoff.handoffId === selectedSummary?.handoffId;
return (
<Link
key={handoff.handoffId}
to="/workspaces/$workspaceId/handoffs/$handoffId"
params={{ workspaceId, handoffId: handoff.handoffId }}
search={{ sessionId: undefined }}
className={css({
display: "block",
textDecoration: "none",
borderLeft: `2px solid ${isActive ? theme.colors.primary : "transparent"}`,
borderTop: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: isActive
? "rgba(143, 180, 255, 0.08)"
: handoff.status === "archived"
? "rgba(255, 255, 255, 0.02)"
: "transparent",
padding: "10px 12px 10px 14px",
transition: "background-color 0.15s ease, border-color 0.15s ease",
":hover": {
backgroundColor: isActive ? "rgba(143, 180, 255, 0.1)" : "rgba(255, 255, 255, 0.03)",
},
})}
>
<LabelSmall marginTop="0" marginBottom="0">
{handoff.title ?? "Determining title..."}
</LabelSmall>
<div
className={css({
marginTop: "8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.sizing.scale300,
})}
>
<ParagraphSmall
marginTop="0"
marginBottom="0"
color="contentSecondary"
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
>
{handoff.branchName ?? "Determining branch..."}
</ParagraphSmall>
<StatusPill kind={statusKind(handoff.status)}>{handoff.status}</StatusPill>
</div>
</Link>
);
})}
<Button
size="compact"
kind="tertiary"
overrides={{
BaseButton: {
style: {
justifyContent: "flex-start",
borderRadius: "0",
paddingLeft: "12px",
paddingRight: "12px",
},
},
}}
onClick={() => {
setCreateRepoId(group.repoId);
setCreateOnBranch(null);
setCreateError(null);
setCreateHandoffOpen(true);
}}
data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`}
>
Create Task
</Button>
</div>
</section>
))}
</ScrollBody>
</Panel>
<Panel>
{repoOverviewMode ? (
<>
<PanelHeader>
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.sizing.scale400,
flexWrap: "wrap",
})}
>
<div
className={css({
display: "flex",
alignItems: "center",
gap: theme.sizing.scale300,
})}
>
<GitBranch size={16} />
<HeadingXSmall marginTop="0" marginBottom="0">
Repo Overview
</HeadingXSmall>
</div>
<div
className={css({
display: "flex",
flexWrap: "wrap",
alignItems: "center",
gap: theme.sizing.scale300,
})}
>
<div className={css({ minWidth: "220px" })}>
<Select
options={FILTER_OPTIONS.map(createOption)}
value={selectValue(selectedFilterOption)}
clearable={false}
searchable={false}
size="compact"
onChange={(params: OnChangeParams) => {
const next = optionId(params.value) as RepoOverviewFilter | null;
if (next) {
setOverviewFilter(next);
}
}}
aria-label="Filter branches"
overrides={selectTestIdOverrides("repo-overview-filter")}
/>
</div>
<Button
size="compact"
kind="secondary"
disabled={!stackActionsEnabled}
onClick={() => {
setStackActionError(null);
void runStackAction.mutateAsync({ action: "sync_repo" });
}}
data-testid="repo-stack-sync"
>
Sync Stack
</Button>
<Button
size="compact"
kind="secondary"
disabled={!stackActionsEnabled}
onClick={() => {
setStackActionError(null);
void runStackAction.mutateAsync({ action: "restack_repo" });
}}
data-testid="repo-stack-restack-all"
>
<span
className={css({
display: "inline-flex",
alignItems: "center",
gap: theme.sizing.scale200,
})}
>
<Shuffle size={14} />
Restack All
</span>
</Button>
</div>
</div>
<div
className={css({
display: "flex",
flexWrap: "wrap",
gap: "8px",
})}
>
<StatusPill kind="neutral">Branches {overviewStats.total}</StatusPill>
<StatusPill kind="positive">Mapped {overviewStats.mapped}</StatusPill>
<StatusPill kind="warning">Unmapped {overviewStats.unmapped}</StatusPill>
<StatusPill kind="negative">Conflicts {overviewStats.conflicts}</StatusPill>
<StatusPill kind="neutral">Open PRs {overviewStats.openPrs}</StatusPill>
<StatusPill kind="neutral">Needs restack {overviewStats.needsRestack}</StatusPill>
</div>
{overview && !overview.stackAvailable ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary" data-testid="repo-stack-unavailable">
git-spice is unavailable for this repo. Stack actions are disabled.
</ParagraphSmall>
) : null}
{stackActionError ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="repo-stack-error">
{stackActionError}
</ParagraphSmall>
) : null}
{stackActionMessage ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="positive" data-testid="repo-stack-message">
{stackActionMessage}
</ParagraphSmall>
) : null}
</PanelHeader>
<ScrollBody data-testid="repo-overview-center">
{repoOverviewQuery.isLoading ? <Skeleton rows={4} height="72px" /> : null}
{!repoOverviewQuery.isLoading && !overview ? <EmptyState>No repo overview is available yet.</EmptyState> : null}
{overview ? (
<div
className={css({
overflowX: "auto",
border: `1px solid ${theme.colors.borderOpaque}`,
})}
>
<div
className={css({
minWidth: "980px",
display: "grid",
gridTemplateColumns: "2fr 1.3fr 0.8fr 1fr 1fr 1.4fr",
})}
>
{["Branch", "Parent", "Ahead", "PR", "CI/Review", "Actions"].map((label) => (
<div
key={label}
className={css({
padding: `12px ${theme.sizing.scale400}`,
backgroundColor: theme.colors.backgroundTertiary,
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
})}
>
<LabelXSmall color="contentSecondary">{label}</LabelXSmall>
</div>
))}
{filteredOverviewBranches.length === 0 ? (
<div
className={css({
gridColumn: "1 / -1",
padding: theme.sizing.scale600,
})}
data-testid="repo-overview-filter-empty"
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
No branches match the selected filter.
</ParagraphSmall>
</div>
) : (
filteredOverviewBranches.map((branch) => {
const selectedRow = selectedBranchOverview?.branchName === branch.branchName;
const branchToken = branchTestIdToken(branch.branchName);
const rowClass = css({
display: "contents",
});
const cellClass = css({
padding: `${theme.sizing.scale400} ${theme.sizing.scale400}`,
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: selectedRow ? "rgba(29, 111, 95, 0.08)" : theme.colors.backgroundSecondary,
fontSize: theme.typography.ParagraphSmall.fontSize,
cursor: "pointer",
});
return (
<div
key={branch.branchName}
className={rowClass}
onClick={() => setSelectedOverviewBranch(branch.branchName)}
data-testid={`repo-overview-row-${branchToken}`}
>
<div className={cellClass}>
<LabelSmall marginTop="0" marginBottom="0">
{branch.branchName}
</LabelSmall>
<div
className={css({
marginTop: "8px",
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: theme.sizing.scale200,
})}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{formatRelativeAge(branch.updatedAt)}
</ParagraphSmall>
<StatusPill kind={branch.handoffId ? "positive" : "warning"}>
{branch.handoffId ? "task" : "unmapped"}
</StatusPill>
{branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null}
</div>
</div>
<div className={cellClass}>{branch.parentBranch ?? "-"}</div>
<div className={cellClass}>{branch.hasUnpushed ? "yes" : "-"}</div>
<div className={cellClass}>
{branch.prNumber ? (
<a
href={branch.prUrl ?? undefined}
target="_blank"
rel="noreferrer"
className={css({
color: theme.colors.contentPrimary,
})}
>
#{branch.prNumber} {branch.prState ?? "open"}
</a>
) : (
<span className={css({ color: theme.colors.contentSecondary })}>-</span>
)}
</div>
<div className={cellClass}>
{branch.ciStatus ?? "-"} / {branch.reviewStatus ?? "-"}
</div>
<div className={cellClass}>
<div
className={css({
display: "flex",
flexWrap: "wrap",
gap: theme.sizing.scale200,
})}
>
<Button
size="compact"
kind="tertiary"
disabled={!stackActionsEnabled}
onClick={(event) => {
event.stopPropagation();
setStackActionError(null);
void runStackAction.mutateAsync({
action: "restack_subtree",
branchName: branch.branchName,
});
}}
data-testid={`repo-overview-restack-${branchToken}`}
>
Restack
</Button>
<Button
size="compact"
kind="tertiary"
disabled={!stackActionsEnabled}
onClick={(event) => {
event.stopPropagation();
setStackActionError(null);
void runStackAction.mutateAsync({
action: "rebase_branch",
branchName: branch.branchName,
});
}}
data-testid={`repo-overview-rebase-${branchToken}`}
>
Rebase
</Button>
<Button
size="compact"
kind="tertiary"
disabled={!stackActionsEnabled}
onClick={(event) => {
event.stopPropagation();
setReparentBranchName(branch.branchName);
setReparentParentBranch(branch.parentBranch ?? "main");
setStackActionError(null);
}}
data-testid={`repo-overview-reparent-${branchToken}`}
>
Reparent
</Button>
{!branch.handoffId ? (
<Button
size="compact"
kind="secondary"
onClick={(event) => {
event.stopPropagation();
openCreateFromBranch(activeRepoId, branch.branchName);
}}
data-testid={`repo-overview-create-${branchToken}`}
>
Create Task
</Button>
) : null}
<StatusPill kind={branchKind(branch)}>{branch.conflictsWithMain ? "conflict" : "ok"}</StatusPill>
</div>
</div>
</div>
);
})
)}
</div>
</div>
) : null}
</ScrollBody>
</>
) : (
<>
<PanelHeader>
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.sizing.scale400,
flexWrap: "wrap",
})}
>
<div
className={css({
display: "flex",
alignItems: "center",
gap: theme.sizing.scale300,
flexWrap: "wrap",
})}
>
<Bot size={16} />
<HeadingXSmall marginTop="0" marginBottom="0">
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No task selected"}
</HeadingXSmall>
{selectedForSession ? <StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill> : null}
</div>
{selectedForSession && !resolvedSessionId ? (
<Button
size="compact"
onClick={() => {
void createSession.mutateAsync();
}}
disabled={createSession.isPending || !canStartSession}
>
{staleSessionId ? "Start New Session" : "Start Session"}
</Button>
) : null}
</div>
</PanelHeader>
<div
className={css({
minHeight: 0,
flex: 1,
display: "grid",
gridTemplateRows: "minmax(0, 1fr) auto",
gap: "1px",
padding: 0,
backgroundColor: theme.colors.borderOpaque,
})}
>
{!selectedForSession ? (
<EmptyState>Select a task from the left sidebar.</EmptyState>
) : (
<>
<div
className={css({
minHeight: 0,
display: "flex",
flexDirection: "column",
backgroundColor: theme.colors.backgroundSecondary,
overflow: "hidden",
})}
>
<div
className={css({
padding: `${theme.sizing.scale400} ${theme.sizing.scale500}`,
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.sizing.scale400,
flexWrap: "wrap",
backgroundColor: theme.colors.backgroundTertiary,
})}
>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "8px",
color: theme.colors.contentSecondary,
})}
>
<MessageSquareText size={14} />
<LabelSmall marginTop="0" marginBottom="0">
Session {resolvedSessionId ?? staleSessionId ?? "(none)"}
</LabelSmall>
</div>
{sessionRows.length > 0 ? (
<div className={css({ minWidth: "280px", maxWidth: "100%" })}>
<Select
options={sessionOptions}
value={selectValue(selectedSessionOption)}
clearable={false}
searchable={false}
size="compact"
onChange={(params: OnChangeParams) => {
const next = optionId(params.value);
if (next) {
setActiveSessionId(next);
}
}}
overrides={selectTestIdOverrides("handoff-session-select")}
/>
</div>
) : null}
</div>
<div
className={css({
minHeight: 0,
flex: 1,
overflowY: "auto",
padding: theme.sizing.scale400,
backgroundColor: theme.colors.backgroundPrimary,
})}
>
{eventsQuery.isLoading ? <Skeleton rows={2} height="90px" /> : null}
{transcript.length === 0 && !eventsQuery.isLoading ? (
<EmptyState testId="session-transcript-empty">
{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."}
</EmptyState>
) : null}
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale400,
})}
data-testid="session-transcript"
>
{transcript.map((entry) => (
<article
key={entry.id}
data-testid="session-transcript-entry"
className={css({
borderLeft: `2px solid ${entry.sender === "agent" ? "rgba(29, 111, 95, 0.45)" : "rgba(32, 108, 176, 0.45)"}`,
border: `1px solid ${entry.sender === "agent" ? "rgba(29, 111, 95, 0.22)" : "rgba(32, 108, 176, 0.22)"}`,
backgroundColor: entry.sender === "agent" ? "rgba(29, 111, 95, 0.07)" : "rgba(32, 108, 176, 0.07)",
padding: `12px ${theme.sizing.scale400}`,
})}
>
<header
className={css({
marginBottom: "8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.sizing.scale300,
})}
>
<LabelXSmall color="contentSecondary">{entry.sender}</LabelXSmall>
<LabelXSmall color="contentSecondary">{formatTime(entry.createdAt)}</LabelXSmall>
</header>
<pre
className={css({
margin: 0,
whiteSpace: "pre-wrap",
overflowX: "auto",
fontFamily: '"IBM Plex Mono", "SFMono-Regular", monospace',
fontSize: theme.typography.MonoParagraphSmall.fontSize,
lineHeight: theme.typography.MonoParagraphSmall.lineHeight,
})}
>
{entry.text}
</pre>
</article>
))}
</div>
</div>
</div>
<div
className={css({
display: "flex",
flexDirection: "column",
gap: "1px",
padding: "10px 12px",
backgroundColor: theme.colors.backgroundSecondary,
})}
>
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
placeholder="Send a follow-up prompt to this session"
rows={5}
disabled={!activeSandbox?.sandboxId}
overrides={textareaTestIdOverrides("handoff-session-prompt")}
/>
<div
className={css({
display: "flex",
justifyContent: "flex-end",
})}
>
<Button
onClick={() => {
const prompt = draft.trim();
if (!prompt) {
return;
}
void sendPrompt.mutateAsync(prompt);
}}
disabled={
sendPrompt.isPending || createSession.isPending || !selectedForSession || !activeSandbox?.sandboxId || draft.trim().length === 0
}
>
<span
className={css({
display: "inline-flex",
alignItems: "center",
gap: theme.sizing.scale200,
})}
>
<SendHorizontal size={14} />
Send
</span>
</Button>
</div>
</div>
</>
)}
</div>
</>
)}
</Panel>
<DetailRail>
<PanelHeader>
<HeadingSmall marginTop="0" marginBottom="0">
{repoOverviewMode ? "Repo Details" : "Task Details"}
</HeadingSmall>
</PanelHeader>
<ScrollBody>
{repoOverviewMode ? (
!overview ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
No repo overview available.
</ParagraphSmall>
) : (
<>
<section>
<HeadingXSmall marginTop="0" marginBottom="0">
Repository
</HeadingXSmall>
<StyledDivider />
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Remote" value={overview.remoteUrl} />
<MetaRow label="Base Ref" value={overview.baseRef ?? "-"} mono />
<MetaRow label="Stack Tool" value={overview.stackAvailable ? "git-spice" : "unavailable"} />
<MetaRow label="Fetched" value={new Date(overview.fetchedAt).toLocaleTimeString()} />
</div>
</section>
<section>
<HeadingXSmall marginTop="0" marginBottom="0">
Selected Branch
</HeadingXSmall>
<StyledDivider />
{!selectedBranchOverview ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
Select a branch in the center panel.
</ParagraphSmall>
) : (
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Branch" value={selectedBranchOverview.branchName} mono />
<MetaRow label="Parent" value={selectedBranchOverview.parentBranch ?? "-"} mono />
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} />
<MetaRow
label="Task"
value={selectedBranchOverview.handoffTitle ?? selectedBranchOverview.handoffId ?? "-"}
/>
</div>
)}
</section>
</>
)
) : !selectedForSession ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
No task selected.
</ParagraphSmall>
) : (
<>
<section>
<HeadingXSmall marginTop="0" marginBottom="0">
Identifiers
</HeadingXSmall>
<StyledDivider />
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Task" value={selectedForSession.handoffId} mono />
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
</div>
</section>
<section>
<HeadingXSmall marginTop="0" marginBottom="0">
Branch + PR
</HeadingXSmall>
<StyledDivider />
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Branch" value={selectedForSession.branchName ?? "-"} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedForSession.diffStat)} />
<MetaRow label="PR" value={selectedForSession.prUrl ?? "-"} />
<MetaRow label="Review" value={selectedForSession.reviewStatus ?? "-"} />
</div>
</section>
<section>
<HeadingXSmall marginTop="0" marginBottom="0">
Task
</HeadingXSmall>
<StyledDivider />
<div
className={css({
padding: theme.sizing.scale400,
borderRadius: "0",
backgroundColor: theme.colors.backgroundTertiary,
border: `1px solid ${theme.colors.borderOpaque}`,
})}
>
<ParagraphSmall marginTop="0" marginBottom="0">
{selectedForSession.task}
</ParagraphSmall>
</div>
</section>
{groupHandoffStatus(selectedForSession.status) === "error" ? (
<div
className={css({
padding: "12px",
borderRadius: "0",
border: `1px solid rgba(188, 57, 74, 0.28)`,
backgroundColor: "rgba(188, 57, 74, 0.06)",
})}
>
<div
className={css({
display: "flex",
alignItems: "center",
gap: theme.sizing.scale200,
marginBottom: theme.sizing.scale200,
})}
>
<CircleAlert size={14} />
<LabelSmall marginTop="0" marginBottom="0">
Session reported an error state
</LabelSmall>
</div>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{selectedForSession.statusMessage ? selectedForSession.statusMessage : "Open transcript in the center panel for details."}
</ParagraphSmall>
</div>
) : null}
</>
)}
</ScrollBody>
</DetailRail>
<Modal isOpen={addRepoOpen} onClose={() => setAddRepoOpen(false)} overrides={modalOverrides}>
<ModalHeader>Add Repo</ModalHeader>
<ModalBody>
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale500,
})}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
Add a git remote URL to this workspace.
</ParagraphSmall>
<Input
placeholder="Git remote (e.g. https://github.com/org/repo.git or org/repo)"
value={addRepoRemote}
onChange={(event) => setAddRepoRemote(event.target.value)}
overrides={inputTestIdOverrides("repo-add-remote")}
/>
{addRepoError ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="repo-add-error">
{addRepoError}
</ParagraphSmall>
) : null}
</div>
</ModalBody>
<ModalFooter>
<Button kind="tertiary" onClick={() => setAddRepoOpen(false)}>
Cancel
</Button>
<Button
onClick={() => {
setAddRepoError(null);
void addRepo.mutateAsync(addRepoRemote);
}}
disabled={addRepo.isPending || addRepoRemote.trim().length === 0}
data-testid="repo-add-submit"
>
Add Repo
</Button>
</ModalFooter>
</Modal>
<Modal
isOpen={createHandoffOpen}
onClose={() => {
setCreateHandoffOpen(false);
setCreateOnBranch(null);
}}
overrides={modalOverrides}
>
<ModalHeader>Create Task</ModalHeader>
<ModalBody>
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale500,
})}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
Pick a repo, describe the task, and the backend will create a task.
</ParagraphSmall>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Repo
</LabelXSmall>
<Select
options={repoOptions}
value={selectValue(selectedRepoOption)}
clearable={false}
searchable={false}
disabled={repos.length === 0}
onChange={(params: OnChangeParams) => {
const next = optionId(params.value);
if (next) {
setCreateRepoId(next);
}
}}
overrides={selectTestIdOverrides("handoff-create-repo")}
/>
{repos.length === 0 ? (
<div
className={css({
marginTop: theme.sizing.scale300,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: theme.sizing.scale300,
padding: "12px",
borderRadius: "0",
border: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundTertiary,
})}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
No repos yet.
</ParagraphSmall>
<Button
size="compact"
kind="secondary"
onClick={() => {
setCreateHandoffOpen(false);
setAddRepoError(null);
setAddRepoOpen(true);
}}
>
Add Repo
</Button>
</div>
) : null}
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Agent
</LabelXSmall>
<Select
options={AGENT_OPTIONS.map(createOption)}
value={selectValue(selectedAgentOption)}
clearable={false}
searchable={false}
onChange={(params: OnChangeParams) => {
const next = optionId(params.value);
if (next === "claude" || next === "codex") {
setNewAgentType(next);
}
}}
overrides={selectTestIdOverrides("handoff-create-agent")}
/>
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Task
</LabelXSmall>
<Textarea
value={newTask}
onChange={(event) => setNewTask(event.target.value)}
placeholder="Task"
rows={6}
overrides={textareaTestIdOverrides("handoff-create-task")}
/>
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Title
</LabelXSmall>
<Input
placeholder="Title (optional)"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
overrides={inputTestIdOverrides("handoff-create-title")}
/>
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Branch
</LabelXSmall>
{createOnBranch ? (
<Input value={createOnBranch} disabled overrides={inputTestIdOverrides("handoff-create-branch")} />
) : (
<Input
placeholder="Branch name (optional)"
value={newBranchName}
onChange={(event) => setNewBranchName(event.target.value)}
overrides={inputTestIdOverrides("handoff-create-branch")}
/>
)}
</div>
{createError ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="handoff-create-error">
{createError}
</ParagraphSmall>
) : null}
</div>
</ModalBody>
<ModalFooter>
<Button
kind="tertiary"
onClick={() => {
setCreateHandoffOpen(false);
setCreateOnBranch(null);
}}
>
Cancel
</Button>
<Button
disabled={!canCreateHandoff || createHandoff.isPending}
onClick={() => {
setCreateError(null);
void createHandoff.mutateAsync();
}}
data-testid="handoff-create-submit"
>
Create Task
</Button>
</ModalFooter>
</Modal>
<Modal
isOpen={reparentBranchName !== null}
onClose={() => {
setReparentBranchName(null);
setReparentParentBranch("");
}}
overrides={modalOverrides}
>
<ModalHeader>Reparent Branch</ModalHeader>
<ModalBody>
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale500,
})}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{reparentBranchName ? `Move ${reparentBranchName} onto a different parent branch.` : ""}
</ParagraphSmall>
<Input
value={reparentParentBranch}
onChange={(event) => setReparentParentBranch(event.target.value)}
placeholder="Parent branch"
overrides={inputTestIdOverrides("repo-overview-reparent-input")}
/>
</div>
</ModalBody>
<ModalFooter>
<Button
kind="tertiary"
onClick={() => {
setReparentBranchName(null);
setReparentParentBranch("");
}}
>
Cancel
</Button>
<Button disabled={!reparentBranchName || !reparentParentBranch.trim()} onClick={handleReparentSubmit}>
Reparent
</Button>
</ModalFooter>
</Modal>
</DashboardGrid>
</AppShell>
);
}