sandbox-agent/foundry/packages/frontend/src/components/workspace-dashboard.tsx
Nathan Flurry 70d31f819c
chore(foundry): improve sandbox impl + status pill (#252)
* Improve Daytona sandbox provisioning and frontend UI

Refactor git clone script in Daytona provider to use cleaner shell logic for GitHub token authentication and branch checkout. Add support for private repository clones with token-based auth. Improve Daytona provider error handling and git configuration setup.

Frontend improvements include enhanced dev panel, workspace dashboard, sidebar navigation, and UI components for better task/session management. Update interest manager and backend client to support improved session state handling.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* Add header status pill showing task/session/sandbox state

Surface aggregate status (error, provisioning, running, ready, no sandbox)
as a colored pill in the transcript panel header. Integrates task runtime
status, session status, and sandbox availability via the sandboxProcesses
interest topic so the pill accurately reflects unreachable sandboxes.

Includes mock tasks demonstrating error, provisioning, and running states,
unit tests for deriveHeaderStatus, and workspace-dashboard integration.

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

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-14 12:14:06 -07:00

2018 lines
77 KiB
TypeScript

import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, RepoBranchRecord, RepoOverview, RepoStackAction, TaskWorkbenchSnapshot, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared";
import { useInterest } from "@sandbox-agent/foundry-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/tasks/model";
import { deriveHeaderStatus, describeTaskState } from "../features/tasks/status";
import { HeaderStatusPill } from "./mock-layout/ui";
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
import { backendClient } from "../lib/backend";
import { interestManager } from "../lib/interest";
import { DevPanel, useDevPanel } from "./dev-panel";
interface WorkspaceDashboardProps {
workspaceId: string;
selectedTaskId?: 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: WorkbenchTaskStatus): StatusTagKind {
if (status === "running") return "positive";
if (status === "error") return "negative";
if (status === "new" || String(status).startsWith("init_")) return "warning";
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 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.taskId) {
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.taskStatus === "archived";
}
if (filter === "unmapped") {
return branch.taskId === null;
}
if (filter === "active") {
return branch.taskStatus !== "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, selectedTaskId, selectedRepoId }: WorkspaceDashboardProps) {
const [css, theme] = useStyletron();
const navigate = useNavigate();
const showDevPanel = useDevPanel();
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 [createTaskOpen, setCreateTaskOpen] = 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 workspaceState = useInterest(interestManager, "workspace", { workspaceId });
const repos = workspaceState.data?.repos ?? [];
const rows = workspaceState.data?.taskSummaries ?? [];
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
const taskState = useInterest(
interestManager,
"task",
!repoOverviewMode && selectedSummary
? {
workspaceId,
repoId: selectedSummary.repoId,
taskId: selectedSummary.id,
}
: null,
);
const activeRepoId = selectedRepoId ?? createRepoId;
const repoOverviewQuery = useQuery({
queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId],
enabled: Boolean(repoOverviewMode && activeRepoId),
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]!.id);
}
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
useEffect(() => {
try {
globalThis.localStorage?.setItem("hf.settings.agentType", newAgentType);
} catch {
// ignore storage failures
}
}, [newAgentType]);
const repoGroups = useMemo(() => {
const byRepo = new Map<string, typeof rows>();
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 tasks = [...(byRepo.get(repo.id) ?? [])].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
const latestTaskAt = tasks[0]?.updatedAtMs ?? 0;
return {
repoId: repo.id,
repoLabel: repo.label,
latestActivityAt: Math.max(repo.latestActivityMs, latestTaskAt),
tasks,
};
})
.sort((a, b) => {
if (a.latestActivityAt !== b.latestActivityAt) {
return b.latestActivityAt - a.latestActivityAt;
}
return a.repoLabel.localeCompare(b.repoLabel);
});
}, [repos, rows]);
const selectedForSession = repoOverviewMode ? null : (taskState.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 && !selectedTaskId && rows.length > 0) {
void navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
taskId: rows[0]!.id,
},
search: { sessionId: undefined },
replace: true,
});
}
}, [navigate, repoOverviewMode, rows, selectedTaskId, workspaceId]);
useEffect(() => {
setActiveSessionId(null);
setDraft("");
}, [selectedForSession?.id]);
const sessionRows = selectedForSession?.sessionsSummary ?? [];
const taskRuntimeStatus = selectedForSession?.runtimeStatus ?? selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskRuntimeStatus, selectedForSession?.statusMessage ?? null);
const taskStateSummary = `${taskStatusState.title}. ${taskStatusState.detail}`;
const shouldUseTaskStateEmptyState = Boolean(selectedForSession && taskRuntimeStatus && taskRuntimeStatus !== "running" && taskRuntimeStatus !== "idle");
const sessionSelection = useMemo(
() =>
resolveSessionSelection({
explicitSessionId: activeSessionId,
taskSessionId: selectedForSession?.activeSessionId ?? null,
sessions: sessionRows.map((session) => ({
id: session.id,
agent: session.agent,
agentSessionId: session.sessionId ?? "",
lastConnectionId: "",
createdAt: 0,
status: session.status,
})),
}),
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
);
const resolvedSessionId = sessionSelection.sessionId;
const staleSessionId = sessionSelection.staleSessionId;
const sessionState = useInterest(
interestManager,
"session",
selectedForSession && resolvedSessionId
? {
workspaceId,
repoId: selectedForSession.repoId,
taskId: selectedForSession.id,
sessionId: resolvedSessionId,
}
: null,
);
const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRows]);
const isPendingProvision = selectedSessionSummary?.status === "pending_provision";
const isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create";
const isSessionError = selectedSessionSummary?.status === "error";
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
const devPanelFocusedTask = useMemo(() => {
if (repoOverviewMode) {
return null;
}
const task = selectedForSession ?? selectedSummary;
if (!task) {
return null;
}
return {
id: task.id,
repoId: task.repoId,
title: task.title,
status: task.status,
runtimeStatus: selectedForSession?.runtimeStatus ?? null,
statusMessage: selectedForSession?.statusMessage ?? null,
branch: task.branch ?? null,
activeSandboxId: selectedForSession?.activeSandboxId ?? null,
activeSessionId: selectedForSession?.activeSessionId ?? null,
sandboxes: selectedForSession?.sandboxes ?? [],
sessions: selectedForSession?.sessionsSummary ?? [],
};
}, [repoOverviewMode, selectedForSession, selectedSummary]);
const devPanelSnapshot = useMemo(
(): TaskWorkbenchSnapshot => ({
workspaceId,
repos: repos.map((repo) => ({ id: repo.id, label: repo.label })),
projects: [],
tasks: rows.map((task) => ({
id: task.id,
repoId: task.repoId,
title: task.title,
status: task.status,
runtimeStatus: selectedForSession?.id === task.id ? selectedForSession.runtimeStatus : undefined,
statusMessage: selectedForSession?.id === task.id ? selectedForSession.statusMessage : null,
repoName: task.repoName,
updatedAtMs: task.updatedAtMs,
branch: task.branch ?? null,
pullRequest: task.pullRequest,
tabs: task.sessionsSummary.map((session) => ({
...session,
draft: {
text: "",
attachments: [],
updatedAtMs: null,
},
transcript: [],
})),
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
activeSandboxId: selectedForSession?.id === task.id ? selectedForSession.activeSandboxId : null,
})),
}),
[repos, rows, selectedForSession, workspaceId],
);
const startSessionFromTask = 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 () => startSessionFromTask(),
onSuccess: (session) => {
setActiveSessionId(session.id);
},
});
const ensureSessionForPrompt = async (): Promise<string> => {
if (resolvedSessionId) {
return resolvedSessionId;
}
const created = await startSessionFromTask();
setActiveSessionId(created.id);
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: () => {
setDraft("");
},
});
const transcript = buildTranscript(sessionState.data?.transcript ?? []);
const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0;
const createTask = 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.createTask({
workspaceId,
repoId,
task,
agentType: newAgentType,
explicitTitle: draftTitle || undefined,
explicitBranchName: createOnBranch ? undefined : draftBranchName || undefined,
onBranch: createOnBranch ?? undefined,
});
},
onSuccess: async (task) => {
setCreateError(null);
setNewTask("");
setNewTitle("");
setNewBranchName("");
setCreateOnBranch(null);
setCreateTaskOpen(false);
await navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
taskId: task.taskId,
},
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);
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 repoOverviewQuery.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}`);
}
setCreateTaskOpen(true);
};
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [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.sessionName} (${session.status})` })),
[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>
{workspaceState.status === "loading" ? (
<>
<Skeleton rows={3} height="72px" />
</>
) : null}
{workspaceState.status !== "loading" && 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.repoLabel}
</Link>
<div
className={css({
display: "flex",
flexDirection: "column",
gap: "0",
})}
>
{group.tasks
.filter((task) => task.status !== "archived" || task.id === selectedSummary?.id)
.map((task) => {
const isActive = !repoOverviewMode && task.id === selectedSummary?.id;
return (
<Link
key={task.id}
to="/workspaces/$workspaceId/tasks/$taskId"
params={{ workspaceId, taskId: task.id }}
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)" : task.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">
{task.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" } } }}
>
{task.branch ?? "Determining branch..."}
</ParagraphSmall>
<StatusPill kind={statusKind(task.status)}>{task.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);
setCreateTaskOpen(true);
}}
data-testid={group.repoId === createRepoId ? "task-create-open" : `task-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.taskId ? "positive" : "warning"}>{branch.taskId ? "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.taskId ? (
<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 ? (
<HeaderStatusPill
status={deriveHeaderStatus(
taskRuntimeStatus ?? selectedForSession.status,
selectedForSession.statusMessage ?? null,
selectedSessionSummary?.status ?? null,
selectedSessionSummary?.errorMessage ?? null,
Boolean(activeSandbox?.sandboxId),
)}
/>
) : null}
</div>
{selectedForSession && !resolvedSessionId ? (
<Button
size="compact"
onClick={() => {
void createSession.mutateAsync();
}}
disabled={createSession.isPending || !canStartSession}
>
{staleSessionId ? "Start New Session" : "Start Session"}
</Button>
) : null}
</div>
{selectedForSession ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary" data-testid="task-runtime-state">
{taskStateSummary}
</ParagraphSmall>
) : null}
</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("task-session-select")}
/>
</div>
) : null}
</div>
<div
className={css({
minHeight: 0,
flex: 1,
overflowY: "auto",
padding: theme.sizing.scale400,
backgroundColor: theme.colors.backgroundPrimary,
})}
>
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
{selectedSessionSummary && (isPendingProvision || isPendingSessionCreate) ? (
<div
className={css({
display: "flex",
flexDirection: "column",
gap: theme.sizing.scale300,
padding: theme.sizing.scale500,
border: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundSecondary,
marginBottom: theme.sizing.scale400,
})}
>
<LabelSmall marginTop="0" marginBottom="0">
{shouldUseTaskStateEmptyState ? taskStatusState.title : isPendingProvision ? "Provisioning sandbox..." : "Creating session..."}
</LabelSmall>
<Skeleton rows={1} height="32px" />
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{shouldUseTaskStateEmptyState
? taskStateSummary
: (selectedForSession?.statusMessage ??
(isPendingProvision ? "The task is still provisioning." : "The session is being created."))}
</ParagraphSmall>
</div>
) : null}
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
<EmptyState testId="session-transcript-empty">
{shouldUseTaskStateEmptyState
? taskStateSummary
: isPendingProvision
? (selectedForSession.statusMessage ?? "Provisioning sandbox...")
: isPendingSessionCreate
? "Creating session..."
: isSessionError
? (selectedSessionSummary?.errorMessage ?? "Session failed to start.")
: !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 || isPendingProvision || isPendingSessionCreate || isSessionError}
overrides={textareaTestIdOverrides("task-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 ||
isPendingProvision ||
isPendingSessionCreate ||
isSessionError ||
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.taskTitle ?? selectedBranchOverview.taskId ?? "-"} />
</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="State" value={taskRuntimeStatus ?? "-"} mono />
<MetaRow label="State detail" value={taskStatusState.detail} />
<MetaRow label="Task" value={selectedForSession.id} 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.branch ?? "-"} 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>
{taskRuntimeStatus === "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">
Task reported an error state
</LabelSmall>
</div>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{taskStatusState.detail}
</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={createTaskOpen}
onClose={() => {
setCreateTaskOpen(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("task-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={() => {
setCreateTaskOpen(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("task-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("task-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("task-create-title")}
/>
</div>
<div>
<LabelXSmall color="contentSecondary" marginBottom="scale200">
Branch
</LabelXSmall>
{createOnBranch ? (
<Input value={createOnBranch} disabled overrides={inputTestIdOverrides("task-create-branch")} />
) : (
<Input
placeholder="Branch name (optional)"
value={newBranchName}
onChange={(event) => setNewBranchName(event.target.value)}
overrides={inputTestIdOverrides("task-create-branch")}
/>
)}
</div>
{createError ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="task-create-error">
{createError}
</ParagraphSmall>
) : null}
</div>
</ModalBody>
<ModalFooter>
<Button
kind="tertiary"
onClick={() => {
setCreateTaskOpen(false);
setCreateOnBranch(null);
}}
>
Cancel
</Button>
<Button
disabled={!canCreateTask || createTask.isPending}
onClick={() => {
setCreateError(null);
void createTask.mutateAsync();
}}
data-testid="task-create-submit"
>
{createTask.isPending ? "Creating..." : "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>
{showDevPanel ? <DevPanel workspaceId={workspaceId} snapshot={devPanelSnapshot} focusedTask={devPanelFocusedTask} /> : null}
</AppShell>
);
}