mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 16:02:42 +00:00
* 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>
2018 lines
77 KiB
TypeScript
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>
|
|
);
|
|
}
|