Complete Foundry refactor checklist

This commit is contained in:
Nathan Flurry 2026-03-15 13:38:51 -07:00 committed by Nathan Flurry
parent 40bed3b0a1
commit 13fc9cb318
91 changed files with 5091 additions and 4108 deletions

View file

@ -6,7 +6,6 @@ import { subscriptionManager } from "../lib/subscription";
import type {
FoundryAppSnapshot,
FoundryOrganization,
TaskStatus,
TaskWorkspaceSnapshot,
WorkspaceSandboxSummary,
WorkspaceSessionSummary,
@ -28,8 +27,6 @@ export interface DevPanelFocusedTask {
repoId: string;
title: string | null;
status: WorkspaceTaskStatus;
runtimeStatus?: TaskStatus | null;
statusMessage?: string | null;
branch?: string | null;
activeSandboxId?: string | null;
activeSessionId?: string | null;
@ -80,7 +77,7 @@ function timeAgo(ts: number | null): string {
}
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
if (status === "new" || status.startsWith("init_") || status.startsWith("archive_") || status.startsWith("kill_") || status.startsWith("pending_")) {
if (status.startsWith("init_") || status.startsWith("archive_") || status.startsWith("kill_") || status.startsWith("pending_")) {
return t.statusWarning;
}
switch (status) {
@ -159,14 +156,16 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
}, [now]);
const appState = useSubscription(subscriptionManager, "app", {});
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const appSnapshot: FoundryAppSnapshot | null = appState.data ?? null;
const liveGithub = organizationState.data?.github ?? organization?.github ?? null;
const repos = snapshot.repos ?? [];
const tasks = snapshot.tasks ?? [];
const prCount = tasks.filter((task) => task.pullRequest != null).length;
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
const focusedTaskStatus = focusedTask?.status ?? null;
const focusedTaskState = describeTaskState(focusedTaskStatus);
const lastWebhookAt = liveGithub?.lastWebhookAt ?? null;
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
const totalOrgs = appSnapshot?.organizations.length ?? 0;
const authStatus = appSnapshot?.auth.status ?? "unknown";
@ -442,7 +441,7 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
{/* GitHub */}
<Section label="GitHub" t={t} css={css}>
{organization ? (
{liveGithub ? (
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
<span
@ -450,13 +449,13 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: installStatusColor(organization.github.installationStatus, t),
backgroundColor: installStatusColor(liveGithub.installationStatus, t),
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1 })}>App Install</span>
<span className={`${mono} ${css({ color: installStatusColor(organization.github.installationStatus, t) })}`}>
{organization.github.installationStatus.replace(/_/g, " ")}
<span className={`${mono} ${css({ color: installStatusColor(liveGithub.installationStatus, t) })}`}>
{liveGithub.installationStatus.replace(/_/g, " ")}
</span>
</div>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
@ -465,14 +464,14 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
width: "5px",
height: "5px",
borderRadius: "50%",
backgroundColor: syncStatusColor(organization.github.syncStatus, t),
backgroundColor: syncStatusColor(liveGithub.syncStatus, t),
flexShrink: 0,
})}
/>
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
{organization.github.lastSyncAt != null && (
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(organization.github.lastSyncAt)}</span>
<span className={`${mono} ${css({ color: syncStatusColor(liveGithub.syncStatus, t) })}`}>{liveGithub.syncStatus}</span>
{liveGithub.lastSyncAt != null && (
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(liveGithub.lastSyncAt)}</span>
)}
</div>
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
@ -488,21 +487,27 @@ export const DevPanel = memo(function DevPanel({ organizationId, snapshot, organ
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
{lastWebhookAt != null ? (
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
{liveGithub.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
</span>
) : (
<span className={`${mono} ${css({ color: t.statusWarning })}`}>never received</span>
)}
</div>
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
<Stat label="imported" value={organization.github.importedRepoCount} t={t} css={css} />
<Stat label="catalog" value={organization.repoCatalog.length} t={t} css={css} />
<Stat label="imported" value={liveGithub.importedRepoCount} t={t} css={css} />
<Stat label="catalog" value={organization?.repoCatalog.length ?? repos.length} t={t} css={css} />
<Stat label="target" value={liveGithub.totalRepositoryCount} t={t} css={css} />
</div>
{organization.github.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
{liveGithub.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{liveGithub.connectedAccount}</div>
)}
{organization.github.lastSyncLabel && (
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
{liveGithub.lastSyncLabel && (
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {liveGithub.lastSyncLabel}</div>
)}
{liveGithub.syncPhase && (
<div className={`${mono} ${css({ color: t.textTertiary })}`}>
phase: {liveGithub.syncPhase.replace(/^syncing_/, "").replace(/_/g, " ")} ({liveGithub.processedRepositoryCount}/{liveGithub.totalRepositoryCount})
</div>
)}
</div>
) : (

View file

@ -1,11 +1,14 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import {
DEFAULT_WORKSPACE_MODEL_GROUPS,
DEFAULT_WORKSPACE_MODEL_ID,
createErrorContext,
type FoundryOrganization,
type TaskWorkspaceSnapshot,
type WorkspaceOpenPrSummary,
type WorkspaceModelGroup,
type WorkspaceSessionSummary,
type WorkspaceTaskDetail,
type WorkspaceTaskSummary,
@ -77,29 +80,35 @@ function sanitizeActiveSessionId(task: Task, sessionId: string | null | undefine
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentSessionId;
}
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
type GithubStatusView = Pick<FoundryOrganization["github"], "connectedAccount" | "installationStatus" | "syncStatus" | "importedRepoCount" | "lastSyncLabel"> & {
syncPhase?: string | null;
processedRepositoryCount?: number;
totalRepositoryCount?: number;
};
function githubInstallationWarningTitle(github: GithubStatusView): string {
return github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
}
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
const statusDetail = organization.github.lastSyncLabel.trim();
function githubInstallationWarningDetail(github: GithubStatusView): string {
const statusDetail = github.lastSyncLabel.trim();
const requirementDetail =
organization.github.installationStatus === "install_required"
github.installationStatus === "install_required"
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this organization."
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
}
function GithubInstallationWarning({
organization,
github,
css,
t,
}: {
organization: FoundryOrganization;
github: GithubStatusView;
css: ReturnType<typeof useStyletron>[0];
t: ReturnType<typeof useFoundryTokens>;
}) {
if (organization.github.installationStatus === "connected") {
if (github.installationStatus === "connected") {
return null;
}
@ -123,8 +132,8 @@ function GithubInstallationWarning({
>
<CircleAlert size={15} color={t.statusError} />
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(github)}</div>
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(github)}</div>
</div>
</div>
);
@ -164,13 +173,12 @@ function toTaskModel(
id: summary.id,
repoId: summary.repoId,
title: detail?.title ?? summary.title,
status: detail?.runtimeStatus ?? detail?.status ?? summary.status,
runtimeStatus: detail?.runtimeStatus,
statusMessage: detail?.statusMessage ?? null,
status: detail?.status ?? summary.status,
repoName: detail?.repoName ?? summary.repoName,
updatedAtMs: detail?.updatedAtMs ?? summary.updatedAtMs,
branch: detail?.branch ?? summary.branch,
pullRequest: detail?.pullRequest ?? summary.pullRequest,
activeSessionId: detail?.activeSessionId ?? summary.activeSessionId ?? null,
sessions: sessions.map((session) => toSessionModel(session, sessionCache?.get(session.id))),
fileChanges: detail?.fileChanges ?? [],
diffs: detail?.diffs ?? {},
@ -180,40 +188,6 @@ function toTaskModel(
};
}
const OPEN_PR_TASK_PREFIX = "pr:";
function openPrTaskId(prId: string): string {
return `${OPEN_PR_TASK_PREFIX}${prId}`;
}
function isOpenPrTaskId(taskId: string): boolean {
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
}
function toOpenPrTaskModel(pullRequest: WorkspaceOpenPrSummary): Task {
return {
id: openPrTaskId(pullRequest.prId),
repoId: pullRequest.repoId,
title: pullRequest.title,
status: "new",
runtimeStatus: undefined,
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
repoName: pullRequest.repoFullName,
updatedAtMs: pullRequest.updatedAtMs,
branch: pullRequest.headRefName,
pullRequest: {
number: pullRequest.number,
status: pullRequest.isDraft ? "draft" : "ready",
},
sessions: [],
fileChanges: [],
diffs: {},
fileTree: [],
minutesUsed: 0,
activeSandboxId: null,
};
}
function sessionStateMessage(tab: Task["sessions"][number] | null | undefined): string | null {
if (!tab) {
return null;
@ -258,15 +232,14 @@ interface WorkspaceActions {
updateDraft(input: { repoId: string; taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
sendMessage(input: { repoId: string; taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
stopAgent(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
selectSession(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
setSessionUnread(input: { repoId: string; taskId: string; sessionId: string; unread: boolean }): Promise<void>;
renameSession(input: { repoId: string; taskId: string; sessionId: string; title: string }): Promise<void>;
closeSession(input: { repoId: string; taskId: string; sessionId: string }): Promise<void>;
addSession(input: { repoId: string; taskId: string; model?: string }): Promise<{ sessionId: string }>;
changeModel(input: { repoId: string; taskId: string; sessionId: string; model: ModelId }): Promise<void>;
adminReloadGithubOrganization(): Promise<void>;
adminReloadGithubPullRequests(): Promise<void>;
adminReloadGithubRepository(repoId: string): Promise<void>;
adminReloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
}
const TranscriptPanel = memo(function TranscriptPanel({
@ -287,6 +260,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed,
onToggleRightSidebar,
selectedSessionHydrating = false,
modelGroups,
onNavigateToUsage,
}: {
taskWorkspaceClient: WorkspaceActions;
@ -306,13 +280,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
selectedSessionHydrating?: boolean;
modelGroups: WorkspaceModelGroup[];
onNavigateToUsage?: () => void;
}) {
const t = useFoundryTokens();
const appSnapshot = useMockAppSnapshot();
const appClient = useMockAppClient();
const currentUser = activeMockUser(appSnapshot);
const defaultModel = currentUser?.defaultModel ?? "claude-sonnet-4";
const defaultModel = currentUser?.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID;
const [editingField, setEditingField] = useState<"title" | null>(null);
const [editValue, setEditValue] = useState("");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
@ -335,9 +310,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
const isTerminal = task.status === "archived";
const historyEvents = useMemo(() => buildHistoryEvents(task.sessions), [task.sessions]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentSession), [activeAgentSession]);
const taskRuntimeStatus = task.runtimeStatus ?? task.status;
const taskState = describeTaskState(taskRuntimeStatus, task.statusMessage ?? null);
const taskProvisioning = isProvisioningTaskStatus(taskRuntimeStatus);
const taskState = describeTaskState(task.status);
const taskProvisioning = isProvisioningTaskStatus(task.status);
const taskProvisioningMessage = taskState.detail;
const activeSessionMessage = sessionStateMessage(activeAgentSession);
const showPendingSessionState =
@ -562,6 +536,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
if (!isDiffTab(sessionId)) {
onSetLastAgentSessionId(sessionId);
void taskWorkspaceClient.selectSession({
repoId: task.repoId,
taskId: task.id,
sessionId,
});
const session = task.sessions.find((candidate) => candidate.id === sessionId);
if (session?.unread) {
void taskWorkspaceClient.setSessionUnread({
@ -574,7 +553,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSyncRouteSession(task.id, sessionId);
}
},
[task.id, task.sessions, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
[task.id, task.repoId, task.sessions, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
);
const setSessionUnread = useCallback(
@ -963,6 +942,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
textareaRef={textareaRef}
placeholder={!promptSession.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
modelGroups={modelGroups}
defaultModel={defaultModel}
model={promptSession.model}
isRunning={promptSession.status === "running"}
@ -1298,30 +1278,20 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
updateDraft: (input) => backendClient.updateWorkspaceDraft(organizationId, input),
sendMessage: (input) => backendClient.sendWorkspaceMessage(organizationId, input),
stopAgent: (input) => backendClient.stopWorkspaceSession(organizationId, input),
selectSession: (input) => backendClient.selectWorkspaceSession(organizationId, input),
setSessionUnread: (input) => backendClient.setWorkspaceSessionUnread(organizationId, input),
renameSession: (input) => backendClient.renameWorkspaceSession(organizationId, input),
closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input),
addSession: (input) => backendClient.createWorkspaceSession(organizationId, input),
changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input),
adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId),
adminReloadGithubPullRequests: () => backendClient.adminReloadGithubPullRequests(organizationId),
adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId),
adminReloadGithubPullRequest: (repoId, prNumber) => backendClient.adminReloadGithubPullRequest(organizationId, repoId, prNumber),
}),
[organizationId],
);
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const organizationRepos = organizationState.data?.repos ?? [];
const taskSummaries = organizationState.data?.taskSummaries ?? [];
const openPullRequests = organizationState.data?.openPullRequests ?? [];
const openPullRequestsByTaskId = useMemo(
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
[openPullRequests],
);
const selectedOpenPullRequest = useMemo(
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
[openPullRequestsByTaskId, selectedTaskId],
);
const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries],
@ -1365,6 +1335,20 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
: null,
);
const hasSandbox = Boolean(activeSandbox) && sandboxState.status !== "error";
const modelGroupsQuery = useQuery({
queryKey: ["mock-layout", "workspace-model-groups", organizationId, activeSandbox?.sandboxProviderId ?? "", activeSandbox?.sandboxId ?? ""],
enabled: Boolean(activeSandbox?.sandboxId),
staleTime: 30_000,
refetchOnWindowFocus: false,
queryFn: async () => {
if (!activeSandbox) {
throw new Error("Cannot load workspace model groups without an active sandbox.");
}
return await backendClient.getSandboxWorkspaceModelGroups(organizationId, activeSandbox.sandboxProviderId, activeSandbox.sandboxId);
},
});
const modelGroups = modelGroupsQuery.data && modelGroupsQuery.data.length > 0 ? modelGroupsQuery.data : DEFAULT_WORKSPACE_MODEL_GROUPS;
const tasks = useMemo(() => {
const sessionCache = new Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>();
if (selectedTaskSummary && taskState.data) {
@ -1389,12 +1373,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const hydratedTasks = taskSummaries.map((summary) =>
summary.id === selectedTaskSummary?.id ? toTaskModel(summary, taskState.data, sessionCache) : toTaskModel(summary),
);
const openPrTasks = openPullRequests.map((pullRequest) => toOpenPrTaskModel(pullRequest));
return [...hydratedTasks, ...openPrTasks].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]);
return hydratedTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, organizationId]);
const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks), [tasks, organizationRepos]);
const appSnapshot = useMockAppSnapshot();
const currentUser = activeMockUser(appSnapshot);
const activeOrg = activeMockOrganization(appSnapshot);
const liveGithub = organizationState.data?.github ?? activeOrg?.github ?? null;
const navigateToUsage = useCallback(() => {
if (activeOrg) {
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } as never });
@ -1419,11 +1404,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
const [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
const showDevPanel = useDevPanel();
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -1490,80 +1473,17 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
}, []);
const activeTask = useMemo(() => {
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
if (selectedOpenPullRequest) {
return null;
}
if (selectedTaskId) {
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
return tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null;
}
return realTasks[0] ?? null;
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
const materializeOpenPullRequest = useCallback(
async (pullRequest: WorkspaceOpenPrSummary) => {
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
return;
}
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
setMaterializingOpenPrId(pullRequest.prId);
try {
const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId: pullRequest.repoId,
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
model: "gpt-5.3-codex",
title: pullRequest.title,
onBranch: pullRequest.headRefName,
});
await navigate({
to: "/organizations/$organizationId/tasks/$taskId",
params: {
organizationId,
taskId,
},
search: { sessionId: sessionId ?? undefined },
replace: true,
});
} catch (error) {
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
logger.error(
{
prId: pullRequest.prId,
repoId: pullRequest.repoId,
branchName: pullRequest.headRefName,
...createErrorContext(error),
},
"failed_to_materialize_open_pull_request_task",
);
}
},
[navigate, taskWorkspaceClient, organizationId],
);
useEffect(() => {
if (!selectedOpenPullRequest) {
if (materializingOpenPrId) {
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
}
setMaterializingOpenPrId(null);
return;
}
void materializeOpenPullRequest(selectedOpenPullRequest);
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
return tasks[0] ?? null;
}, [selectedTaskId, tasks]);
useEffect(() => {
if (activeTask) {
return;
}
if (selectedOpenPullRequest || materializingOpenPrId) {
return;
}
const fallbackTaskId = tasks[0]?.id;
if (!fallbackTaskId) {
return;
@ -1580,11 +1500,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
search: { sessionId: fallbackTask?.sessions[0]?.id ?? undefined },
replace: true,
});
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, organizationId]);
}, [activeTask, navigate, tasks, organizationId]);
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
const lastAgentSessionId = activeTask ? sanitizeLastAgentSessionId(activeTask, lastAgentSessionIdByTask[activeTask.id]) : null;
const activeSessionId = activeTask ? sanitizeActiveSessionId(activeTask, activeSessionIdByTask[activeTask.id], openDiffs, lastAgentSessionId) : null;
const activeSessionId = activeTask
? sanitizeActiveSessionId(activeTask, activeSessionIdByTask[activeTask.id] ?? activeTask.activeSessionId ?? null, openDiffs, lastAgentSessionId)
: null;
const selectedSessionHydrating = Boolean(
selectedSessionId && activeSessionId === selectedSessionId && sessionState.status === "loading" && !sessionState.data,
);
@ -1697,7 +1619,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const { taskId, sessionId } = await taskWorkspaceClient.createTask({
repoId,
task: options?.task ?? "New task",
model: "gpt-5.3-codex",
model: currentUser?.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
title: options?.title ?? "New task",
...(options?.branch ? { branch: options.branch } : {}),
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
@ -1712,7 +1634,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
});
})();
},
[navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
[currentUser?.defaultModel, navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
);
const openDiffTab = useCallback(
@ -1741,14 +1663,6 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const selectTask = useCallback(
(id: string) => {
if (isOpenPrTaskId(id)) {
const pullRequest = openPullRequestsByTaskId.get(id);
if (!pullRequest) {
return;
}
void materializeOpenPullRequest(pullRequest);
return;
}
const task = tasks.find((candidate) => candidate.id === id) ?? null;
void navigate({
to: "/organizations/$organizationId/tasks/$taskId",
@ -1759,7 +1673,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
search: { sessionId: task?.sessions[0]?.id ?? undefined },
});
},
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, organizationId],
[navigate, tasks, organizationId],
);
const markTaskUnread = useCallback(
@ -1904,7 +1818,6 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
};
if (!activeTask) {
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
return (
<>
{dragRegion}
@ -1935,9 +1848,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1979,7 +1890,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
gap: "12px",
}}
>
{activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? (
{liveGithub?.syncStatus === "syncing" || liveGithub?.syncStatus === "pending" ? (
<>
<div
className={css({
@ -2000,19 +1911,18 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
/>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Syncing with GitHub</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}...
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
{liveGithub.lastSyncLabel || `Importing repos from @${liveGithub.connectedAccount || "GitHub"}...`}
{liveGithub.totalRepositoryCount > 0 && (
<>
{" "}
{liveGithub.syncPhase === "syncing_repositories"
? `${liveGithub.importedRepoCount} of ${liveGithub.totalRepositoryCount} repos imported so far.`
: `${liveGithub.processedRepositoryCount} of ${liveGithub.totalRepositoryCount} repos processed in ${liveGithub.syncPhase?.replace(/^syncing_/, "").replace(/_/g, " ") ?? "sync"}.`}
</>
)}
</p>
</>
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
<>
<SpinnerDot />
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
</p>
</>
) : activeOrg?.github.syncStatus === "error" ? (
) : liveGithub?.syncStatus === "error" ? (
<>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
<p style={{ margin: 0, opacity: 0.75 }}>There was a problem syncing repos from GitHub. Check the dev panel for details.</p>
@ -2066,7 +1976,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
</div>
</div>
</Shell>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{liveGithub && <GithubInstallationWarning github={liveGithub} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
@ -2109,9 +2019,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -2163,9 +2071,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
@ -2181,6 +2087,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskWorkspaceClient={taskWorkspaceClient}
task={activeTask}
hasSandbox={hasSandbox}
modelGroups={modelGroups}
activeSessionId={activeSessionId}
lastAgentSessionId={lastAgentSessionId}
openDiffs={openDiffs}
@ -2233,7 +2140,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
</div>
</div>
</div>
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
{liveGithub && <GithubInstallationWarning github={liveGithub} css={css} t={t} />}
{showDevPanel && (
<DevPanel
organizationId={organizationId}
@ -2244,11 +2151,9 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
repoId: activeTask.repoId,
title: activeTask.title,
status: activeTask.status,
runtimeStatus: activeTask.runtimeStatus ?? null,
statusMessage: activeTask.statusMessage ?? null,
branch: activeTask.branch ?? null,
activeSandboxId: activeTask.activeSandboxId ?? null,
activeSessionId: selectedSessionId ?? activeTask.sessions[0]?.id ?? null,
activeSessionId: activeTask.activeSessionId ?? selectedSessionId ?? activeTask.sessions[0]?.id ?? null,
sandboxes: [],
sessions:
activeTask.sessions?.map((tab) => ({

View file

@ -2,18 +2,21 @@ import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronUp, Star } from "lucide-react";
import { workspaceModelLabel, type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { useFoundryTokens } from "../../app/theme";
import { AgentIcon } from "./ui";
import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model";
import { type ModelId } from "./view-model";
const ModelPickerContent = memo(function ModelPickerContent({
groups,
value,
defaultModel,
onChange,
onSetDefault,
close,
}: {
groups: WorkspaceModelGroup[];
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
@ -26,7 +29,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
return (
<div className={css({ minWidth: "220px", padding: "6px 0" })}>
{MODEL_GROUPS.map((group) => (
{groups.map((group) => (
<div key={group.provider}>
<div
className={css({
@ -44,7 +47,7 @@ const ModelPickerContent = memo(function ModelPickerContent({
const isActive = model.id === value;
const isDefault = model.id === defaultModel;
const isHovered = model.id === hoveredId;
const agent = providerAgent(group.provider);
const agent = group.agentKind;
return (
<div
@ -94,11 +97,13 @@ const ModelPickerContent = memo(function ModelPickerContent({
});
export const ModelPicker = memo(function ModelPicker({
groups,
value,
defaultModel,
onChange,
onSetDefault,
}: {
groups: WorkspaceModelGroup[];
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
@ -137,7 +142,9 @@ export const ModelPicker = memo(function ModelPicker({
},
},
}}
content={({ close }) => <ModelPickerContent value={value} defaultModel={defaultModel} onChange={onChange} onSetDefault={onSetDefault} close={close} />}
content={({ close }) => (
<ModelPickerContent groups={groups} value={value} defaultModel={defaultModel} onChange={onChange} onSetDefault={onSetDefault} close={close} />
)}
>
<div className={css({ display: "inline-flex" })}>
<button
@ -162,7 +169,7 @@ export const ModelPicker = memo(function ModelPicker({
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
{modelLabel(value)}
{workspaceModelLabel(value, groups)}
{(isHovered || isOpen) && <ChevronUp size={11} />}
</button>
</div>

View file

@ -2,6 +2,7 @@ import { memo, type Ref } from "react";
import { useStyletron } from "baseui";
import { ChatComposer, type ChatComposerClassNames } from "@sandbox-agent/react";
import { FileCode, SendHorizonal, Square, X } from "lucide-react";
import { type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
import { useFoundryTokens } from "../../app/theme";
import { ModelPicker } from "./model-picker";
@ -13,6 +14,7 @@ export const PromptComposer = memo(function PromptComposer({
textareaRef,
placeholder,
attachments,
modelGroups,
defaultModel,
model,
isRunning,
@ -27,6 +29,7 @@ export const PromptComposer = memo(function PromptComposer({
textareaRef: Ref<HTMLTextAreaElement>;
placeholder: string;
attachments: LineAttachment[];
modelGroups: WorkspaceModelGroup[];
defaultModel: ModelId;
model: ModelId;
isRunning: boolean;
@ -172,7 +175,7 @@ export const PromptComposer = memo(function PromptComposer({
renderSubmitContent={() => (isRunning ? <Square size={16} style={{ display: "block" }} /> : <SendHorizonal size={16} style={{ display: "block" }} />)}
renderFooter={() => (
<div className={css({ padding: "0 10px 8px" })}>
<ModelPicker value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
<ModelPicker groups={modelGroups} value={model} defaultModel={defaultModel} onChange={onChangeModel} onSetDefault={onSetDefaultModel} />
</div>
)}
/>

View file

@ -125,7 +125,7 @@ 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 pullRequestUrl = task.pullRequest?.url ?? null;
const copyFilePath = useCallback(async (path: string) => {
try {

View file

@ -54,10 +54,6 @@ function repositoryIconColor(label: string): string {
return REPOSITORY_COLORS[Math.abs(hash) % REPOSITORY_COLORS.length]!;
}
function isPullRequestSidebarItem(task: Task): boolean {
return task.id.startsWith("pr:");
}
export const Sidebar = memo(function Sidebar({
repositories,
newTaskRepos,
@ -72,9 +68,7 @@ export const Sidebar = memo(function Sidebar({
taskOrderByRepository,
onReorderTasks,
onReloadOrganization,
onReloadPullRequests,
onReloadRepository,
onReloadPullRequest,
onToggleSidebar,
}: {
repositories: RepositorySection[];
@ -90,9 +84,7 @@ export const Sidebar = memo(function Sidebar({
taskOrderByRepository: Record<string, string[]>;
onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => void;
onReloadOrganization: () => void;
onReloadPullRequests: () => void;
onReloadRepository: (repoId: string) => void;
onReloadPullRequest: (repoId: string, prNumber: number) => void;
onToggleSidebar?: () => void;
}) {
const [css] = useStyletron();
@ -444,16 +436,6 @@ export const Sidebar = memo(function Sidebar({
>
Reload organization
</button>
<button
type="button"
onClick={() => {
setHeaderMenuOpen(false);
onReloadPullRequests();
}}
className={css(menuButtonStyle(false, t))}
>
Reload all PRs
</button>
</div>
) : null}
<div
@ -665,15 +647,12 @@ export const Sidebar = memo(function Sidebar({
if (item.type === "task") {
const { repository, task, taskIndex } = item;
const isActive = task.id === activeId;
const isPullRequestItem = isPullRequestSidebarItem(task);
const isRunning = task.sessions.some((s) => s.status === "running");
const isProvisioning =
!isPullRequestItem &&
((String(task.status).startsWith("init_") && task.status !== "init_complete") ||
task.status === "new" ||
task.sessions.some((s) => s.status === "pending_provision" || s.status === "pending_session_create"));
(String(task.status).startsWith("init_") && task.status !== "init_complete") ||
task.sessions.some((s) => s.status === "pending_provision" || s.status === "pending_session_create");
const hasUnread = task.sessions.some((s) => s.unread);
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
const isDraft = task.pullRequest?.isDraft ?? true;
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
@ -718,17 +697,11 @@ export const Sidebar = memo(function Sidebar({
<div
onClick={() => onSelect(task.id)}
onContextMenu={(event) => {
if (isPullRequestItem && task.pullRequest) {
contextMenu.open(event, [
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
{ label: "Create task", onClick: () => onSelect(task.id) },
]);
return;
}
contextMenu.open(event, [
const items = [
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
]);
];
contextMenu.open(event, items);
}}
className={css({
padding: "8px 12px",
@ -753,11 +726,7 @@ export const Sidebar = memo(function Sidebar({
flexShrink: 0,
})}
>
{isPullRequestItem ? (
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
) : (
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
)}
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
<LabelSmall
@ -773,18 +742,13 @@ export const Sidebar = memo(function Sidebar({
>
{task.title}
</LabelSmall>
{isPullRequestItem && task.statusMessage ? (
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{task.statusMessage}
</LabelXSmall>
) : null}
</div>
{task.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
#{task.pullRequest.number}
</LabelXSmall>
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color={t.accent} /> : null}
{task.pullRequest.isDraft ? <CloudUpload size={11} color={t.accent} /> : null}
</span>
) : (
<GitPullRequestDraft size={11} color={t.textTertiary} />

View file

@ -49,10 +49,9 @@ export const TranscriptHeader = memo(function TranscriptHeader({
const t = useFoundryTokens();
const isDesktop = !!import.meta.env.VITE_DESKTOP;
const needsTrafficLightInset = isDesktop && sidebarCollapsed;
const taskStatus = task.runtimeStatus ?? task.status;
const headerStatus = useMemo(
() => deriveHeaderStatus(taskStatus, task.statusMessage ?? null, activeSession?.status ?? null, activeSession?.errorMessage ?? null, hasSandbox),
[taskStatus, task.statusMessage, activeSession?.status, activeSession?.errorMessage, hasSandbox],
() => deriveHeaderStatus(task.status, activeSession?.status ?? null, activeSession?.errorMessage ?? null, hasSandbox),
[task.status, activeSession?.status, activeSession?.errorMessage, hasSandbox],
);
return (

View file

@ -181,6 +181,8 @@ export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent:
return <OpenAIIcon size={size} />;
case "Cursor":
return <CursorIcon size={size} />;
default:
return <CursorIcon size={size} />;
}
});

View file

@ -1,3 +1,8 @@
import {
DEFAULT_WORKSPACE_MODEL_GROUPS as SharedModelGroups,
workspaceModelLabel as sharedWorkspaceModelLabel,
workspaceProviderAgent as sharedWorkspaceProviderAgent,
} from "@sandbox-agent/foundry-shared";
import type {
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
@ -17,26 +22,7 @@ import { extractEventText } from "../../features/sessions/model";
export type { RepositorySection };
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
{ id: "gpt-5.4", label: "GPT-5.4" },
{ id: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.2", label: "GPT-5.2" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
],
},
];
export const MODEL_GROUPS: ModelGroup[] = SharedModelGroups;
export function formatRelativeAge(updatedAtMs: number, nowMs = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((nowMs - updatedAtMs) / 1000));
@ -94,15 +80,11 @@ export function formatMessageDuration(durationMs: number): string {
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
return sharedWorkspaceModelLabel(id, MODEL_GROUPS);
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
return sharedWorkspaceProviderAgent(provider);
}
const DIFF_PREFIX = "diff:";

View file

@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared";
import type { RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared";
import { currentFoundryOrganization, useSubscription } from "@sandbox-agent/foundry-client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
@ -14,7 +14,6 @@ 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 } 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";
@ -95,25 +94,13 @@ const FILTER_OPTIONS: SelectItem[] = [
{ id: "all", label: "All Branches" },
];
const AGENT_OPTIONS: SelectItem[] = [
{ id: "codex", label: "codex" },
{ id: "claude", label: "claude" },
];
function statusKind(status: WorkspaceTaskStatus): StatusTagKind {
if (status === "running") return "positive";
if (status === "error") return "negative";
if (status === "new" || String(status).startsWith("init_")) return "warning";
if (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" });
}
@ -160,7 +147,7 @@ function repoSummary(overview: RepoOverview | undefined): {
if (row.taskId) {
mapped += 1;
}
if (row.prNumber && row.prState !== "MERGED" && row.prState !== "CLOSED") {
if (row.pullRequest && row.pullRequest.state !== "MERGED" && row.pullRequest.state !== "CLOSED") {
openPrs += 1;
}
}
@ -174,15 +161,25 @@ function repoSummary(overview: RepoOverview | undefined): {
}
function branchKind(row: RepoBranchRecord): StatusTagKind {
if (row.prState === "OPEN" || row.prState === "DRAFT") {
if (row.pullRequest?.isDraft || row.pullRequest?.state === "OPEN") {
return "warning";
}
if (row.prState === "MERGED") {
if (row.pullRequest?.state === "MERGED") {
return "positive";
}
return "neutral";
}
function branchPullRequestLabel(branch: RepoBranchRecord): string {
if (!branch.pullRequest) {
return "no pr";
}
if (branch.pullRequest.isDraft) {
return "draft";
}
return branch.pullRequest.state.toLowerCase();
}
function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean {
if (filter === "archived") {
return branch.taskStatus === "archived";
@ -332,14 +329,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
const [createTaskOpen, setCreateTaskOpen] = useState(false);
const [selectedOverviewBranch, setSelectedOverviewBranch] = useState<string | null>(null);
const [overviewFilter, setOverviewFilter] = useState<RepoOverviewFilter>("active");
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 appState = useSubscription(subscriptionManager, "app", {});
@ -383,14 +372,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}
}, [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) {
@ -451,10 +432,10 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}, [selectedForSession?.id]);
const sessionRows = selectedForSession?.sessionsSummary ?? [];
const taskRuntimeStatus = selectedForSession?.runtimeStatus ?? selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskRuntimeStatus, selectedForSession?.statusMessage ?? null);
const taskStatus = selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskStatus);
const taskStateSummary = `${taskStatusState.title}. ${taskStatusState.detail}`;
const shouldUseTaskStateEmptyState = Boolean(selectedForSession && taskRuntimeStatus && taskRuntimeStatus !== "running" && taskRuntimeStatus !== "idle");
const shouldUseTaskStateEmptyState = Boolean(selectedForSession && taskStatus && taskStatus !== "running" && taskStatus !== "idle");
const sessionSelection = useMemo(
() =>
resolveSessionSelection({
@ -505,8 +486,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
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,
@ -524,8 +503,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
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,
@ -553,13 +530,15 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this task");
}
const preferredAgent =
selectedSessionSummary?.agent === "Claude" ? "claude" : selectedSessionSummary?.agent === "Codex" ? "codex" : undefined;
return backendClient.createSandboxSession({
organizationId,
sandboxProviderId: activeSandbox.sandboxProviderId,
sandboxId: activeSandbox.sandboxId,
prompt: selectedForSession.task,
cwd: activeSandbox.cwd ?? undefined,
agent: normalizeAgent(selectedForSession.agentType),
agent: preferredAgent,
});
};
@ -616,7 +595,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
organizationId,
repoId,
task,
agentType: newAgentType,
explicitTitle: draftTitle || undefined,
explicitBranchName: createOnBranch ? undefined : draftBranchName || undefined,
onBranch: createOnBranch ?? undefined,
@ -656,7 +634,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
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],
@ -1057,23 +1034,23 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
</div>
<div className={cellClass}>{branch.taskTitle ?? branch.taskId ?? "-"}</div>
<div className={cellClass}>
{branch.prNumber ? (
{branch.pullRequest ? (
<a
href={branch.prUrl ?? undefined}
href={branch.pullRequest.url}
target="_blank"
rel="noreferrer"
className={css({
color: theme.colors.contentPrimary,
})}
>
#{branch.prNumber} {branch.prState ?? "open"}
#{branch.pullRequest.number} {branchPullRequestLabel(branch)}
</a>
) : (
<span className={css({ color: theme.colors.contentSecondary })}>-</span>
)}
</div>
<div className={cellClass}>
{branch.ciStatus ?? "-"} / {branch.reviewStatus ?? "-"}
{branch.ciStatus ?? "-"} / {branch.pullRequest ? (branch.pullRequest.isDraft ? "draft" : "ready") : "-"}
</div>
<div className={cellClass}>{formatRelativeAge(branch.updatedAt)}</div>
<div className={cellClass}>
@ -1098,7 +1075,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
</Button>
) : null}
<StatusPill kind={branchKind(branch)}>{branch.prState?.toLowerCase() ?? "no pr"}</StatusPill>
<StatusPill kind={branchKind(branch)}>{branchPullRequestLabel(branch)}</StatusPill>
</div>
</div>
</div>
@ -1138,7 +1115,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<HeaderStatusPill
status={deriveHeaderStatus(
taskRuntimeStatus ?? selectedForSession.status,
selectedForSession.statusMessage ?? null,
selectedSessionSummary?.status ?? null,
selectedSessionSummary?.errorMessage ?? null,
Boolean(activeSandbox?.sandboxId),
@ -1266,8 +1242,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{shouldUseTaskStateEmptyState
? taskStateSummary
: (selectedForSession?.statusMessage ??
(isPendingProvision ? "The task is still provisioning." : "The session is being created."))}
: (isPendingProvision ? "The task is still provisioning." : "The session is being created.")}
</ParagraphSmall>
</div>
) : null}
@ -1277,15 +1252,13 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
{shouldUseTaskStateEmptyState
? taskStateSummary
: isPendingProvision
? (selectedForSession.statusMessage ?? "Provisioning sandbox...")
? "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."
? "This task is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
@ -1458,7 +1431,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
<MetaRow label="Branch" value={selectedBranchOverview.branchName} mono />
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
<MetaRow label="Task" value={selectedBranchOverview.taskTitle ?? selectedBranchOverview.taskId ?? "-"} />
<MetaRow label="PR" value={selectedBranchOverview.prUrl ?? "-"} />
<MetaRow label="PR" value={selectedBranchOverview.pullRequest?.url ?? "-"} />
<MetaRow label="Updated" value={new Date(selectedBranchOverview.updatedAt).toLocaleTimeString()} />
</div>
)}
@ -1504,9 +1477,8 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
})}
>
<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 ?? "-"} />
<MetaRow label="PR" value={selectedForSession.pullRequest?.url ?? "-"} />
<MetaRow label="Review" value={selectedForSession.pullRequest ? (selectedForSession.pullRequest.isDraft ? "draft" : "ready") : "-"} />
</div>
</section>
@ -1607,25 +1579,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
) : 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