chore(foundry): migrate to actions (#262)

* feat(foundry): checkpoint actor and workspace refactor

* docs(foundry): add agent handoff context

* wip(foundry): continue actor refactor

* wip(foundry): capture remaining local changes

* Complete Foundry refactor checklist

* Fix Foundry validation fallout

* wip

* wip: convert all actors from workflow to plain run handlers

Workaround for RivetKit bug where c.queue.iter() never yields messages
for actors created via getOrCreate from another actor's context. The
queue accepts messages (visible in inspector) but the iterator hangs.
Sleep/wake fixes it, but actors with active connections never sleep.

Converted organization, github-data, task, and user actors from
run: workflow(...) to plain run: async (c) => { for await ... }.

Also fixes:
- Missing auth tables in org migration (auth_verification etc)
- default_model NOT NULL constraint on org profile upsert
- Nested workflow step in github-data (HistoryDivergedError)
- Removed --force from frontend Dockerfile pnpm install

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Convert all actors from queues/workflows to direct actions, lazy task creation

Major refactor replacing all queue-based workflow communication with direct
RivetKit action calls across all actors. This works around a RivetKit bug
where c.queue.iter() deadlocks for actors created from another actor's context.

Key changes:
- All actors (organization, task, user, audit-log, github-data) converted
  from run: workflow(...) to actions-only (no run handler, no queues)
- PR sync creates virtual task entries in org local DB instead of spawning
  task actors — prevents OOM from 200+ actors created simultaneously
- Task actors created lazily on first user interaction via getOrCreate,
  self-initialize from org's getTaskIndexEntry data
- Removed requireRepoExists cross-actor call (caused 500s), replaced with
  local resolveTaskRepoId from org's taskIndex table
- Fixed getOrganizationContext to thread overrides through all sync phases
- Fixed sandbox repo path (/home/user/repo for E2B compatibility)
- Fixed buildSessionDetail to skip transcript fetch for pending sessions
- Added process crash protection (uncaughtException/unhandledRejection)
- Fixed React infinite render loop in mock-layout useEffect dependencies
- Added sandbox listProcesses error handling for expired E2B sandboxes
- Set E2B sandbox timeout to 1 hour (was 5 min default)
- Updated CLAUDE.md with lazy task creation rules, no-silent-catch policy,
  React hook dependency safety rules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix E2B sandbox timeout comment, frontend stability, and create-flow improvements

- Add TEMPORARY comment on E2B timeoutMs with pointer to rivetkit sandbox
  resilience proposal for when autoPause lands
- Fix React useEffect dependency stability in mock-layout and
  organization-dashboard to prevent infinite re-render loops
- Fix terminal-pane ref handling
- Improve create-flow service and tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-16 15:23:59 -07:00 committed by GitHub
parent 32f3c6c3bc
commit f45a467484
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 9768 additions and 7204 deletions

View file

@ -6,11 +6,10 @@ import { subscriptionManager } from "../lib/subscription";
import type {
FoundryAppSnapshot,
FoundryOrganization,
TaskStatus,
TaskWorkbenchSnapshot,
WorkbenchSandboxSummary,
WorkbenchSessionSummary,
WorkbenchTaskStatus,
TaskWorkspaceSnapshot,
WorkspaceSandboxSummary,
WorkspaceSessionSummary,
WorkspaceTaskStatus,
} from "@sandbox-agent/foundry-shared";
import { useSubscription } from "@sandbox-agent/foundry-client";
import type { DebugSubscriptionTopic } from "@sandbox-agent/foundry-client";
@ -18,7 +17,7 @@ import { describeTaskState } from "../features/tasks/status";
interface DevPanelProps {
organizationId: string;
snapshot: TaskWorkbenchSnapshot;
snapshot: TaskWorkspaceSnapshot;
organization?: FoundryOrganization | null;
focusedTask?: DevPanelFocusedTask | null;
}
@ -27,14 +26,12 @@ export interface DevPanelFocusedTask {
id: string;
repoId: string;
title: string | null;
status: WorkbenchTaskStatus;
runtimeStatus?: TaskStatus | null;
statusMessage?: string | null;
status: WorkspaceTaskStatus;
branch?: string | null;
activeSandboxId?: string | null;
activeSessionId?: string | null;
sandboxes?: WorkbenchSandboxSummary[];
sessions?: WorkbenchSessionSummary[];
sandboxes?: WorkspaceSandboxSummary[];
sessions?: WorkspaceSessionSummary[];
}
interface TopicInfo {
@ -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,15 +464,13 @@ 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" })}>
<span
@ -488,21 +485,24 @@ 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 ?? 0} t={t} css={css} />
</div>
{organization.github.connectedAccount && (
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
)}
{organization.github.lastSyncLabel && (
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
{liveGithub.connectedAccount && <div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{liveGithub.connectedAccount}</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,14 +1,17 @@
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 TaskWorkbenchSnapshot,
type WorkbenchOpenPrSummary,
type WorkbenchSessionSummary,
type WorkbenchTaskDetail,
type WorkbenchTaskSummary,
type TaskWorkspaceSnapshot,
type WorkspaceModelGroup,
type WorkspaceSessionSummary,
type WorkspaceTaskDetail,
type WorkspaceTaskSummary,
} from "@sandbox-agent/foundry-shared";
import { useSubscription } from "@sandbox-agent/foundry-client";
@ -39,7 +42,7 @@ import {
type Message,
type ModelId,
} from "./mock-layout/view-model";
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
import { activeMockOrganization, activeMockUser, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
import { backendClient } from "../lib/backend";
import { subscriptionManager } from "../lib/subscription";
import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status";
@ -77,29 +80,38 @@ 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,15 +135,15 @@ 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>
);
}
function toSessionModel(
summary: WorkbenchSessionSummary,
summary: WorkspaceSessionSummary,
sessionDetail?: { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] },
): Task["sessions"][number] {
return {
@ -155,8 +167,8 @@ function toSessionModel(
}
function toTaskModel(
summary: WorkbenchTaskSummary,
detail?: WorkbenchTaskDetail,
summary: WorkspaceTaskSummary,
detail?: WorkspaceTaskDetail,
sessionCache?: Map<string, { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }>,
): Task {
const sessions = detail?.sessionsSummary ?? summary.sessionsSummary;
@ -164,13 +176,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 +191,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: WorkbenchOpenPrSummary): 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;
@ -230,18 +207,41 @@ function sessionStateMessage(tab: Task["sessions"][number] | null | undefined):
return null;
}
function groupRepositories(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
function groupRepositories(
repos: Array<{ id: string; label: string }>,
tasks: Task[],
openPullRequests?: Array<{
repoId: string;
repoFullName: string;
number: number;
title: string;
state: string;
url: string;
headRefName: string;
authorLogin: string | null;
isDraft: boolean;
}>,
) {
return repos
.map((repo) => ({
id: repo.id,
label: repo.label,
updatedAtMs: tasks.filter((task) => task.repoId === repo.id).reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0),
tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs),
pullRequests: (openPullRequests ?? []).filter((pr) => pr.repoId === repo.id),
}))
.filter((repo) => repo.tasks.length > 0);
.sort((a, b) => {
// Repos with tasks first, then repos with PRs, then alphabetical
const aHasActivity = a.tasks.length > 0 || a.pullRequests.length > 0;
const bHasActivity = b.tasks.length > 0 || b.pullRequests.length > 0;
if (aHasActivity && !bHasActivity) return -1;
if (!aHasActivity && bHasActivity) return 1;
if (a.updatedAtMs !== b.updatedAtMs) return b.updatedAtMs - a.updatedAtMs;
return a.label.localeCompare(b.label);
});
}
interface WorkbenchActions {
interface WorkspaceActions {
createTask(input: {
repoId: string;
task: string;
@ -250,28 +250,26 @@ interface WorkbenchActions {
onBranch?: string;
model?: ModelId;
}): Promise<{ taskId: string; sessionId?: string }>;
markTaskUnread(input: { taskId: string }): Promise<void>;
renameTask(input: { taskId: string; value: string }): Promise<void>;
renameBranch(input: { taskId: string; value: string }): Promise<void>;
archiveTask(input: { taskId: string }): Promise<void>;
publishPr(input: { taskId: string }): Promise<void>;
revertFile(input: { taskId: string; path: string }): Promise<void>;
updateDraft(input: { taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
sendMessage(input: { taskId: string; sessionId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
stopAgent(input: { taskId: string; sessionId: string }): Promise<void>;
setSessionUnread(input: { taskId: string; sessionId: string; unread: boolean }): Promise<void>;
renameSession(input: { taskId: string; sessionId: string; title: string }): Promise<void>;
closeSession(input: { taskId: string; sessionId: string }): Promise<void>;
addSession(input: { taskId: string; model?: string }): Promise<{ sessionId: string }>;
changeModel(input: { taskId: string; sessionId: string; model: ModelId }): Promise<void>;
reloadGithubOrganization(): Promise<void>;
reloadGithubPullRequests(): Promise<void>;
reloadGithubRepository(repoId: string): Promise<void>;
reloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
markTaskUnread(input: { repoId: string; taskId: string }): Promise<void>;
renameTask(input: { repoId: string; taskId: string; value: string }): Promise<void>;
archiveTask(input: { repoId: string; taskId: string }): Promise<void>;
publishPr(input: { repoId: string; taskId: string }): Promise<void>;
revertFile(input: { repoId: string; taskId: string; path: string }): Promise<void>;
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>;
adminReloadGithubRepository(repoId: string): Promise<void>;
}
const TranscriptPanel = memo(function TranscriptPanel({
taskWorkbenchClient,
taskWorkspaceClient,
task,
hasSandbox,
activeSessionId,
@ -288,9 +286,10 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed,
onToggleRightSidebar,
selectedSessionHydrating = false,
modelGroups,
onNavigateToUsage,
}: {
taskWorkbenchClient: WorkbenchActions;
taskWorkspaceClient: WorkspaceActions;
task: Task;
hasSandbox: boolean;
activeSessionId: string | null;
@ -307,11 +306,15 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
selectedSessionHydrating?: boolean;
modelGroups: WorkspaceModelGroup[];
onNavigateToUsage?: () => void;
}) {
const t = useFoundryTokens();
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4");
const [editingField, setEditingField] = useState<"title" | "branch" | null>(null);
const appSnapshot = useMockAppSnapshot();
const appClient = useMockAppClient();
const currentUser = activeMockUser(appSnapshot);
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);
const [editingSessionName, setEditingSessionName] = useState("");
@ -333,9 +336,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 =
@ -344,16 +346,17 @@ const TranscriptPanel = memo(function TranscriptPanel({
(activeAgentSession.status === "pending_provision" || activeAgentSession.status === "pending_session_create" || activeAgentSession.status === "error") &&
activeMessages.length === 0;
const serverDraft = promptSession?.draft.text ?? "";
const serverAttachments = promptSession?.draft.attachments ?? [];
const serverAttachments = promptSession?.draft.attachments;
const serverAttachmentsJson = JSON.stringify(serverAttachments ?? []);
// Sync server → local only when user hasn't typed recently (3s cooldown)
const DRAFT_SYNC_COOLDOWN_MS = 3_000;
useEffect(() => {
if (Date.now() - lastEditTimeRef.current > DRAFT_SYNC_COOLDOWN_MS) {
setLocalDraft(serverDraft);
setLocalAttachments(serverAttachments);
setLocalAttachments(serverAttachments ?? []);
}
}, [serverDraft, serverAttachments]);
}, [serverDraft, serverAttachmentsJson]);
// Reset local draft immediately on session/task switch
useEffect(() => {
@ -436,14 +439,15 @@ const TranscriptPanel = memo(function TranscriptPanel({
return;
}
void taskWorkbenchClient.setSessionUnread({
void taskWorkspaceClient.setSessionUnread({
repoId: task.repoId,
taskId: task.id,
sessionId: activeAgentSession.id,
unread: false,
});
}, [activeAgentSession?.id, activeAgentSession?.unread, task.id]);
const startEditingField = useCallback((field: "title" | "branch", value: string) => {
const startEditingField = useCallback((field: "title", value: string) => {
setEditingField(field);
setEditValue(value);
}, []);
@ -453,18 +457,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
}, []);
const commitEditingField = useCallback(
(field: "title" | "branch") => {
(field: "title") => {
const value = editValue.trim();
if (!value) {
setEditingField(null);
return;
}
if (field === "title") {
void taskWorkbenchClient.renameTask({ taskId: task.id, value });
} else {
void taskWorkbenchClient.renameBranch({ taskId: task.id, value });
}
void taskWorkspaceClient.renameTask({ repoId: task.repoId, taskId: task.id, value });
setEditingField(null);
},
[editValue, task.id],
@ -474,7 +474,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
const flushDraft = useCallback(
(text: string, nextAttachments: LineAttachment[], sessionId: string) => {
void taskWorkbenchClient.updateDraft({
void taskWorkspaceClient.updateDraft({
repoId: task.repoId,
taskId: task.id,
sessionId,
text,
@ -535,7 +536,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSetActiveSessionId(promptSession.id);
onSetLastAgentSessionId(promptSession.id);
void taskWorkbenchClient.sendMessage({
void taskWorkspaceClient.sendMessage({
repoId: task.repoId,
taskId: task.id,
sessionId: promptSession.id,
text,
@ -548,7 +550,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
return;
}
void taskWorkbenchClient.stopAgent({
void taskWorkspaceClient.stopAgent({
repoId: task.repoId,
taskId: task.id,
sessionId: promptSession.id,
});
@ -560,9 +563,15 @@ 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 taskWorkbenchClient.setSessionUnread({
void taskWorkspaceClient.setSessionUnread({
repoId: task.repoId,
taskId: task.id,
sessionId,
unread: false,
@ -571,14 +580,14 @@ 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(
(sessionId: string, unread: boolean) => {
void taskWorkbenchClient.setSessionUnread({ taskId: task.id, sessionId, unread });
void taskWorkspaceClient.setSessionUnread({ repoId: task.repoId, taskId: task.id, sessionId, unread });
},
[task.id],
[task.id, task.repoId],
);
const startRenamingSession = useCallback(
@ -610,7 +619,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
return;
}
void taskWorkbenchClient.renameSession({
void taskWorkspaceClient.renameSession({
repoId: task.repoId,
taskId: task.id,
sessionId: editingSessionId,
title: trimmedName,
@ -631,9 +641,9 @@ const TranscriptPanel = memo(function TranscriptPanel({
}
onSyncRouteSession(task.id, nextSessionId);
void taskWorkbenchClient.closeSession({ taskId: task.id, sessionId });
void taskWorkspaceClient.closeSession({ repoId: task.repoId, taskId: task.id, sessionId });
},
[activeSessionId, task.id, task.sessions, lastAgentSessionId, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
[activeSessionId, task.id, task.repoId, task.sessions, lastAgentSessionId, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession],
);
const closeDiffTab = useCallback(
@ -651,12 +661,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
const addSession = useCallback(() => {
void (async () => {
const { sessionId } = await taskWorkbenchClient.addSession({ taskId: task.id });
const { sessionId } = await taskWorkspaceClient.addSession({ repoId: task.repoId, taskId: task.id });
onSetLastAgentSessionId(sessionId);
onSetActiveSessionId(sessionId);
onSyncRouteSession(task.id, sessionId);
})();
}, [task.id, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession]);
}, [task.id, task.repoId, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession]);
const changeModel = useCallback(
(model: ModelId) => {
@ -664,7 +674,8 @@ const TranscriptPanel = memo(function TranscriptPanel({
throw new Error(`Unable to change model for task ${task.id} without an active prompt session`);
}
void taskWorkbenchClient.changeModel({
void taskWorkspaceClient.changeModel({
repoId: task.repoId,
taskId: task.id,
sessionId: promptSession.id,
model,
@ -939,7 +950,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
messageRefs={messageRefs}
historyEvents={historyEvents}
onSelectHistoryEvent={jumpToHistoryEvent}
targetMessageId={pendingHistoryTarget && activeSessionId === pendingHistoryTarget.sessionId ? pendingHistoryTarget.messageId : null}
targetMessageId={pendingHistoryTarget && activeAgentSession?.id === pendingHistoryTarget.sessionId ? pendingHistoryTarget.messageId : null}
onTargetMessageResolved={() => setPendingHistoryTarget(null)}
copiedMessageId={copiedMessageId}
onCopyMessage={(message) => {
@ -958,6 +969,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"}
@ -966,7 +978,9 @@ const TranscriptPanel = memo(function TranscriptPanel({
onStop={stopAgent}
onRemoveAttachment={removeAttachment}
onChangeModel={changeModel}
onSetDefaultModel={setDefaultModel}
onSetDefaultModel={(model) => {
void appClient.setDefaultModel(model);
}}
/>
) : null}
</div>
@ -1280,45 +1294,37 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
const [css] = useStyletron();
const t = useFoundryTokens();
const navigate = useNavigate();
const taskWorkbenchClient = useMemo<WorkbenchActions>(
const taskWorkspaceClient = useMemo<WorkspaceActions>(
() => ({
createTask: (input) => backendClient.createWorkbenchTask(organizationId, input),
markTaskUnread: (input) => backendClient.markWorkbenchUnread(organizationId, input),
renameTask: (input) => backendClient.renameWorkbenchTask(organizationId, input),
renameBranch: (input) => backendClient.renameWorkbenchBranch(organizationId, input),
archiveTask: async (input) => backendClient.runAction(organizationId, input.taskId, "archive"),
publishPr: (input) => backendClient.publishWorkbenchPr(organizationId, input),
revertFile: (input) => backendClient.revertWorkbenchFile(organizationId, input),
updateDraft: (input) => backendClient.updateWorkbenchDraft(organizationId, input),
sendMessage: (input) => backendClient.sendWorkbenchMessage(organizationId, input),
stopAgent: (input) => backendClient.stopWorkbenchSession(organizationId, input),
setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(organizationId, input),
renameSession: (input) => backendClient.renameWorkbenchSession(organizationId, input),
closeSession: (input) => backendClient.closeWorkbenchSession(organizationId, input),
addSession: (input) => backendClient.createWorkbenchSession(organizationId, input),
changeModel: (input) => backendClient.changeWorkbenchModel(organizationId, input),
reloadGithubOrganization: () => backendClient.reloadGithubOrganization(organizationId),
reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(organizationId),
reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(organizationId, repoId),
reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(organizationId, repoId, prNumber),
createTask: (input) => backendClient.createWorkspaceTask(organizationId, input),
markTaskUnread: (input) => backendClient.markWorkspaceUnread(organizationId, input),
renameTask: (input) => backendClient.renameWorkspaceTask(organizationId, input),
archiveTask: async (input) => backendClient.runAction(organizationId, input.repoId, input.taskId, "archive"),
publishPr: (input) => backendClient.publishWorkspacePr(organizationId, input),
revertFile: (input) => backendClient.revertWorkspaceFile(organizationId, input),
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),
adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId),
}),
[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 organizationReposData = organizationState.data?.repos;
const taskSummariesData = organizationState.data?.taskSummaries;
const openPullRequestsData = organizationState.data?.openPullRequests;
const organizationRepos = organizationReposData ?? [];
const taskSummaries = taskSummariesData ?? [];
const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries],
[selectedTaskId, taskSummariesData],
);
const taskState = useSubscription(
subscriptionManager,
@ -1359,6 +1365,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) {
@ -1383,12 +1403,14 @@ 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]);
const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks), [tasks, organizationRepos]);
return hydratedTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummariesData, organizationId]);
const openPullRequests = openPullRequestsData ?? [];
const rawRepositories = useMemo(() => groupRepositories(organizationRepos, tasks, openPullRequests), [tasks, organizationReposData, openPullRequestsData]);
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 });
@ -1413,11 +1435,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);
@ -1484,80 +1504,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: WorkbenchOpenPrSummary) => {
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
return;
}
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
setMaterializingOpenPrId(pullRequest.prId);
try {
const { taskId, sessionId } = await taskWorkbenchClient.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, taskWorkbenchClient, 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;
@ -1574,11 +1531,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,
);
@ -1635,6 +1594,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
}, [activeTask, lastAgentSessionIdByTask, selectedSessionId, syncRouteSession]);
useEffect(() => {
const organizationRepos = organizationReposData ?? [];
if (selectedNewTaskRepoId && organizationRepos.some((repo) => repo.id === selectedNewTaskRepoId)) {
return;
}
@ -1644,7 +1604,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
if (fallbackRepoId !== selectedNewTaskRepoId) {
setSelectedNewTaskRepoId(fallbackRepoId);
}
}, [activeTask?.repoId, selectedNewTaskRepoId, organizationRepos]);
}, [activeTask?.repoId, selectedNewTaskRepoId, organizationReposData]);
useEffect(() => {
if (!activeTask) {
@ -1664,7 +1624,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
autoCreatingSessionForTaskRef.current.add(activeTask.id);
void (async () => {
try {
const { sessionId } = await taskWorkbenchClient.addSession({ taskId: activeTask.id });
const { sessionId } = await taskWorkspaceClient.addSession({ repoId: activeTask.repoId, taskId: activeTask.id });
syncRouteSession(activeTask.id, sessionId, true);
} catch (error) {
logger.error(
@ -1672,13 +1632,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
taskId: activeTask.id,
...createErrorContext(error),
},
"failed_to_auto_create_workbench_session",
"failed_to_auto_create_workspace_session",
);
// Keep the guard in the set on error to prevent retry storms.
// The guard is cleared when sessions appear (line above) or the task changes.
}
})();
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkspaceClient]);
const createTask = useCallback(
(overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => {
@ -1688,10 +1648,10 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
throw new Error("Cannot create a task without an available repo");
}
const { taskId, sessionId } = await taskWorkbenchClient.createTask({
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 } : {}),
@ -1706,7 +1666,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
});
})();
},
[navigate, selectedNewTaskRepoId, taskWorkbenchClient, organizationId],
[currentUser?.defaultModel, navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId],
);
const openDiffTab = useCallback(
@ -1735,14 +1695,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",
@ -1753,12 +1705,19 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
search: { sessionId: task?.sessions[0]?.id ?? undefined },
});
},
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, organizationId],
[navigate, tasks, organizationId],
);
const markTaskUnread = useCallback((id: string) => {
void taskWorkbenchClient.markTaskUnread({ taskId: id });
}, []);
const markTaskUnread = useCallback(
(id: string) => {
const task = tasks.find((candidate) => candidate.id === id);
if (!task) {
return;
}
void taskWorkspaceClient.markTaskUnread({ repoId: task.repoId, taskId: id });
},
[tasks],
);
const renameTask = useCallback(
(id: string) => {
@ -1777,29 +1736,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
return;
}
void taskWorkbenchClient.renameTask({ taskId: id, value: trimmedTitle });
},
[tasks],
);
const renameBranch = useCallback(
(id: string) => {
const currentTask = tasks.find((task) => task.id === id);
if (!currentTask) {
throw new Error(`Unable to rename missing task ${id}`);
}
const nextBranch = window.prompt("Rename branch", currentTask.branch ?? "");
if (nextBranch === null) {
return;
}
const trimmedBranch = nextBranch.trim();
if (!trimmedBranch) {
return;
}
void taskWorkbenchClient.renameBranch({ taskId: id, value: trimmedBranch });
void taskWorkspaceClient.renameTask({ repoId: currentTask.repoId, taskId: id, value: trimmedTitle });
},
[tasks],
);
@ -1808,14 +1745,14 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
if (!activeTask) {
throw new Error("Cannot archive without an active task");
}
void taskWorkbenchClient.archiveTask({ taskId: activeTask.id });
void taskWorkspaceClient.archiveTask({ repoId: activeTask.repoId, taskId: activeTask.id });
}, [activeTask]);
const publishPr = useCallback(() => {
if (!activeTask) {
throw new Error("Cannot publish PR without an active task");
}
void taskWorkbenchClient.publishPr({ taskId: activeTask.id });
void taskWorkspaceClient.publishPr({ repoId: activeTask.repoId, taskId: activeTask.id });
}, [activeTask]);
const revertFile = useCallback(
@ -1835,7 +1772,8 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
: (current[activeTask.id] ?? null),
}));
void taskWorkbenchClient.revertFile({
void taskWorkspaceClient.revertFile({
repoId: activeTask.repoId,
taskId: activeTask.id,
path,
});
@ -1912,7 +1850,6 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
};
if (!activeTask) {
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
return (
<>
{dragRegion}
@ -1939,14 +1876,11 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -1988,7 +1922,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({
@ -2009,19 +1943,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) > 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>
@ -2075,11 +2008,11 @@ 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}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkbenchSnapshot}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkspaceSnapshot}
organization={activeOrg}
focusedTask={null}
/>
@ -2114,14 +2047,11 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onToggleSidebar={() => setLeftSidebarOpen(false)}
/>
</div>
@ -2169,14 +2099,11 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderRepositories={reorderRepositories}
taskOrderByRepository={taskOrderByRepository}
onReorderTasks={reorderTasks}
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()}
onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
@ -2189,9 +2116,10 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId }
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<TranscriptPanel
taskWorkbenchClient={taskWorkbenchClient}
taskWorkspaceClient={taskWorkspaceClient}
task={activeTask}
hasSandbox={hasSandbox}
modelGroups={modelGroups}
activeSessionId={activeSessionId}
lastAgentSessionId={lastAgentSessionId}
openDiffs={openDiffs}
@ -2244,22 +2172,20 @@ 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}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkbenchSnapshot}
snapshot={{ organizationId, repos: organizationRepos, repositories: rawRepositories, tasks } as TaskWorkspaceSnapshot}
organization={activeOrg}
focusedTask={{
id: activeTask.id,
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,
@ -68,14 +64,11 @@ export const Sidebar = memo(function Sidebar({
onSelectNewTaskRepo,
onMarkUnread,
onRenameTask,
onRenameBranch,
onReorderRepositories,
taskOrderByRepository,
onReorderTasks,
onReloadOrganization,
onReloadPullRequests,
onReloadRepository,
onReloadPullRequest,
onToggleSidebar,
}: {
repositories: RepositorySection[];
@ -87,14 +80,11 @@ export const Sidebar = memo(function Sidebar({
onSelectNewTaskRepo: (repoId: string) => void;
onMarkUnread: (id: string) => void;
onRenameTask: (id: string) => void;
onRenameBranch: (id: string) => void;
onReorderRepositories: (fromIndex: number, toIndex: number) => void;
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();
@ -446,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
@ -526,6 +506,7 @@ export const Sidebar = memo(function Sidebar({
return (
<div
key={item.key}
data-index={virtualItem.index}
data-repository-idx={repositoryIndex}
ref={(node) => {
if (node) {
@ -667,15 +648,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;
@ -686,6 +664,7 @@ export const Sidebar = memo(function Sidebar({
return (
<div
key={item.key}
data-index={virtualItem.index}
data-task-idx={taskIndex}
data-task-repository-id={repository.id}
ref={(node) => {
@ -720,18 +699,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: "Rename branch", onClick: () => onRenameBranch(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
]);
];
contextMenu.open(event, items);
}}
className={css({
padding: "8px 12px",
@ -756,11 +728,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
@ -776,18 +744,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} />
@ -814,6 +777,7 @@ export const Sidebar = memo(function Sidebar({
return (
<div
key={item.key}
data-index={virtualItem.index}
data-task-idx={taskCount}
data-task-repository-id={repository.id}
ref={(node) => {
@ -851,6 +815,7 @@ export const Sidebar = memo(function Sidebar({
return (
<div
key={item.key}
data-index={virtualItem.index}
data-repository-idx={item.repositoryCount}
ref={(node) => {
if (node) {

View file

@ -305,7 +305,8 @@ export function TerminalPane({ organizationId, taskId, isExpanded, onExpand, onC
setProcessTabs([]);
}, [taskId]);
const processes = processesState.data ?? [];
const processesData = processesState.data;
const processes = processesData ?? [];
const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
setProcessTabs((current) => {
@ -361,7 +362,7 @@ export function TerminalPane({ organizationId, taskId, isExpanded, onExpand, onC
const activeProcessTab = activeSessionId ? (processTabsById.get(activeSessionId) ?? null) : null;
const activeTerminalProcess = useMemo(
() => (activeProcessTab ? (processes.find((process) => process.id === activeProcessTab.processId) ?? null) : null),
[activeProcessTab, processes],
[activeProcessTab, processesData],
);
const emptyBodyClassName = css({

View file

@ -30,11 +30,11 @@ export const TranscriptHeader = memo(function TranscriptHeader({
task: Task;
hasSandbox: boolean;
activeSession: AgentSession | null | undefined;
editingField: "title" | "branch" | null;
editingField: "title" | null;
editValue: string;
onEditValueChange: (value: string) => void;
onStartEditingField: (field: "title" | "branch", value: string) => void;
onCommitEditingField: (field: "title" | "branch") => void;
onStartEditingField: (field: "title", value: string) => void;
onCommitEditingField: (field: "title") => void;
onCancelEditingField: () => void;
onSetActiveSessionUnread: (unread: boolean) => void;
sidebarCollapsed?: boolean;
@ -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 (
@ -118,55 +117,20 @@ export const TranscriptHeader = memo(function TranscriptHeader({
</LabelSmall>
)}
{task.branch ? (
editingField === "branch" ? (
<input
autoFocus
value={editValue}
onChange={(event) => onEditValueChange(event.target.value)}
onBlur={() => onCommitEditingField("branch")}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitEditingField("branch");
} else if (event.key === "Escape") {
onCancelEditingField();
}
}}
className={css({
appearance: "none",
WebkitAppearance: "none",
margin: "0",
outline: "none",
padding: "2px 8px",
borderRadius: "999px",
border: `1px solid ${t.borderFocus}`,
backgroundColor: t.interactiveSubtle,
color: t.textPrimary,
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
minWidth: "60px",
})}
/>
) : (
<span
title="Rename"
onClick={() => onStartEditingField("branch", task.branch ?? "")}
className={css({
padding: "2px 8px",
borderRadius: "999px",
border: `1px solid ${t.borderMedium}`,
backgroundColor: t.interactiveSubtle,
color: t.textPrimary,
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
cursor: "pointer",
":hover": { borderColor: t.borderFocus },
})}
>
{task.branch}
</span>
)
<span
className={css({
padding: "2px 8px",
borderRadius: "999px",
border: `1px solid ${t.borderMedium}`,
backgroundColor: t.interactiveSubtle,
color: t.textPrimary,
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
})}
>
{task.branch}
</span>
) : null}
<HeaderStatusPill status={headerStatus} />
<div className={css({ flex: 1 })} />

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,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { WorkbenchSession } from "@sandbox-agent/foundry-shared";
import type { WorkspaceSession } from "@sandbox-agent/foundry-shared";
import { buildDisplayMessages } from "./view-model";
function makeSession(transcript: WorkbenchSession["transcript"]): WorkbenchSession {
function makeSession(transcript: WorkspaceSession["transcript"]): WorkspaceSession {
return {
id: "session-1",
sessionId: "session-1",

View file

@ -1,42 +1,28 @@
import {
DEFAULT_WORKSPACE_MODEL_GROUPS as SharedModelGroups,
workspaceModelLabel as sharedWorkspaceModelLabel,
workspaceProviderAgent as sharedWorkspaceProviderAgent,
} from "@sandbox-agent/foundry-shared";
import type {
WorkbenchAgentKind as AgentKind,
WorkbenchSession as AgentSession,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileChange as FileChange,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchTask as Task,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchLineAttachment as LineAttachment,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchRepositorySection as RepositorySection,
WorkbenchTranscriptEvent as TranscriptEvent,
WorkspaceAgentKind as AgentKind,
WorkspaceSession as AgentSession,
WorkspaceDiffLineKind as DiffLineKind,
WorkspaceFileChange as FileChange,
WorkspaceFileTreeNode as FileTreeNode,
WorkspaceTask as Task,
WorkspaceHistoryEvent as HistoryEvent,
WorkspaceLineAttachment as LineAttachment,
WorkspaceModelGroup as ModelGroup,
WorkspaceModelId as ModelId,
WorkspaceParsedDiffLine as ParsedDiffLine,
WorkspaceRepositorySection as RepositorySection,
WorkspaceTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared";
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, TaskWorkbenchSnapshot, WorkbenchTaskStatus } 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: WorkbenchTaskStatus): StatusTagKind {
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,23 +329,17 @@ 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", {});
const activeOrg = appState.data ? currentFoundryOrganization(appState.data) : null;
const organizationState = useSubscription(subscriptionManager, "organization", { organizationId });
const repos = organizationState.data?.repos ?? [];
const rows = organizationState.data?.taskSummaries ?? [];
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
const reposData = organizationState.data?.repos;
const rowsData = organizationState.data?.taskSummaries;
const repos = reposData ?? [];
const rows = rowsData ?? [];
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rowsData, selectedTaskId]);
const taskState = useSubscription(
subscriptionManager,
"task",
@ -374,6 +365,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
});
useEffect(() => {
const repos = reposData ?? [];
if (repoOverviewMode && selectedRepoId) {
setCreateRepoId(selectedRepoId);
return;
@ -381,17 +373,11 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
if (!createRepoId && repos.length > 0) {
setCreateRepoId(repos[0]!.id);
}
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
useEffect(() => {
try {
globalThis.localStorage?.setItem("hf.settings.agentType", newAgentType);
} catch {
// ignore storage failures
}
}, [newAgentType]);
}, [createRepoId, repoOverviewMode, reposData, selectedRepoId]);
const repoGroups = useMemo(() => {
const repos = reposData ?? [];
const rows = rowsData ?? [];
const byRepo = new Map<string, typeof rows>();
for (const row of rows) {
const bucket = byRepo.get(row.repoId);
@ -419,7 +405,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}
return a.repoLabel.localeCompare(b.repoLabel);
});
}, [repos, rows]);
}, [reposData, rowsData]);
const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null);
@ -432,6 +418,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}, [selectedForSession]);
useEffect(() => {
const rows = rowsData ?? [];
if (!repoOverviewMode && !selectedTaskId && rows.length > 0) {
void navigate({
to: "/organizations/$organizationId/tasks/$taskId",
@ -443,18 +430,19 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
replace: true,
});
}
}, [navigate, repoOverviewMode, rows, selectedTaskId, organizationId]);
}, [navigate, repoOverviewMode, rowsData, selectedTaskId, organizationId]);
useEffect(() => {
setActiveSessionId(null);
setDraft("");
}, [selectedForSession?.id]);
const sessionRows = selectedForSession?.sessionsSummary ?? [];
const taskRuntimeStatus = selectedForSession?.runtimeStatus ?? selectedForSession?.status ?? null;
const taskStatusState = describeTaskState(taskRuntimeStatus, selectedForSession?.statusMessage ?? null);
const sessionRowsData = selectedForSession?.sessionsSummary;
const sessionRows = sessionRowsData ?? [];
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({
@ -469,7 +457,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
status: session.status,
})),
}),
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
[activeSessionId, selectedForSession?.activeSessionId, sessionRowsData],
);
const resolvedSessionId = sessionSelection.sessionId;
const staleSessionId = sessionSelection.staleSessionId;
@ -485,7 +473,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
}
: null,
);
const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRows]);
const selectedSessionSummary = useMemo(() => sessionRows.find((session) => session.id === resolvedSessionId) ?? null, [resolvedSessionId, sessionRowsData]);
const isPendingProvision = selectedSessionSummary?.status === "pending_provision";
const isPendingSessionCreate = selectedSessionSummary?.status === "pending_session_create";
const isSessionError = selectedSessionSummary?.status === "error";
@ -505,8 +493,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,
@ -515,7 +501,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
};
}, [repoOverviewMode, selectedForSession, selectedSummary]);
const devPanelSnapshot = useMemo(
(): TaskWorkbenchSnapshot => ({
(): TaskWorkspaceSnapshot => ({
organizationId,
repos: repos.map((repo) => ({ id: repo.id, label: repo.label })),
repositories: [],
@ -524,8 +510,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,
@ -546,20 +530,21 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
activeSandboxId: selectedForSession?.id === task.id ? selectedForSession.activeSandboxId : null,
})),
}),
[repos, rows, selectedForSession, organizationId],
[reposData, rowsData, selectedForSession, organizationId],
);
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
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 +601,6 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
organizationId,
repoId,
task,
agentType: newAgentType,
explicitTitle: draftTitle || undefined,
explicitBranchName: createOnBranch ? undefined : draftBranchName || undefined,
onBranch: createOnBranch ?? undefined,
@ -654,16 +638,15 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
setCreateTaskOpen(true);
};
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [repos]);
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.id, label: repo.label })), [reposData]);
const selectedRepoOption = repoOptions.find((option) => option.id === createRepoId) ?? null;
const selectedAgentOption = useMemo(() => createOption(AGENT_OPTIONS.find((option) => option.id === newAgentType) ?? AGENT_OPTIONS[0]!), [newAgentType]);
const selectedFilterOption = useMemo(
() => createOption(FILTER_OPTIONS.find((option) => option.id === overviewFilter) ?? FILTER_OPTIONS[0]!),
[overviewFilter],
);
const sessionOptions = useMemo(
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })),
[sessionRows],
[sessionRowsData],
);
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;
@ -1057,23 +1040,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 +1081,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>
@ -1137,8 +1120,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
{selectedForSession ? (
<HeaderStatusPill
status={deriveHeaderStatus(
taskRuntimeStatus ?? selectedForSession.status,
selectedForSession.statusMessage ?? null,
taskStatus ?? selectedForSession.status,
selectedSessionSummary?.status ?? null,
selectedSessionSummary?.errorMessage ?? null,
Boolean(activeSandbox?.sandboxId),
@ -1266,8 +1248,9 @@ 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 +1260,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 +1439,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>
)}
@ -1483,7 +1464,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
gap: theme.sizing.scale300,
})}
>
<MetaRow label="State" value={taskRuntimeStatus ?? "-"} mono />
<MetaRow label="State" value={taskStatus ?? "-"} mono />
<MetaRow label="State detail" value={taskStatusState.detail} />
<MetaRow label="Task" value={selectedForSession.id} mono />
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
@ -1504,9 +1485,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>
@ -1529,7 +1509,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected
</div>
</section>
{taskRuntimeStatus === "error" ? (
{taskStatus === "error" ? (
<div
className={css({
padding: "12px",
@ -1607,25 +1587,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