mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 14:02:55 +00:00
* feat: modernize chat UI and rename handoff to task - Remove agent message bubbles, keep user bubbles (right-aligned) - Rename "Handoffs" to "Tasks" with ListChecks icon in sidebar - Move model picker inside composer, add renderFooter to ChatComposer SDK - Make project sections collapsible with hover-only chevrons - Remove divider between chat and composer - Update model picker chevron to flip on open/close - Replace all user-visible "handoff" strings with "task" across frontend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: real org mock data, model picker styling, project icons, task minutes indicator - Replace fake acme/* mock data with real rivet-dev GitHub org repos and PRs - Fix model picker popover: dark gray surface with backdrop blur instead of pure black - Add colored letter icons to project section headers (swap to chevron on hover) - Add "847 min used" indicator in transcript header - Rename browser tab title from OpenHandoff to Foundry - Reduce transcript header title font weight to 500 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1937 lines
73 KiB
TypeScript
1937 lines
73 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@openhandoff/shared";
|
|
import { groupHandoffStatus, type SandboxSessionEventRecord } from "@openhandoff/client";
|
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { Link, useNavigate } from "@tanstack/react-router";
|
|
import { Button } from "baseui/button";
|
|
import { Input } from "baseui/input";
|
|
import { Modal, ModalBody, ModalFooter, ModalHeader } from "baseui/modal";
|
|
import { Select, type OnChangeParams, type Option, type Value } from "baseui/select";
|
|
import { Skeleton } from "baseui/skeleton";
|
|
import { Tag } from "baseui/tag";
|
|
import { Textarea } from "baseui/textarea";
|
|
import { StyledDivider } from "baseui/divider";
|
|
import { styled, useStyletron } from "baseui";
|
|
import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography";
|
|
import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal, Shuffle } from "lucide-react";
|
|
import { formatDiffStat } from "../features/handoffs/model";
|
|
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
|
|
import { backendClient } from "../lib/backend";
|
|
|
|
interface WorkspaceDashboardProps {
|
|
workspaceId: string;
|
|
selectedHandoffId?: string;
|
|
selectedRepoId?: string;
|
|
}
|
|
|
|
type RepoOverviewFilter = "active" | "archived" | "unmapped" | "all";
|
|
type StatusTagKind = "neutral" | "positive" | "warning" | "negative";
|
|
type SelectItem = Readonly<{ id: string; label: string; disabled?: boolean }>;
|
|
|
|
const AppShell = styled("main", ({ $theme }) => ({
|
|
minHeight: "100dvh",
|
|
backgroundColor: $theme.colors.backgroundPrimary,
|
|
}));
|
|
|
|
const DashboardGrid = styled("div", ({ $theme }) => ({
|
|
display: "grid",
|
|
gap: "1px",
|
|
minHeight: "100dvh",
|
|
backgroundColor: $theme.colors.borderOpaque,
|
|
gridTemplateColumns: "minmax(0, 1fr)",
|
|
"@media screen and (min-width: 960px)": {
|
|
gridTemplateColumns: "260px minmax(0, 1fr)",
|
|
},
|
|
"@media screen and (min-width: 1480px)": {
|
|
gridTemplateColumns: "260px minmax(0, 1fr) 280px",
|
|
},
|
|
}));
|
|
|
|
const Panel = styled("section", ({ $theme }) => ({
|
|
minHeight: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: $theme.colors.backgroundSecondary,
|
|
overflow: "hidden",
|
|
}));
|
|
|
|
const PanelHeader = styled("div", ({ $theme }) => ({
|
|
padding: "10px 12px",
|
|
borderBottom: `1px solid ${$theme.colors.borderOpaque}`,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "8px",
|
|
}));
|
|
|
|
const ScrollBody = styled("div", ({ $theme }) => ({
|
|
minHeight: 0,
|
|
flex: 1,
|
|
overflowY: "auto",
|
|
padding: "10px 12px",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "8px",
|
|
}));
|
|
|
|
const DetailRail = styled("aside", ({ $theme }) => ({
|
|
minHeight: 0,
|
|
display: "none",
|
|
backgroundColor: $theme.colors.backgroundSecondary,
|
|
overflow: "hidden",
|
|
"@media screen and (min-width: 1480px)": {
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
},
|
|
}));
|
|
|
|
const FILTER_OPTIONS: SelectItem[] = [
|
|
{ id: "active", label: "Active + Unmapped" },
|
|
{ id: "archived", label: "Archived Tasks" },
|
|
{ id: "unmapped", label: "Unmapped Only" },
|
|
{ id: "all", label: "All Branches" },
|
|
];
|
|
|
|
const AGENT_OPTIONS: SelectItem[] = [
|
|
{ id: "codex", label: "codex" },
|
|
{ id: "claude", label: "claude" },
|
|
];
|
|
|
|
function statusKind(status: HandoffSummary["status"]): StatusTagKind {
|
|
const group = groupHandoffStatus(status);
|
|
if (group === "running") return "positive";
|
|
if (group === "queued") return "warning";
|
|
if (group === "error") return "negative";
|
|
return "neutral";
|
|
}
|
|
|
|
function normalizeAgent(agent: string | null): AgentType | undefined {
|
|
if (agent === "claude" || agent === "codex") {
|
|
return agent;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function formatTime(value: number): string {
|
|
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
|
|
function formatRelativeAge(value: number): string {
|
|
const deltaSeconds = Math.max(0, Math.floor((Date.now() - value) / 1000));
|
|
if (deltaSeconds < 60) return `${deltaSeconds}s`;
|
|
const minutes = Math.floor(deltaSeconds / 60);
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d`;
|
|
}
|
|
|
|
function branchTestIdToken(value: string): string {
|
|
const token = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "");
|
|
return token || "branch";
|
|
}
|
|
|
|
function useSessionEvents(
|
|
handoff: HandoffRecord | null,
|
|
sessionId: string | null,
|
|
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
|
|
return useQuery({
|
|
queryKey: ["workspace", handoff?.workspaceId ?? "", "session", handoff?.handoffId ?? "", sessionId ?? ""],
|
|
enabled: Boolean(handoff?.activeSandboxId && sessionId),
|
|
refetchInterval: 2_500,
|
|
queryFn: async () => {
|
|
if (!handoff?.activeSandboxId || !sessionId) {
|
|
return { items: [] };
|
|
}
|
|
return backendClient.listSandboxSessionEvents(handoff.workspaceId, handoff.providerId, handoff.activeSandboxId, {
|
|
sessionId,
|
|
limit: 120,
|
|
});
|
|
},
|
|
});
|
|
}
|
|
|
|
function repoSummary(overview: RepoOverview | undefined): {
|
|
total: number;
|
|
mapped: number;
|
|
unmapped: number;
|
|
conflicts: number;
|
|
needsRestack: number;
|
|
openPrs: number;
|
|
} {
|
|
if (!overview) {
|
|
return {
|
|
total: 0,
|
|
mapped: 0,
|
|
unmapped: 0,
|
|
conflicts: 0,
|
|
needsRestack: 0,
|
|
openPrs: 0,
|
|
};
|
|
}
|
|
|
|
let mapped = 0;
|
|
let conflicts = 0;
|
|
let needsRestack = 0;
|
|
let openPrs = 0;
|
|
|
|
for (const row of overview.branches) {
|
|
if (row.handoffId) {
|
|
mapped += 1;
|
|
}
|
|
if (row.conflictsWithMain) {
|
|
conflicts += 1;
|
|
}
|
|
if (row.trackedInStack && row.parentBranch && row.hasUnpushed) {
|
|
needsRestack += 1;
|
|
}
|
|
if (row.prNumber && row.prState !== "MERGED" && row.prState !== "CLOSED") {
|
|
openPrs += 1;
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: overview.branches.length,
|
|
mapped,
|
|
unmapped: Math.max(0, overview.branches.length - mapped),
|
|
conflicts,
|
|
needsRestack,
|
|
openPrs,
|
|
};
|
|
}
|
|
|
|
function branchKind(row: RepoBranchRecord): StatusTagKind {
|
|
if (row.conflictsWithMain) {
|
|
return "negative";
|
|
}
|
|
if (row.prState === "OPEN" || row.prState === "DRAFT") {
|
|
return "warning";
|
|
}
|
|
if (row.prState === "MERGED") {
|
|
return "positive";
|
|
}
|
|
return "neutral";
|
|
}
|
|
|
|
function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean {
|
|
if (filter === "archived") {
|
|
return branch.handoffStatus === "archived";
|
|
}
|
|
if (filter === "unmapped") {
|
|
return branch.handoffId === null;
|
|
}
|
|
if (filter === "active") {
|
|
return branch.handoffStatus !== "archived";
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function selectValue(option: Option | null | undefined): Value {
|
|
return option ? [option] : [];
|
|
}
|
|
|
|
function optionId(value: Value): string | null {
|
|
const id = value[0]?.id;
|
|
if (typeof id === "string") return id;
|
|
if (typeof id === "number") return String(id);
|
|
return null;
|
|
}
|
|
|
|
function createOption(item: SelectItem): Option {
|
|
return {
|
|
id: item.id,
|
|
label: item.label,
|
|
disabled: item.disabled,
|
|
};
|
|
}
|
|
|
|
function inputTestIdOverrides(testId?: string) {
|
|
return testId
|
|
? {
|
|
Input: {
|
|
props: {
|
|
"data-testid": testId,
|
|
},
|
|
},
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
function textareaTestIdOverrides(testId?: string) {
|
|
return testId
|
|
? {
|
|
Input: {
|
|
props: {
|
|
"data-testid": testId,
|
|
},
|
|
},
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
function selectTestIdOverrides(testId?: string) {
|
|
return testId
|
|
? {
|
|
ControlContainer: {
|
|
props: {
|
|
"data-testid": testId,
|
|
},
|
|
},
|
|
}
|
|
: undefined;
|
|
}
|
|
|
|
function EmptyState({ children, testId }: { children: string; testId?: string }) {
|
|
return (
|
|
<div
|
|
data-testid={testId}
|
|
style={{
|
|
padding: "12px",
|
|
borderRadius: "0",
|
|
border: "1px dashed rgba(166, 176, 191, 0.24)",
|
|
background: "rgba(255, 255, 255, 0.02)",
|
|
}}
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
{children}
|
|
</ParagraphSmall>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusPill({ children, kind }: { children: ReactNode; kind: StatusTagKind }) {
|
|
return (
|
|
<Tag
|
|
closeable={false}
|
|
kind={kind}
|
|
hierarchy="secondary"
|
|
size="small"
|
|
overrides={{
|
|
Root: {
|
|
style: {
|
|
borderRadius: "2px",
|
|
minHeight: "20px",
|
|
fontFamily: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
|
letterSpacing: "0.02em",
|
|
},
|
|
},
|
|
}}
|
|
>
|
|
{children}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
function MetaRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
gap: "12px",
|
|
alignItems: "flex-start",
|
|
}}
|
|
>
|
|
<LabelXSmall color="contentSecondary">{label}</LabelXSmall>
|
|
{mono ? (
|
|
<MonoLabelSmall marginTop="0" marginBottom="0" overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}>
|
|
{value}
|
|
</MonoLabelSmall>
|
|
) : (
|
|
<LabelSmall marginTop="0" marginBottom="0" overrides={{ Block: { style: { textAlign: "right", wordBreak: "break-word" } } }}>
|
|
{value}
|
|
</LabelSmall>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRepoId }: WorkspaceDashboardProps) {
|
|
const [css, theme] = useStyletron();
|
|
const navigate = useNavigate();
|
|
const repoOverviewMode = typeof selectedRepoId === "string" && selectedRepoId.length > 0;
|
|
|
|
const [draft, setDraft] = useState("");
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
|
const [createRepoId, setCreateRepoId] = useState("");
|
|
const [newTask, setNewTask] = useState("");
|
|
const [newTitle, setNewTitle] = useState("");
|
|
const [newBranchName, setNewBranchName] = useState("");
|
|
const [createOnBranch, setCreateOnBranch] = useState<string | null>(null);
|
|
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
|
const [createHandoffOpen, setCreateHandoffOpen] = useState(false);
|
|
const [addRepoRemote, setAddRepoRemote] = useState("");
|
|
const [addRepoError, setAddRepoError] = useState<string | null>(null);
|
|
const [stackActionError, setStackActionError] = useState<string | null>(null);
|
|
const [stackActionMessage, setStackActionMessage] = useState<string | null>(null);
|
|
const [selectedOverviewBranch, setSelectedOverviewBranch] = useState<string | null>(null);
|
|
const [overviewFilter, setOverviewFilter] = useState<RepoOverviewFilter>("active");
|
|
const [reparentBranchName, setReparentBranchName] = useState<string | null>(null);
|
|
const [reparentParentBranch, setReparentParentBranch] = useState("");
|
|
const [newAgentType, setNewAgentType] = useState<AgentType>(() => {
|
|
try {
|
|
const raw = globalThis.localStorage?.getItem("hf.settings.agentType");
|
|
return raw === "claude" || raw === "codex" ? raw : "codex";
|
|
} catch {
|
|
return "codex";
|
|
}
|
|
});
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
|
|
const handoffsQuery = useQuery({
|
|
queryKey: ["workspace", workspaceId, "handoffs"],
|
|
queryFn: async () => backendClient.listHandoffs(workspaceId),
|
|
refetchInterval: 2_500,
|
|
});
|
|
|
|
const handoffDetailQuery = useQuery({
|
|
queryKey: ["workspace", workspaceId, "handoff-detail", selectedHandoffId],
|
|
enabled: Boolean(selectedHandoffId && !repoOverviewMode),
|
|
refetchInterval: 2_500,
|
|
queryFn: async () => {
|
|
if (!selectedHandoffId) {
|
|
throw new Error("No task selected");
|
|
}
|
|
return backendClient.getHandoff(workspaceId, selectedHandoffId);
|
|
},
|
|
});
|
|
|
|
const reposQuery = useQuery({
|
|
queryKey: ["workspace", workspaceId, "repos"],
|
|
queryFn: async () => backendClient.listRepos(workspaceId),
|
|
refetchInterval: 10_000,
|
|
});
|
|
|
|
const repos = reposQuery.data ?? [];
|
|
const activeRepoId = selectedRepoId ?? createRepoId;
|
|
|
|
const repoOverviewQuery = useQuery({
|
|
queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId],
|
|
enabled: Boolean(repoOverviewMode && activeRepoId),
|
|
refetchInterval: 5_000,
|
|
queryFn: async () => {
|
|
if (!activeRepoId) {
|
|
throw new Error("No repo selected");
|
|
}
|
|
return backendClient.getRepoOverview(workspaceId, activeRepoId);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (repoOverviewMode && selectedRepoId) {
|
|
setCreateRepoId(selectedRepoId);
|
|
return;
|
|
}
|
|
if (!createRepoId && repos.length > 0) {
|
|
setCreateRepoId(repos[0]!.repoId);
|
|
}
|
|
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
globalThis.localStorage?.setItem("hf.settings.agentType", newAgentType);
|
|
} catch {
|
|
// ignore storage failures
|
|
}
|
|
}, [newAgentType]);
|
|
|
|
const rows = handoffsQuery.data ?? [];
|
|
const repoGroups = useMemo(() => {
|
|
const byRepo = new Map<string, HandoffSummary[]>();
|
|
for (const row of rows) {
|
|
const bucket = byRepo.get(row.repoId);
|
|
if (bucket) {
|
|
bucket.push(row);
|
|
} else {
|
|
byRepo.set(row.repoId, [row]);
|
|
}
|
|
}
|
|
|
|
return repos
|
|
.map((repo) => {
|
|
const handoffs = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
const latestHandoffAt = handoffs[0]?.updatedAt ?? 0;
|
|
return {
|
|
repoId: repo.repoId,
|
|
repoRemote: repo.remoteUrl,
|
|
latestActivityAt: Math.max(repo.updatedAt, latestHandoffAt),
|
|
handoffs,
|
|
};
|
|
})
|
|
.sort((a, b) => {
|
|
if (a.latestActivityAt !== b.latestActivityAt) {
|
|
return b.latestActivityAt - a.latestActivityAt;
|
|
}
|
|
return a.repoRemote.localeCompare(b.repoRemote);
|
|
});
|
|
}, [repos, rows]);
|
|
|
|
const selectedSummary = useMemo(() => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null, [rows, selectedHandoffId]);
|
|
|
|
const selectedForSession = repoOverviewMode ? null : (handoffDetailQuery.data ?? null);
|
|
|
|
const activeSandbox = useMemo(() => {
|
|
if (!selectedForSession) return null;
|
|
const byActive = selectedForSession.activeSandboxId
|
|
? (selectedForSession.sandboxes.find((sandbox) => sandbox.sandboxId === selectedForSession.activeSandboxId) ?? null)
|
|
: null;
|
|
return byActive ?? selectedForSession.sandboxes[0] ?? null;
|
|
}, [selectedForSession]);
|
|
|
|
useEffect(() => {
|
|
if (!repoOverviewMode && !selectedHandoffId && rows.length > 0) {
|
|
void navigate({
|
|
to: "/workspaces/$workspaceId/handoffs/$handoffId",
|
|
params: {
|
|
workspaceId,
|
|
handoffId: rows[0]!.handoffId,
|
|
},
|
|
search: { sessionId: undefined },
|
|
replace: true,
|
|
});
|
|
}
|
|
}, [navigate, repoOverviewMode, rows, selectedHandoffId, workspaceId]);
|
|
|
|
useEffect(() => {
|
|
setActiveSessionId(null);
|
|
setDraft("");
|
|
}, [selectedForSession?.handoffId]);
|
|
|
|
const sessionsQuery = useQuery({
|
|
queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"],
|
|
enabled: Boolean(activeSandbox?.sandboxId && selectedForSession),
|
|
refetchInterval: 3_000,
|
|
queryFn: async () => {
|
|
if (!activeSandbox?.sandboxId || !selectedForSession) {
|
|
return { items: [] };
|
|
}
|
|
return backendClient.listSandboxSessions(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, {
|
|
limit: 30,
|
|
});
|
|
},
|
|
});
|
|
|
|
const sessionRows = sessionsQuery.data?.items ?? [];
|
|
const sessionSelection = useMemo(
|
|
() =>
|
|
resolveSessionSelection({
|
|
explicitSessionId: activeSessionId,
|
|
handoffSessionId: selectedForSession?.activeSessionId ?? null,
|
|
sessions: sessionRows,
|
|
}),
|
|
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
|
|
);
|
|
const resolvedSessionId = sessionSelection.sessionId;
|
|
const staleSessionId = sessionSelection.staleSessionId;
|
|
const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId);
|
|
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
|
|
|
|
const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
|
|
if (!selectedForSession || !activeSandbox?.sandboxId) {
|
|
throw new Error("No sandbox is available for this task");
|
|
}
|
|
return backendClient.createSandboxSession({
|
|
workspaceId,
|
|
providerId: activeSandbox.providerId,
|
|
sandboxId: activeSandbox.sandboxId,
|
|
prompt: selectedForSession.task,
|
|
cwd: activeSandbox.cwd ?? undefined,
|
|
agent: normalizeAgent(selectedForSession.agentType),
|
|
});
|
|
};
|
|
|
|
const createSession = useMutation({
|
|
mutationFn: async () => startSessionFromHandoff(),
|
|
onSuccess: async (session) => {
|
|
setActiveSessionId(session.id);
|
|
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
|
},
|
|
});
|
|
|
|
const ensureSessionForPrompt = async (): Promise<string> => {
|
|
if (resolvedSessionId) {
|
|
return resolvedSessionId;
|
|
}
|
|
const created = await startSessionFromHandoff();
|
|
setActiveSessionId(created.id);
|
|
await sessionsQuery.refetch();
|
|
return created.id;
|
|
};
|
|
|
|
const sendPrompt = useMutation({
|
|
mutationFn: async (prompt: string) => {
|
|
if (!selectedForSession || !activeSandbox?.sandboxId) {
|
|
throw new Error("No sandbox is available for this task");
|
|
}
|
|
const sessionId = await ensureSessionForPrompt();
|
|
await backendClient.sendSandboxPrompt({
|
|
workspaceId,
|
|
providerId: activeSandbox.providerId,
|
|
sandboxId: activeSandbox.sandboxId,
|
|
sessionId,
|
|
prompt,
|
|
});
|
|
},
|
|
onSuccess: async () => {
|
|
setDraft("");
|
|
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
|
|
},
|
|
});
|
|
|
|
const transcript = buildTranscript(eventsQuery.data?.items ?? []);
|
|
const canCreateHandoff = createRepoId.trim().length > 0 && newTask.trim().length > 0;
|
|
|
|
const createHandoff = useMutation({
|
|
mutationFn: async () => {
|
|
const repoId = createRepoId.trim();
|
|
const task = newTask.trim();
|
|
if (!repoId || !task) {
|
|
throw new Error("Repository and task are required");
|
|
}
|
|
|
|
const draftTitle = newTitle.trim();
|
|
const draftBranchName = newBranchName.trim();
|
|
|
|
return backendClient.createHandoff({
|
|
workspaceId,
|
|
repoId,
|
|
task,
|
|
agentType: newAgentType,
|
|
explicitTitle: draftTitle || undefined,
|
|
explicitBranchName: createOnBranch ? undefined : draftBranchName || undefined,
|
|
onBranch: createOnBranch ?? undefined,
|
|
});
|
|
},
|
|
onSuccess: async (handoff) => {
|
|
setCreateError(null);
|
|
setNewTask("");
|
|
setNewTitle("");
|
|
setNewBranchName("");
|
|
setCreateOnBranch(null);
|
|
setCreateHandoffOpen(false);
|
|
await handoffsQuery.refetch();
|
|
await repoOverviewQuery.refetch();
|
|
await navigate({
|
|
to: "/workspaces/$workspaceId/handoffs/$handoffId",
|
|
params: {
|
|
workspaceId,
|
|
handoffId: handoff.handoffId,
|
|
},
|
|
search: { sessionId: undefined },
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
setCreateError(error instanceof Error ? error.message : String(error));
|
|
},
|
|
});
|
|
|
|
const addRepo = useMutation({
|
|
mutationFn: async (remoteUrl: string) => {
|
|
const trimmed = remoteUrl.trim();
|
|
if (!trimmed) {
|
|
throw new Error("Remote URL is required");
|
|
}
|
|
return backendClient.addRepo(workspaceId, trimmed);
|
|
},
|
|
onSuccess: async (created) => {
|
|
setAddRepoError(null);
|
|
setAddRepoRemote("");
|
|
setAddRepoOpen(false);
|
|
await reposQuery.refetch();
|
|
setCreateRepoId(created.repoId);
|
|
if (repoOverviewMode) {
|
|
await navigate({
|
|
to: "/workspaces/$workspaceId/repos/$repoId",
|
|
params: {
|
|
workspaceId,
|
|
repoId: created.repoId,
|
|
},
|
|
});
|
|
}
|
|
},
|
|
onError: (error) => {
|
|
setAddRepoError(error instanceof Error ? error.message : String(error));
|
|
},
|
|
});
|
|
|
|
const runStackAction = useMutation({
|
|
mutationFn: async (input: { action: RepoStackAction; branchName?: string; parentBranch?: string }) => {
|
|
if (!activeRepoId) {
|
|
throw new Error("No repository selected");
|
|
}
|
|
return backendClient.runRepoStackAction({
|
|
workspaceId,
|
|
repoId: activeRepoId,
|
|
action: input.action,
|
|
branchName: input.branchName,
|
|
parentBranch: input.parentBranch,
|
|
});
|
|
},
|
|
onSuccess: async (result) => {
|
|
if (result.executed) {
|
|
setStackActionError(null);
|
|
setStackActionMessage(result.message);
|
|
} else {
|
|
setStackActionMessage(null);
|
|
setStackActionError(result.message);
|
|
}
|
|
await Promise.all([repoOverviewQuery.refetch(), handoffsQuery.refetch()]);
|
|
},
|
|
onError: (error) => {
|
|
setStackActionMessage(null);
|
|
setStackActionError(error instanceof Error ? error.message : String(error));
|
|
},
|
|
});
|
|
|
|
const openCreateFromBranch = (repoId: string, branchName: string): void => {
|
|
setCreateRepoId(repoId);
|
|
setCreateOnBranch(branchName);
|
|
setNewBranchName("");
|
|
setCreateError(null);
|
|
if (!newTask.trim()) {
|
|
setNewTask(`Continue work on ${branchName}`);
|
|
}
|
|
setCreateHandoffOpen(true);
|
|
};
|
|
|
|
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
|
|
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
|
|
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
|
|
const selectedFilterOption = useMemo(
|
|
() => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!),
|
|
[overviewFilter],
|
|
);
|
|
const sessionOptions = useMemo(
|
|
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.id} (${session.status ?? "running"})` })),
|
|
[sessionRows],
|
|
);
|
|
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;
|
|
|
|
const overview = repoOverviewQuery.data;
|
|
const overviewStats = repoSummary(overview);
|
|
const stackActionsEnabled = Boolean(overview?.stackAvailable) && !runStackAction.isPending;
|
|
const filteredOverviewBranches = useMemo(() => {
|
|
if (!overview?.branches?.length) {
|
|
return [];
|
|
}
|
|
return overview.branches.filter((branch) => matchesOverviewFilter(branch, overviewFilter));
|
|
}, [overview, overviewFilter]);
|
|
const selectedBranchOverview = useMemo(() => {
|
|
if (!filteredOverviewBranches.length) {
|
|
return null;
|
|
}
|
|
if (!selectedOverviewBranch) {
|
|
return filteredOverviewBranches[0] ?? null;
|
|
}
|
|
return filteredOverviewBranches.find((row) => row.branchName === selectedOverviewBranch) ?? filteredOverviewBranches[0] ?? null;
|
|
}, [filteredOverviewBranches, selectedOverviewBranch]);
|
|
|
|
useEffect(() => {
|
|
if (!filteredOverviewBranches.length) {
|
|
setSelectedOverviewBranch(null);
|
|
return;
|
|
}
|
|
if (!selectedOverviewBranch || !filteredOverviewBranches.some((row) => row.branchName === selectedOverviewBranch)) {
|
|
setSelectedOverviewBranch(filteredOverviewBranches[0]?.branchName ?? null);
|
|
}
|
|
}, [filteredOverviewBranches, selectedOverviewBranch]);
|
|
|
|
const handleReparentSubmit = (): void => {
|
|
if (!reparentBranchName || !reparentParentBranch.trim()) {
|
|
return;
|
|
}
|
|
setStackActionError(null);
|
|
void runStackAction
|
|
.mutateAsync({
|
|
action: "reparent_branch",
|
|
branchName: reparentBranchName,
|
|
parentBranch: reparentParentBranch.trim(),
|
|
})
|
|
.then(() => {
|
|
setReparentBranchName(null);
|
|
setReparentParentBranch("");
|
|
})
|
|
.catch(() => {
|
|
// mutation state is surfaced above
|
|
});
|
|
};
|
|
|
|
const modalOverrides = useMemo(
|
|
() => ({
|
|
Dialog: {
|
|
style: {
|
|
borderRadius: "0",
|
|
backgroundColor: theme.colors.backgroundSecondary,
|
|
border: `1px solid ${theme.colors.borderOpaque}`,
|
|
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.45)",
|
|
},
|
|
},
|
|
Close: {
|
|
style: {
|
|
borderRadius: "0",
|
|
},
|
|
},
|
|
}),
|
|
[theme.colors.backgroundSecondary, theme.colors.borderOpaque],
|
|
);
|
|
|
|
return (
|
|
<AppShell>
|
|
<DashboardGrid>
|
|
<Panel>
|
|
<PanelHeader>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.sizing.scale400,
|
|
})}
|
|
>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "flex-start",
|
|
gap: "2px",
|
|
})}
|
|
>
|
|
<LabelXSmall color="contentTertiary">Workspace</LabelXSmall>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<FolderGit2 size={14} />
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
{workspaceId}
|
|
</HeadingXSmall>
|
|
</div>
|
|
</div>
|
|
|
|
<Button
|
|
size="compact"
|
|
kind="secondary"
|
|
onClick={() => {
|
|
setAddRepoError(null);
|
|
setAddRepoOpen(true);
|
|
}}
|
|
data-testid="repo-add-open"
|
|
>
|
|
Add Repo
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
className={css({
|
|
paddingTop: theme.sizing.scale200,
|
|
borderTop: `1px solid ${theme.colors.borderOpaque}`,
|
|
})}
|
|
>
|
|
<LabelXSmall color="contentSecondary">Tasks</LabelXSmall>
|
|
</div>
|
|
</PanelHeader>
|
|
|
|
<ScrollBody>
|
|
{handoffsQuery.isLoading ? (
|
|
<>
|
|
<Skeleton rows={3} height="72px" />
|
|
</>
|
|
) : null}
|
|
|
|
{!handoffsQuery.isLoading && repoGroups.length === 0 ? (
|
|
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
|
|
) : null}
|
|
|
|
{repoGroups.map((group) => (
|
|
<section
|
|
key={group.repoId}
|
|
className={css({
|
|
marginLeft: "-12px",
|
|
marginRight: "-12px",
|
|
paddingBottom: "8px",
|
|
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
|
|
})}
|
|
>
|
|
<Link
|
|
to="/workspaces/$workspaceId/repos/$repoId"
|
|
params={{ workspaceId, repoId: group.repoId }}
|
|
className={css({
|
|
display: "block",
|
|
textDecoration: "none",
|
|
fontSize: theme.typography.LabelSmall.fontSize,
|
|
fontWeight: 600,
|
|
lineHeight: "1.35",
|
|
color: theme.colors.contentSecondary,
|
|
padding: "10px 12px 8px",
|
|
wordBreak: "break-word",
|
|
":hover": {
|
|
color: theme.colors.contentPrimary,
|
|
backgroundColor: "rgba(255, 255, 255, 0.02)",
|
|
},
|
|
})}
|
|
data-testid={group.repoId === activeRepoId ? "repo-overview-open" : `repo-overview-open-${group.repoId}`}
|
|
>
|
|
{group.repoRemote}
|
|
</Link>
|
|
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "0",
|
|
})}
|
|
>
|
|
{group.handoffs
|
|
.filter((handoff) => handoff.status !== "archived" || handoff.handoffId === selectedSummary?.handoffId)
|
|
.map((handoff) => {
|
|
const isActive = !repoOverviewMode && handoff.handoffId === selectedSummary?.handoffId;
|
|
return (
|
|
<Link
|
|
key={handoff.handoffId}
|
|
to="/workspaces/$workspaceId/handoffs/$handoffId"
|
|
params={{ workspaceId, handoffId: handoff.handoffId }}
|
|
search={{ sessionId: undefined }}
|
|
className={css({
|
|
display: "block",
|
|
textDecoration: "none",
|
|
borderLeft: `2px solid ${isActive ? theme.colors.primary : "transparent"}`,
|
|
borderTop: `1px solid ${theme.colors.borderOpaque}`,
|
|
backgroundColor: isActive
|
|
? "rgba(143, 180, 255, 0.08)"
|
|
: handoff.status === "archived"
|
|
? "rgba(255, 255, 255, 0.02)"
|
|
: "transparent",
|
|
padding: "10px 12px 10px 14px",
|
|
transition: "background-color 0.15s ease, border-color 0.15s ease",
|
|
":hover": {
|
|
backgroundColor: isActive ? "rgba(143, 180, 255, 0.1)" : "rgba(255, 255, 255, 0.03)",
|
|
},
|
|
})}
|
|
>
|
|
<LabelSmall marginTop="0" marginBottom="0">
|
|
{handoff.title ?? "Determining title..."}
|
|
</LabelSmall>
|
|
<div
|
|
className={css({
|
|
marginTop: "8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<ParagraphSmall
|
|
marginTop="0"
|
|
marginBottom="0"
|
|
color="contentSecondary"
|
|
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
|
|
>
|
|
{handoff.branchName ?? "Determining branch..."}
|
|
</ParagraphSmall>
|
|
<StatusPill kind={statusKind(handoff.status)}>{handoff.status}</StatusPill>
|
|
</div>
|
|
</Link>
|
|
);
|
|
})}
|
|
|
|
<Button
|
|
size="compact"
|
|
kind="tertiary"
|
|
overrides={{
|
|
BaseButton: {
|
|
style: {
|
|
justifyContent: "flex-start",
|
|
borderRadius: "0",
|
|
paddingLeft: "12px",
|
|
paddingRight: "12px",
|
|
},
|
|
},
|
|
}}
|
|
onClick={() => {
|
|
setCreateRepoId(group.repoId);
|
|
setCreateOnBranch(null);
|
|
setCreateError(null);
|
|
setCreateHandoffOpen(true);
|
|
}}
|
|
data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`}
|
|
>
|
|
Create Task
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
))}
|
|
</ScrollBody>
|
|
</Panel>
|
|
|
|
<Panel>
|
|
{repoOverviewMode ? (
|
|
<>
|
|
<PanelHeader>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.sizing.scale400,
|
|
flexWrap: "wrap",
|
|
})}
|
|
>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<GitBranch size={16} />
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
Repo Overview
|
|
</HeadingXSmall>
|
|
</div>
|
|
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
alignItems: "center",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<div className={css({ minWidth: "220px" })}>
|
|
<Select
|
|
options={FILTER_OPTIONS.map(createOption)}
|
|
value={selectValue(selectedFilterOption)}
|
|
clearable={false}
|
|
searchable={false}
|
|
size="compact"
|
|
onChange={(params: OnChangeParams) => {
|
|
const next = optionId(params.value) as RepoOverviewFilter | null;
|
|
if (next) {
|
|
setOverviewFilter(next);
|
|
}
|
|
}}
|
|
aria-label="Filter branches"
|
|
overrides={selectTestIdOverrides("repo-overview-filter")}
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
size="compact"
|
|
kind="secondary"
|
|
disabled={!stackActionsEnabled}
|
|
onClick={() => {
|
|
setStackActionError(null);
|
|
void runStackAction.mutateAsync({ action: "sync_repo" });
|
|
}}
|
|
data-testid="repo-stack-sync"
|
|
>
|
|
Sync Stack
|
|
</Button>
|
|
|
|
<Button
|
|
size="compact"
|
|
kind="secondary"
|
|
disabled={!stackActionsEnabled}
|
|
onClick={() => {
|
|
setStackActionError(null);
|
|
void runStackAction.mutateAsync({ action: "restack_repo" });
|
|
}}
|
|
data-testid="repo-stack-restack-all"
|
|
>
|
|
<span
|
|
className={css({
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: theme.sizing.scale200,
|
|
})}
|
|
>
|
|
<Shuffle size={14} />
|
|
Restack All
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "8px",
|
|
})}
|
|
>
|
|
<StatusPill kind="neutral">Branches {overviewStats.total}</StatusPill>
|
|
<StatusPill kind="positive">Mapped {overviewStats.mapped}</StatusPill>
|
|
<StatusPill kind="warning">Unmapped {overviewStats.unmapped}</StatusPill>
|
|
<StatusPill kind="negative">Conflicts {overviewStats.conflicts}</StatusPill>
|
|
<StatusPill kind="neutral">Open PRs {overviewStats.openPrs}</StatusPill>
|
|
<StatusPill kind="neutral">Needs restack {overviewStats.needsRestack}</StatusPill>
|
|
</div>
|
|
|
|
{overview && !overview.stackAvailable ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary" data-testid="repo-stack-unavailable">
|
|
git-spice is unavailable for this repo. Stack actions are disabled.
|
|
</ParagraphSmall>
|
|
) : null}
|
|
|
|
{stackActionError ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="repo-stack-error">
|
|
{stackActionError}
|
|
</ParagraphSmall>
|
|
) : null}
|
|
|
|
{stackActionMessage ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="positive" data-testid="repo-stack-message">
|
|
{stackActionMessage}
|
|
</ParagraphSmall>
|
|
) : null}
|
|
</PanelHeader>
|
|
|
|
<ScrollBody data-testid="repo-overview-center">
|
|
{repoOverviewQuery.isLoading ? <Skeleton rows={4} height="72px" /> : null}
|
|
|
|
{!repoOverviewQuery.isLoading && !overview ? <EmptyState>No repo overview is available yet.</EmptyState> : null}
|
|
|
|
{overview ? (
|
|
<div
|
|
className={css({
|
|
overflowX: "auto",
|
|
border: `1px solid ${theme.colors.borderOpaque}`,
|
|
})}
|
|
>
|
|
<div
|
|
className={css({
|
|
minWidth: "980px",
|
|
display: "grid",
|
|
gridTemplateColumns: "2fr 1.3fr 0.8fr 1fr 1fr 1.4fr",
|
|
})}
|
|
>
|
|
{["Branch", "Parent", "Ahead", "PR", "CI/Review", "Actions"].map((label) => (
|
|
<div
|
|
key={label}
|
|
className={css({
|
|
padding: `12px ${theme.sizing.scale400}`,
|
|
backgroundColor: theme.colors.backgroundTertiary,
|
|
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
|
|
})}
|
|
>
|
|
<LabelXSmall color="contentSecondary">{label}</LabelXSmall>
|
|
</div>
|
|
))}
|
|
|
|
{filteredOverviewBranches.length === 0 ? (
|
|
<div
|
|
className={css({
|
|
gridColumn: "1 / -1",
|
|
padding: theme.sizing.scale600,
|
|
})}
|
|
data-testid="repo-overview-filter-empty"
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
No branches match the selected filter.
|
|
</ParagraphSmall>
|
|
</div>
|
|
) : (
|
|
filteredOverviewBranches.map((branch) => {
|
|
const selectedRow = selectedBranchOverview?.branchName === branch.branchName;
|
|
const branchToken = branchTestIdToken(branch.branchName);
|
|
const rowClass = css({
|
|
display: "contents",
|
|
});
|
|
const cellClass = css({
|
|
padding: `${theme.sizing.scale400} ${theme.sizing.scale400}`,
|
|
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
|
|
backgroundColor: selectedRow ? "rgba(29, 111, 95, 0.08)" : theme.colors.backgroundSecondary,
|
|
fontSize: theme.typography.ParagraphSmall.fontSize,
|
|
cursor: "pointer",
|
|
});
|
|
return (
|
|
<div
|
|
key={branch.branchName}
|
|
className={rowClass}
|
|
onClick={() => setSelectedOverviewBranch(branch.branchName)}
|
|
data-testid={`repo-overview-row-${branchToken}`}
|
|
>
|
|
<div className={cellClass}>
|
|
<LabelSmall marginTop="0" marginBottom="0">
|
|
{branch.branchName}
|
|
</LabelSmall>
|
|
<div
|
|
className={css({
|
|
marginTop: "8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
flexWrap: "wrap",
|
|
gap: theme.sizing.scale200,
|
|
})}
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
{formatRelativeAge(branch.updatedAt)}
|
|
</ParagraphSmall>
|
|
<StatusPill kind={branch.handoffId ? "positive" : "warning"}>
|
|
{branch.handoffId ? "task" : "unmapped"}
|
|
</StatusPill>
|
|
{branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null}
|
|
</div>
|
|
</div>
|
|
<div className={cellClass}>{branch.parentBranch ?? "-"}</div>
|
|
<div className={cellClass}>{branch.hasUnpushed ? "yes" : "-"}</div>
|
|
<div className={cellClass}>
|
|
{branch.prNumber ? (
|
|
<a
|
|
href={branch.prUrl ?? undefined}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className={css({
|
|
color: theme.colors.contentPrimary,
|
|
})}
|
|
>
|
|
#{branch.prNumber} {branch.prState ?? "open"}
|
|
</a>
|
|
) : (
|
|
<span className={css({ color: theme.colors.contentSecondary })}>-</span>
|
|
)}
|
|
</div>
|
|
<div className={cellClass}>
|
|
{branch.ciStatus ?? "-"} / {branch.reviewStatus ?? "-"}
|
|
</div>
|
|
<div className={cellClass}>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: theme.sizing.scale200,
|
|
})}
|
|
>
|
|
<Button
|
|
size="compact"
|
|
kind="tertiary"
|
|
disabled={!stackActionsEnabled}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setStackActionError(null);
|
|
void runStackAction.mutateAsync({
|
|
action: "restack_subtree",
|
|
branchName: branch.branchName,
|
|
});
|
|
}}
|
|
data-testid={`repo-overview-restack-${branchToken}`}
|
|
>
|
|
Restack
|
|
</Button>
|
|
|
|
<Button
|
|
size="compact"
|
|
kind="tertiary"
|
|
disabled={!stackActionsEnabled}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setStackActionError(null);
|
|
void runStackAction.mutateAsync({
|
|
action: "rebase_branch",
|
|
branchName: branch.branchName,
|
|
});
|
|
}}
|
|
data-testid={`repo-overview-rebase-${branchToken}`}
|
|
>
|
|
Rebase
|
|
</Button>
|
|
|
|
<Button
|
|
size="compact"
|
|
kind="tertiary"
|
|
disabled={!stackActionsEnabled}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setReparentBranchName(branch.branchName);
|
|
setReparentParentBranch(branch.parentBranch ?? "main");
|
|
setStackActionError(null);
|
|
}}
|
|
data-testid={`repo-overview-reparent-${branchToken}`}
|
|
>
|
|
Reparent
|
|
</Button>
|
|
|
|
{!branch.handoffId ? (
|
|
<Button
|
|
size="compact"
|
|
kind="secondary"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
openCreateFromBranch(activeRepoId, branch.branchName);
|
|
}}
|
|
data-testid={`repo-overview-create-${branchToken}`}
|
|
>
|
|
Create Task
|
|
</Button>
|
|
) : null}
|
|
|
|
<StatusPill kind={branchKind(branch)}>{branch.conflictsWithMain ? "conflict" : "ok"}</StatusPill>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</ScrollBody>
|
|
</>
|
|
) : (
|
|
<>
|
|
<PanelHeader>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.sizing.scale400,
|
|
flexWrap: "wrap",
|
|
})}
|
|
>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: theme.sizing.scale300,
|
|
flexWrap: "wrap",
|
|
})}
|
|
>
|
|
<Bot size={16} />
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No task selected"}
|
|
</HeadingXSmall>
|
|
{selectedForSession ? <StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill> : null}
|
|
</div>
|
|
|
|
{selectedForSession && !resolvedSessionId ? (
|
|
<Button
|
|
size="compact"
|
|
onClick={() => {
|
|
void createSession.mutateAsync();
|
|
}}
|
|
disabled={createSession.isPending || !canStartSession}
|
|
>
|
|
{staleSessionId ? "Start New Session" : "Start Session"}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</PanelHeader>
|
|
|
|
<div
|
|
className={css({
|
|
minHeight: 0,
|
|
flex: 1,
|
|
display: "grid",
|
|
gridTemplateRows: "minmax(0, 1fr) auto",
|
|
gap: "1px",
|
|
padding: 0,
|
|
backgroundColor: theme.colors.borderOpaque,
|
|
})}
|
|
>
|
|
{!selectedForSession ? (
|
|
<EmptyState>Select a task from the left sidebar.</EmptyState>
|
|
) : (
|
|
<>
|
|
<div
|
|
className={css({
|
|
minHeight: 0,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
backgroundColor: theme.colors.backgroundSecondary,
|
|
overflow: "hidden",
|
|
})}
|
|
>
|
|
<div
|
|
className={css({
|
|
padding: `${theme.sizing.scale400} ${theme.sizing.scale500}`,
|
|
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.sizing.scale400,
|
|
flexWrap: "wrap",
|
|
backgroundColor: theme.colors.backgroundTertiary,
|
|
})}
|
|
>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
color: theme.colors.contentSecondary,
|
|
})}
|
|
>
|
|
<MessageSquareText size={14} />
|
|
<LabelSmall marginTop="0" marginBottom="0">
|
|
Session {resolvedSessionId ?? staleSessionId ?? "(none)"}
|
|
</LabelSmall>
|
|
</div>
|
|
|
|
{sessionRows.length > 0 ? (
|
|
<div className={css({ minWidth: "280px", maxWidth: "100%" })}>
|
|
<Select
|
|
options={sessionOptions}
|
|
value={selectValue(selectedSessionOption)}
|
|
clearable={false}
|
|
searchable={false}
|
|
size="compact"
|
|
onChange={(params: OnChangeParams) => {
|
|
const next = optionId(params.value);
|
|
if (next) {
|
|
setActiveSessionId(next);
|
|
}
|
|
}}
|
|
overrides={selectTestIdOverrides("handoff-session-select")}
|
|
/>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div
|
|
className={css({
|
|
minHeight: 0,
|
|
flex: 1,
|
|
overflowY: "auto",
|
|
padding: theme.sizing.scale400,
|
|
backgroundColor: theme.colors.backgroundPrimary,
|
|
})}
|
|
>
|
|
{eventsQuery.isLoading ? <Skeleton rows={2} height="90px" /> : null}
|
|
|
|
{transcript.length === 0 && !eventsQuery.isLoading ? (
|
|
<EmptyState testId="session-transcript-empty">
|
|
{groupHandoffStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
|
|
? `Session failed: ${selectedForSession.statusMessage}`
|
|
: !activeSandbox?.sandboxId
|
|
? selectedForSession.statusMessage
|
|
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
|
|
: "This task is still provisioning its sandbox."
|
|
: staleSessionId
|
|
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
|
|
: resolvedSessionId
|
|
? "No transcript events yet. Send a prompt to start this session."
|
|
: "No active session for this task."}
|
|
</EmptyState>
|
|
) : null}
|
|
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale400,
|
|
})}
|
|
data-testid="session-transcript"
|
|
>
|
|
{transcript.map((entry) => (
|
|
<article
|
|
key={entry.id}
|
|
data-testid="session-transcript-entry"
|
|
className={css({
|
|
borderLeft: `2px solid ${entry.sender === "agent" ? "rgba(29, 111, 95, 0.45)" : "rgba(32, 108, 176, 0.45)"}`,
|
|
border: `1px solid ${entry.sender === "agent" ? "rgba(29, 111, 95, 0.22)" : "rgba(32, 108, 176, 0.22)"}`,
|
|
backgroundColor: entry.sender === "agent" ? "rgba(29, 111, 95, 0.07)" : "rgba(32, 108, 176, 0.07)",
|
|
padding: `12px ${theme.sizing.scale400}`,
|
|
})}
|
|
>
|
|
<header
|
|
className={css({
|
|
marginBottom: "8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<LabelXSmall color="contentSecondary">{entry.sender}</LabelXSmall>
|
|
<LabelXSmall color="contentSecondary">{formatTime(entry.createdAt)}</LabelXSmall>
|
|
</header>
|
|
<pre
|
|
className={css({
|
|
margin: 0,
|
|
whiteSpace: "pre-wrap",
|
|
overflowX: "auto",
|
|
fontFamily: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
|
fontSize: theme.typography.MonoParagraphSmall.fontSize,
|
|
lineHeight: theme.typography.MonoParagraphSmall.lineHeight,
|
|
})}
|
|
>
|
|
{entry.text}
|
|
</pre>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: "1px",
|
|
padding: "10px 12px",
|
|
backgroundColor: theme.colors.backgroundSecondary,
|
|
})}
|
|
>
|
|
<Textarea
|
|
value={draft}
|
|
onChange={(event) => setDraft(event.target.value)}
|
|
placeholder="Send a follow-up prompt to this session"
|
|
rows={5}
|
|
disabled={!activeSandbox?.sandboxId}
|
|
overrides={textareaTestIdOverrides("handoff-session-prompt")}
|
|
/>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
justifyContent: "flex-end",
|
|
})}
|
|
>
|
|
<Button
|
|
onClick={() => {
|
|
const prompt = draft.trim();
|
|
if (!prompt) {
|
|
return;
|
|
}
|
|
void sendPrompt.mutateAsync(prompt);
|
|
}}
|
|
disabled={
|
|
sendPrompt.isPending || createSession.isPending || !selectedForSession || !activeSandbox?.sandboxId || draft.trim().length === 0
|
|
}
|
|
>
|
|
<span
|
|
className={css({
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: theme.sizing.scale200,
|
|
})}
|
|
>
|
|
<SendHorizontal size={14} />
|
|
Send
|
|
</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</Panel>
|
|
|
|
<DetailRail>
|
|
<PanelHeader>
|
|
<HeadingSmall marginTop="0" marginBottom="0">
|
|
{repoOverviewMode ? "Repo Details" : "Task Details"}
|
|
</HeadingSmall>
|
|
</PanelHeader>
|
|
|
|
<ScrollBody>
|
|
{repoOverviewMode ? (
|
|
!overview ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
No repo overview available.
|
|
</ParagraphSmall>
|
|
) : (
|
|
<>
|
|
<section>
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
Repository
|
|
</HeadingXSmall>
|
|
<StyledDivider />
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<MetaRow label="Remote" value={overview.remoteUrl} />
|
|
<MetaRow label="Base Ref" value={overview.baseRef ?? "-"} mono />
|
|
<MetaRow label="Stack Tool" value={overview.stackAvailable ? "git-spice" : "unavailable"} />
|
|
<MetaRow label="Fetched" value={new Date(overview.fetchedAt).toLocaleTimeString()} />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
Selected Branch
|
|
</HeadingXSmall>
|
|
<StyledDivider />
|
|
{!selectedBranchOverview ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
Select a branch in the center panel.
|
|
</ParagraphSmall>
|
|
) : (
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<MetaRow label="Branch" value={selectedBranchOverview.branchName} mono />
|
|
<MetaRow label="Parent" value={selectedBranchOverview.parentBranch ?? "-"} mono />
|
|
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
|
|
<MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} />
|
|
<MetaRow
|
|
label="Task"
|
|
value={selectedBranchOverview.handoffTitle ?? selectedBranchOverview.handoffId ?? "-"}
|
|
/>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</>
|
|
)
|
|
) : !selectedForSession ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
No task selected.
|
|
</ParagraphSmall>
|
|
) : (
|
|
<>
|
|
<section>
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
Identifiers
|
|
</HeadingXSmall>
|
|
<StyledDivider />
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<MetaRow label="Task" value={selectedForSession.handoffId} mono />
|
|
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
|
|
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
Branch + PR
|
|
</HeadingXSmall>
|
|
<StyledDivider />
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale300,
|
|
})}
|
|
>
|
|
<MetaRow label="Branch" value={selectedForSession.branchName ?? "-"} mono />
|
|
<MetaRow label="Diff" value={formatDiffStat(selectedForSession.diffStat)} />
|
|
<MetaRow label="PR" value={selectedForSession.prUrl ?? "-"} />
|
|
<MetaRow label="Review" value={selectedForSession.reviewStatus ?? "-"} />
|
|
</div>
|
|
</section>
|
|
|
|
<section>
|
|
<HeadingXSmall marginTop="0" marginBottom="0">
|
|
Task
|
|
</HeadingXSmall>
|
|
<StyledDivider />
|
|
<div
|
|
className={css({
|
|
padding: theme.sizing.scale400,
|
|
borderRadius: "0",
|
|
backgroundColor: theme.colors.backgroundTertiary,
|
|
border: `1px solid ${theme.colors.borderOpaque}`,
|
|
})}
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0">
|
|
{selectedForSession.task}
|
|
</ParagraphSmall>
|
|
</div>
|
|
</section>
|
|
|
|
{groupHandoffStatus(selectedForSession.status) === "error" ? (
|
|
<div
|
|
className={css({
|
|
padding: "12px",
|
|
borderRadius: "0",
|
|
border: `1px solid rgba(188, 57, 74, 0.28)`,
|
|
backgroundColor: "rgba(188, 57, 74, 0.06)",
|
|
})}
|
|
>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: theme.sizing.scale200,
|
|
marginBottom: theme.sizing.scale200,
|
|
})}
|
|
>
|
|
<CircleAlert size={14} />
|
|
<LabelSmall marginTop="0" marginBottom="0">
|
|
Session reported an error state
|
|
</LabelSmall>
|
|
</div>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
{selectedForSession.statusMessage ? selectedForSession.statusMessage : "Open transcript in the center panel for details."}
|
|
</ParagraphSmall>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</ScrollBody>
|
|
</DetailRail>
|
|
|
|
<Modal isOpen={addRepoOpen} onClose={() => setAddRepoOpen(false)} overrides={modalOverrides}>
|
|
<ModalHeader>Add Repo</ModalHeader>
|
|
<ModalBody>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale500,
|
|
})}
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
Add a git remote URL to this workspace.
|
|
</ParagraphSmall>
|
|
<Input
|
|
placeholder="Git remote (e.g. https://github.com/org/repo.git or org/repo)"
|
|
value={addRepoRemote}
|
|
onChange={(event) => setAddRepoRemote(event.target.value)}
|
|
overrides={inputTestIdOverrides("repo-add-remote")}
|
|
/>
|
|
{addRepoError ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="repo-add-error">
|
|
{addRepoError}
|
|
</ParagraphSmall>
|
|
) : null}
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button kind="tertiary" onClick={() => setAddRepoOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setAddRepoError(null);
|
|
void addRepo.mutateAsync(addRepoRemote);
|
|
}}
|
|
disabled={addRepo.isPending || addRepoRemote.trim().length === 0}
|
|
data-testid="repo-add-submit"
|
|
>
|
|
Add Repo
|
|
</Button>
|
|
</ModalFooter>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={createHandoffOpen}
|
|
onClose={() => {
|
|
setCreateHandoffOpen(false);
|
|
setCreateOnBranch(null);
|
|
}}
|
|
overrides={modalOverrides}
|
|
>
|
|
<ModalHeader>Create Task</ModalHeader>
|
|
<ModalBody>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale500,
|
|
})}
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
Pick a repo, describe the task, and the backend will create a task.
|
|
</ParagraphSmall>
|
|
|
|
<div>
|
|
<LabelXSmall color="contentSecondary" marginBottom="scale200">
|
|
Repo
|
|
</LabelXSmall>
|
|
<Select
|
|
options={repoOptions}
|
|
value={selectValue(selectedRepoOption)}
|
|
clearable={false}
|
|
searchable={false}
|
|
disabled={repos.length === 0}
|
|
onChange={(params: OnChangeParams) => {
|
|
const next = optionId(params.value);
|
|
if (next) {
|
|
setCreateRepoId(next);
|
|
}
|
|
}}
|
|
overrides={selectTestIdOverrides("handoff-create-repo")}
|
|
/>
|
|
{repos.length === 0 ? (
|
|
<div
|
|
className={css({
|
|
marginTop: theme.sizing.scale300,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.sizing.scale300,
|
|
padding: "12px",
|
|
borderRadius: "0",
|
|
border: `1px solid ${theme.colors.borderOpaque}`,
|
|
backgroundColor: theme.colors.backgroundTertiary,
|
|
})}
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
No repos yet.
|
|
</ParagraphSmall>
|
|
<Button
|
|
size="compact"
|
|
kind="secondary"
|
|
onClick={() => {
|
|
setCreateHandoffOpen(false);
|
|
setAddRepoError(null);
|
|
setAddRepoOpen(true);
|
|
}}
|
|
>
|
|
Add Repo
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div>
|
|
<LabelXSmall color="contentSecondary" marginBottom="scale200">
|
|
Agent
|
|
</LabelXSmall>
|
|
<Select
|
|
options={AGENT_OPTIONS.map(createOption)}
|
|
value={selectValue(selectedAgentOption)}
|
|
clearable={false}
|
|
searchable={false}
|
|
onChange={(params: OnChangeParams) => {
|
|
const next = optionId(params.value);
|
|
if (next === "claude" || next === "codex") {
|
|
setNewAgentType(next);
|
|
}
|
|
}}
|
|
overrides={selectTestIdOverrides("handoff-create-agent")}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<LabelXSmall color="contentSecondary" marginBottom="scale200">
|
|
Task
|
|
</LabelXSmall>
|
|
<Textarea
|
|
value={newTask}
|
|
onChange={(event) => setNewTask(event.target.value)}
|
|
placeholder="Task"
|
|
rows={6}
|
|
overrides={textareaTestIdOverrides("handoff-create-task")}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<LabelXSmall color="contentSecondary" marginBottom="scale200">
|
|
Title
|
|
</LabelXSmall>
|
|
<Input
|
|
placeholder="Title (optional)"
|
|
value={newTitle}
|
|
onChange={(event) => setNewTitle(event.target.value)}
|
|
overrides={inputTestIdOverrides("handoff-create-title")}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<LabelXSmall color="contentSecondary" marginBottom="scale200">
|
|
Branch
|
|
</LabelXSmall>
|
|
{createOnBranch ? (
|
|
<Input value={createOnBranch} disabled overrides={inputTestIdOverrides("handoff-create-branch")} />
|
|
) : (
|
|
<Input
|
|
placeholder="Branch name (optional)"
|
|
value={newBranchName}
|
|
onChange={(event) => setNewBranchName(event.target.value)}
|
|
overrides={inputTestIdOverrides("handoff-create-branch")}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{createError ? (
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="handoff-create-error">
|
|
{createError}
|
|
</ParagraphSmall>
|
|
) : null}
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button
|
|
kind="tertiary"
|
|
onClick={() => {
|
|
setCreateHandoffOpen(false);
|
|
setCreateOnBranch(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
disabled={!canCreateHandoff || createHandoff.isPending}
|
|
onClick={() => {
|
|
setCreateError(null);
|
|
void createHandoff.mutateAsync();
|
|
}}
|
|
data-testid="handoff-create-submit"
|
|
>
|
|
Create Task
|
|
</Button>
|
|
</ModalFooter>
|
|
</Modal>
|
|
|
|
<Modal
|
|
isOpen={reparentBranchName !== null}
|
|
onClose={() => {
|
|
setReparentBranchName(null);
|
|
setReparentParentBranch("");
|
|
}}
|
|
overrides={modalOverrides}
|
|
>
|
|
<ModalHeader>Reparent Branch</ModalHeader>
|
|
<ModalBody>
|
|
<div
|
|
className={css({
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: theme.sizing.scale500,
|
|
})}
|
|
>
|
|
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
|
|
{reparentBranchName ? `Move ${reparentBranchName} onto a different parent branch.` : ""}
|
|
</ParagraphSmall>
|
|
<Input
|
|
value={reparentParentBranch}
|
|
onChange={(event) => setReparentParentBranch(event.target.value)}
|
|
placeholder="Parent branch"
|
|
overrides={inputTestIdOverrides("repo-overview-reparent-input")}
|
|
/>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<Button
|
|
kind="tertiary"
|
|
onClick={() => {
|
|
setReparentBranchName(null);
|
|
setReparentParentBranch("");
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button disabled={!reparentBranchName || !reparentParentBranch.trim()} onClick={handleReparentSubmit}>
|
|
Reparent
|
|
</Button>
|
|
</ModalFooter>
|
|
</Modal>
|
|
</DashboardGrid>
|
|
</AppShell>
|
|
);
|
|
}
|