Refactor Foundry GitHub and sandbox flows

This commit is contained in:
Nathan Flurry 2026-03-12 10:51:33 -07:00
parent 4bccd5fc8d
commit ec8e816d0d
112 changed files with 4026 additions and 2715 deletions

View file

@ -28,6 +28,7 @@ import {
type ModelId,
} from "./mock-layout/view-model";
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
import { backendClient } from "../lib/backend";
import { getTaskWorkbenchClient } from "../lib/workbench";
function firstAgentTabId(task: Task): string | null {
@ -63,6 +64,39 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
}
function resolvedTaskLifecycle(task: Task) {
return (
task.lifecycle ?? {
code: task.status === "running" ? "running" : task.status === "idle" ? "idle" : task.status === "archived" ? "archived" : "init_create_sandbox",
state: task.status === "running" || task.status === "idle" ? "ready" : task.status === "archived" ? "archived" : "starting",
label:
task.status === "running" ? "Agent running" : task.status === "idle" ? "Task idle" : task.status === "archived" ? "Task archived" : "Creating sandbox",
message: null,
}
);
}
function taskLifecycleAccent(task: Task): string {
switch (resolvedTaskLifecycle(task).state) {
case "error":
return "#ef4444";
case "starting":
return "#f59e0b";
case "ready":
return "#10b981";
case "archived":
case "killed":
return "#94a3b8";
default:
return "#94a3b8";
}
}
function shouldShowTaskLifecycle(task: Task): boolean {
const lifecycle = resolvedTaskLifecycle(task);
return lifecycle.state === "starting" || lifecycle.state === "error";
}
const TranscriptPanel = memo(function TranscriptPanel({
taskWorkbenchClient,
task,
@ -445,6 +479,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
activeAgentTab?.status === "running" && activeAgentTab.thinkingSinceMs !== null
? formatThinkingDuration(timerNowMs - activeAgentTab.thinkingSinceMs)
: null;
const lifecycle = resolvedTaskLifecycle(task);
return (
<SPanel>
@ -470,6 +505,37 @@ const TranscriptPanel = memo(function TranscriptPanel({
onToggleRightSidebar={onToggleRightSidebar}
onNavigateToUsage={onNavigateToUsage}
/>
{shouldShowTaskLifecycle(task) ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
padding: "10px 16px",
borderLeft: `3px solid ${taskLifecycleAccent(task)}`,
background: lifecycle.state === "error" ? "rgba(127, 29, 29, 0.35)" : "rgba(120, 53, 15, 0.28)",
borderBottom: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.04em",
textTransform: "uppercase",
}}
>
<span style={{ color: taskLifecycleAccent(task) }}>{lifecycle.label}</span>
<span style={{ opacity: 0.6 }}>{lifecycle.code}</span>
</div>
<div style={{ fontSize: "13px", color: "#e4e4e7" }}>
{lifecycle.message ?? (lifecycle.state === "starting" ? "Waiting for the sandbox and first session to come online." : "Task startup failed.")}
</div>
</div>
) : null}
<div
style={{
flex: 1,
@ -530,7 +596,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
}}
>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create the first session</h2>
<p style={{ margin: 0, opacity: 0.75 }}>Sessions are where you chat with the agent. Start one now to send the first prompt on this task.</p>
<p style={{ margin: 0, opacity: 0.75 }}>
{lifecycle.state === "starting"
? `Task startup is still in progress: ${lifecycle.label}.`
: "Sessions are where you chat with the agent. Start one now to send the first prompt on this task."}
</p>
{lifecycle.message ? <p style={{ margin: 0, fontSize: "13px", color: "#d4d4d8" }}>{lifecycle.message}</p> : null}
<button
type="button"
onClick={addTab}
@ -814,6 +885,72 @@ interface MockLayoutProps {
selectedSessionId?: string | null;
}
function githubStatusPill(organization: ReturnType<typeof activeMockOrganization>) {
if (!organization) {
return null;
}
const label =
organization.github.installationStatus !== "connected"
? "GitHub disconnected"
: organization.github.syncStatus === "syncing"
? "GitHub syncing"
: organization.github.syncStatus === "error"
? "GitHub error"
: organization.github.syncStatus === "pending"
? "GitHub pending"
: "GitHub synced";
const colors =
organization.github.installationStatus !== "connected"
? { background: "rgba(255, 193, 7, 0.18)", color: "#ffe6a6" }
: organization.github.syncStatus === "syncing"
? { background: "rgba(24, 140, 255, 0.18)", color: "#b9d8ff" }
: organization.github.syncStatus === "error"
? { background: "rgba(255, 79, 0, 0.18)", color: "#ffd6c7" }
: { background: "rgba(46, 160, 67, 0.16)", color: "#b7f0c3" };
return (
<span
style={{
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: "999px",
padding: "6px 10px",
background: colors.background,
color: colors.color,
fontSize: "12px",
fontWeight: 700,
}}
>
{label}
</span>
);
}
function actorRuntimePill(organization: ReturnType<typeof activeMockOrganization>) {
if (!organization || organization.runtime.status !== "error") {
return null;
}
const label = organization.runtime.errorCount === 1 ? "1 actor error" : `${organization.runtime.errorCount} actor errors`;
return (
<span
style={{
border: "1px solid rgba(255,255,255,0.08)",
borderRadius: "999px",
padding: "6px 10px",
background: "rgba(255, 79, 0, 0.2)",
color: "#ffd6c7",
fontSize: "12px",
fontWeight: 800,
}}
>
{label}
</span>
);
}
function MockWorkspaceOrgBar() {
const navigate = useNavigate();
const snapshot = useMockAppSnapshot();
@ -834,6 +971,7 @@ function MockWorkspaceOrgBar() {
fontSize: "13px",
fontWeight: 600,
} satisfies React.CSSProperties;
const latestRuntimeIssue = organization.runtime.issues[0] ?? null;
return (
<div
@ -851,6 +989,15 @@ function MockWorkspaceOrgBar() {
<strong style={{ fontSize: "14px", fontWeight: 600 }}>{organization.settings.displayName}</strong>
<span style={{ fontSize: "12px", color: t.textMuted }}>{organization.settings.primaryDomain}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "10px", flexWrap: "wrap" }}>
{actorRuntimePill(organization)}
{githubStatusPill(organization)}
<span style={{ fontSize: "12px", color: t.textMuted }}>
{organization.runtime.status === "error" && latestRuntimeIssue
? `${latestRuntimeIssue.scopeLabel}: ${latestRuntimeIssue.message}`
: organization.github.lastSyncLabel}
</span>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button
type="button"
@ -923,6 +1070,8 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState<Record<string, string | null>>({});
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
const [selectedNewTaskRepoId, setSelectedNewTaskRepoId] = useState("");
const [activeTaskDetail, setActiveTaskDetail] = useState<Task | null>(null);
const [activeTaskDetailLoading, setActiveTaskDetailLoading] = useState(false);
const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH));
const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH));
const leftWidthRef = useRef(leftWidth);
@ -995,7 +1144,43 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
startRightRef.current = rightWidthRef.current;
}, []);
const activeTask = useMemo(() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, [tasks, selectedTaskId]);
const activeTaskSummary = useMemo(() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, [tasks, selectedTaskId]);
const activeTask = useMemo(() => {
if (activeTaskSummary && activeTaskDetail?.id === activeTaskSummary.id) {
return activeTaskDetail;
}
return activeTaskSummary;
}, [activeTaskDetail, activeTaskSummary]);
useEffect(() => {
if (!activeTaskSummary) {
setActiveTaskDetail(null);
setActiveTaskDetailLoading(false);
return;
}
let cancelled = false;
setActiveTaskDetailLoading(true);
void backendClient
.getWorkbenchTask(workspaceId, activeTaskSummary.id)
.then((task) => {
if (cancelled) return;
setActiveTaskDetail(task as Task);
})
.catch((error) => {
if (cancelled) return;
console.error("failed to load active task detail", error);
setActiveTaskDetail(null);
})
.finally(() => {
if (cancelled) return;
setActiveTaskDetailLoading(false);
});
return () => {
cancelled = true;
};
}, [activeTaskSummary?.id, workspaceId, viewModel]);
useEffect(() => {
if (activeTask) {
@ -1091,6 +1276,9 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
if (!activeTask) {
return;
}
if (activeTaskDetailLoading) {
return;
}
if (activeTask.tabs.length > 0) {
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
return;
@ -1113,7 +1301,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
}
})();
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
}, [activeTask, activeTaskDetailLoading, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
const createTask = useCallback(() => {
void (async () => {

View file

@ -122,6 +122,24 @@ export const RightSidebar = memo(function RightSidebar({
observer.observe(node);
}, []);
const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null;
const pullRequestStatusLabel =
task.pullRequest?.status === "merged"
? "Merged"
: task.pullRequest?.status === "closed"
? "Closed"
: task.pullRequest?.status === "draft"
? "Draft"
: task.pullRequest?.status === "ready"
? "Open"
: null;
const pullRequestActionLabel =
task.pullRequest?.status === "merged"
? "Open merged PR"
: task.pullRequest?.status === "closed"
? "Open closed PR"
: pullRequestUrl
? "Open PR"
: "Publish PR";
const copyFilePath = useCallback(async (path: string) => {
try {
@ -155,6 +173,38 @@ export const RightSidebar = memo(function RightSidebar({
<div ref={headerRef} className={css({ display: "flex", alignItems: "center", flex: 1, minWidth: 0, justifyContent: "flex-end", gap: "2px" })}>
{!isTerminal ? (
<div className={css({ display: "flex", alignItems: "center", gap: "2px", flexShrink: 1, minWidth: 0 })}>
{pullRequestStatusLabel ? (
<span
className={css({
display: "inline-flex",
alignItems: "center",
padding: compact ? "3px 6px" : "4px 8px",
borderRadius: "999px",
fontSize: "10px",
fontWeight: 600,
color:
task.pullRequest?.status === "merged"
? "#86efac"
: task.pullRequest?.status === "closed"
? "#fca5a5"
: task.pullRequest?.status === "draft"
? t.accent
: t.textSecondary,
backgroundColor:
task.pullRequest?.status === "merged"
? "rgba(22, 101, 52, 0.35)"
: task.pullRequest?.status === "closed"
? "rgba(127, 29, 29, 0.35)"
: task.pullRequest?.status === "draft"
? "rgba(154, 52, 18, 0.28)"
: t.interactiveHover,
whiteSpace: "nowrap",
flexShrink: 0,
})}
>
{pullRequestStatusLabel}
</span>
) : null}
<button
onClick={() => {
if (pullRequestUrl) {
@ -188,7 +238,7 @@ export const RightSidebar = memo(function RightSidebar({
})}
>
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
{!compact && <span>{pullRequestUrl ? "Open PR" : "Publish PR"}</span>}
{!compact && <span>{pullRequestActionLabel}</span>}
</button>
<button
className={css({

View file

@ -593,6 +593,11 @@ export const Sidebar = memo(function Sidebar({
#{task.pullRequest.number}
</LabelXSmall>
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
{task.pullRequest.status === "merged" ? <LabelXSmall color="#86efac">merged</LabelXSmall> : null}
{task.pullRequest.status === "closed" ? <LabelXSmall color="#fca5a5">closed</LabelXSmall> : null}
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
{task.pullRequest.status === "merged" ? <LabelXSmall color="#86efac">merged</LabelXSmall> : null}
{task.pullRequest.status === "closed" ? <LabelXSmall color="#fca5a5">closed</LabelXSmall> : null}
</span>
) : (
<GitPullRequestDraft size={11} color={t.textTertiary} />

View file

@ -697,13 +697,44 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
description={`Connected as ${organization.github.connectedAccount}. ${organization.github.importedRepoCount} repos imported.`}
>
<SettingsRow label="Installation status" description={`Last sync: ${organization.github.lastSyncLabel}`} action={githubBadge(t, organization)} />
<div style={{ display: "flex", gap: "8px" }}>
{organization.runtime.status === "error" ? (
<div
style={{
display: "grid",
gap: "8px",
padding: "12px",
borderRadius: "8px",
background: "rgba(255, 79, 0, 0.08)",
border: "1px solid rgba(255, 79, 0, 0.18)",
}}
>
<div style={{ color: t.textPrimary, fontSize: "12px", fontWeight: 600 }}>
{organization.runtime.errorCount === 1
? "1 actor is currently reporting a workflow error"
: `${organization.runtime.errorCount} actors are currently reporting workflow errors`}
</div>
{organization.runtime.issues.slice(0, 4).map((issue) => (
<div key={issue.actorId} style={{ color: t.textSecondary, fontSize: "11px", lineHeight: 1.5 }}>
<strong>{issue.scopeLabel}</strong>: {issue.message}
{issue.stepName ? ` · step ${issue.stepName}` : ""}
{issue.attempt != null ? ` · attempt ${issue.attempt}` : ""}
{issue.willRetry && issue.retryDelayMs != null ? ` · retrying in ${issue.retryDelayMs}ms` : ""}
</div>
))}
</div>
) : null}
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={secondaryButtonStyle(t)}>
Reconnect GitHub
</button>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={subtleButtonStyle(t)}>
Sync repos
</button>
{organization.runtime.errorCount > 0 ? (
<button type="button" onClick={() => void client.clearOrganizationRuntimeIssues(organization.id)} style={subtleButtonStyle(t)}>
Clear actor errors
</button>
) : null}
</div>
</SettingsContentSection>