WIP: async action fixes and interest manager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-13 18:48:07 -07:00
parent 0185130230
commit 2022a6ec18
35 changed files with 2950 additions and 385 deletions

View file

@ -1,6 +1,7 @@
import { type ReactNode, useEffect } from "react";
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
import { useInterest } from "@sandbox-agent/foundry-client";
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
import { DevPanel } from "../components/dev-panel";
import { MockLayout } from "../components/mock-layout";
@ -13,8 +14,8 @@ import {
MockSignInPage,
} from "../components/mock-onboarding";
import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env";
import { interestManager } from "../lib/interest";
import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
import { getTaskWorkbenchClient } from "../lib/workbench";
const rootRoute = createRootRoute({
component: RootLayout,
@ -325,7 +326,7 @@ function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; chil
}
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
const taskWorkbenchClient = getTaskWorkbenchClient(workspaceId);
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
useEffect(() => {
setFrontendErrorContext({
workspaceId,
@ -333,7 +334,7 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
repoId,
});
}, [repoId, workspaceId]);
const activeTaskId = taskWorkbenchClient.getSnapshot().tasks.find((task) => task.repoId === repoId)?.id;
const activeTaskId = workspaceState.data?.taskSummaries.find((task) => task.repoId === repoId)?.id;
if (!activeTaskId) {
return <Navigate to="/workspaces/$workspaceId" params={{ workspaceId }} replace />;
}

View file

@ -1,7 +1,8 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import { createErrorContext } from "@sandbox-agent/foundry-shared";
import { createErrorContext, type WorkbenchSessionSummary, type WorkbenchTaskDetail, type WorkbenchTaskSummary } from "@sandbox-agent/foundry-shared";
import { useInterest } from "@sandbox-agent/foundry-client";
import { PanelLeft, PanelRight } from "lucide-react";
import { useFoundryTokens } from "../app/theme";
@ -30,7 +31,8 @@ import {
type ModelId,
} from "./mock-layout/view-model";
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
import { getTaskWorkbenchClient } from "../lib/workbench";
import { backendClient } from "../lib/backend";
import { interestManager } from "../lib/interest";
function firstAgentTabId(task: Task): string | null {
return task.tabs[0]?.id ?? null;
@ -65,6 +67,81 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
}
function toLegacyTab(
summary: WorkbenchSessionSummary,
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
): Task["tabs"][number] {
return {
id: summary.id,
sessionId: summary.sessionId,
sessionName: summary.sessionName,
agent: summary.agent,
model: summary.model,
status: summary.status,
thinkingSinceMs: summary.thinkingSinceMs,
unread: summary.unread,
created: summary.created,
draft: sessionDetail?.draft ?? {
text: "",
attachments: [],
updatedAtMs: null,
},
transcript: sessionDetail?.transcript ?? [],
};
}
function toLegacyTask(
summary: WorkbenchTaskSummary,
detail?: WorkbenchTaskDetail,
sessionCache?: Map<string, { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] }>,
): Task {
const sessions = detail?.sessionsSummary ?? summary.sessionsSummary;
return {
id: summary.id,
repoId: summary.repoId,
title: detail?.title ?? summary.title,
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,
tabs: sessions.map((session) => toLegacyTab(session, sessionCache?.get(session.id))),
fileChanges: detail?.fileChanges ?? [],
diffs: detail?.diffs ?? {},
fileTree: detail?.fileTree ?? [],
minutesUsed: detail?.minutesUsed ?? 0,
};
}
function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[]) {
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),
}))
.filter((repo) => repo.tasks.length > 0);
}
interface WorkbenchActions {
createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: 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; tabId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
sendMessage(input: { taskId: string; tabId: string; text: string; attachments: LineAttachment[] }): Promise<void>;
stopAgent(input: { taskId: string; tabId: string }): Promise<void>;
setSessionUnread(input: { taskId: string; tabId: string; unread: boolean }): Promise<void>;
renameSession(input: { taskId: string; tabId: string; title: string }): Promise<void>;
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
}
const TranscriptPanel = memo(function TranscriptPanel({
taskWorkbenchClient,
task,
@ -83,7 +160,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onToggleRightSidebar,
onNavigateToUsage,
}: {
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
taskWorkbenchClient: WorkbenchActions;
task: Task;
activeTabId: string | null;
lastAgentTabId: string | null;
@ -902,14 +979,82 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const [css] = useStyletron();
const t = useFoundryTokens();
const navigate = useNavigate();
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
const viewModel = useSyncExternalStore(
taskWorkbenchClient.subscribe.bind(taskWorkbenchClient),
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
const taskWorkbenchClient = useMemo<WorkbenchActions>(
() => ({
createTask: (input) => backendClient.createWorkbenchTask(workspaceId, input),
markTaskUnread: (input) => backendClient.markWorkbenchUnread(workspaceId, input),
renameTask: (input) => backendClient.renameWorkbenchTask(workspaceId, input),
renameBranch: (input) => backendClient.renameWorkbenchBranch(workspaceId, input),
archiveTask: async (input) => backendClient.runAction(workspaceId, input.taskId, "archive"),
publishPr: (input) => backendClient.publishWorkbenchPr(workspaceId, input),
revertFile: (input) => backendClient.revertWorkbenchFile(workspaceId, input),
updateDraft: (input) => backendClient.updateWorkbenchDraft(workspaceId, input),
sendMessage: (input) => backendClient.sendWorkbenchMessage(workspaceId, input),
stopAgent: (input) => backendClient.stopWorkbenchSession(workspaceId, input),
setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(workspaceId, input),
renameSession: (input) => backendClient.renameWorkbenchSession(workspaceId, input),
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
}),
[workspaceId],
);
const tasks = viewModel.tasks ?? [];
const rawProjects = viewModel.projects ?? [];
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
const workspaceRepos = workspaceState.data?.repos ?? [];
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
const selectedTaskSummary = useMemo(
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
[selectedTaskId, taskSummaries],
);
const taskState = useInterest(
interestManager,
"task",
selectedTaskSummary
? {
workspaceId,
repoId: selectedTaskSummary.repoId,
taskId: selectedTaskSummary.id,
}
: null,
);
const sessionState = useInterest(
interestManager,
"session",
selectedTaskSummary && selectedSessionId
? {
workspaceId,
repoId: selectedTaskSummary.repoId,
taskId: selectedTaskSummary.id,
sessionId: selectedSessionId,
}
: null,
);
const tasks = useMemo(() => {
const sessionCache = new Map<string, { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] }>();
if (selectedTaskSummary && taskState.data) {
for (const session of taskState.data.sessionsSummary) {
const cached =
(selectedSessionId && session.id === selectedSessionId ? sessionState.data : undefined) ??
interestManager.getSnapshot("session", {
workspaceId,
repoId: selectedTaskSummary.repoId,
taskId: selectedTaskSummary.id,
sessionId: session.id,
});
if (cached) {
sessionCache.set(session.id, {
draft: cached.draft,
transcript: cached.transcript,
});
}
}
}
return taskSummaries.map((summary) =>
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
);
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
const appSnapshot = useMockAppSnapshot();
const activeOrg = activeMockOrganization(appSnapshot);
const navigateToUsage = useCallback(() => {
@ -1084,16 +1229,16 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
}, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]);
useEffect(() => {
if (selectedNewTaskRepoId && viewModel.repos.some((repo) => repo.id === selectedNewTaskRepoId)) {
if (selectedNewTaskRepoId && workspaceRepos.some((repo) => repo.id === selectedNewTaskRepoId)) {
return;
}
const fallbackRepoId =
activeTask?.repoId && viewModel.repos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (viewModel.repos[0]?.id ?? "");
activeTask?.repoId && workspaceRepos.some((repo) => repo.id === activeTask.repoId) ? activeTask.repoId : (workspaceRepos[0]?.id ?? "");
if (fallbackRepoId !== selectedNewTaskRepoId) {
setSelectedNewTaskRepoId(fallbackRepoId);
}
}, [activeTask?.repoId, selectedNewTaskRepoId, viewModel.repos]);
}, [activeTask?.repoId, selectedNewTaskRepoId, workspaceRepos]);
useEffect(() => {
if (!activeTask) {
@ -1366,7 +1511,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
<Sidebar
projects={projects}
newTaskRepos={viewModel.repos}
newTaskRepos={workspaceRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId=""
onSelect={selectTask}
@ -1421,22 +1566,22 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
>
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
<p style={{ margin: 0, opacity: 0.75 }}>
{viewModel.repos.length > 0
{workspaceRepos.length > 0
? "Start from the sidebar to create a task on the first available repo."
: "No repos are available in this workspace yet."}
</p>
<button
type="button"
onClick={createTask}
disabled={viewModel.repos.length === 0}
disabled={workspaceRepos.length === 0}
style={{
alignSelf: "center",
border: 0,
borderRadius: "999px",
padding: "10px 18px",
background: viewModel.repos.length > 0 ? t.borderMedium : t.textTertiary,
background: workspaceRepos.length > 0 ? t.borderMedium : t.textTertiary,
color: t.textPrimary,
cursor: viewModel.repos.length > 0 ? "pointer" : "not-allowed",
cursor: workspaceRepos.length > 0 ? "pointer" : "not-allowed",
fontWeight: 600,
}}
>
@ -1486,7 +1631,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
<Sidebar
projects={projects}
newTaskRepos={viewModel.repos}
newTaskRepos={workspaceRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={activeTask.id}
onSelect={selectTask}
@ -1534,7 +1679,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
>
<Sidebar
projects={projects}
newTaskRepos={viewModel.repos}
newTaskRepos={workspaceRepos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={activeTask.id}
onSelect={(id) => {

View file

@ -1,4 +1,4 @@
import type { SandboxProcessRecord } from "@sandbox-agent/foundry-client";
import { type SandboxProcessRecord, useInterest } from "@sandbox-agent/foundry-client";
import { ProcessTerminal } from "@sandbox-agent/react";
import { useQuery } from "@tanstack/react-query";
import { useStyletron } from "baseui";
@ -7,6 +7,7 @@ import { ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-rea
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SandboxAgent } from "sandbox-agent";
import { backendClient } from "../../lib/backend";
import { interestManager } from "../../lib/interest";
interface TerminalPaneProps {
workspaceId: string;
@ -183,28 +184,31 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
[listWidth],
);
const taskQuery = useQuery({
queryKey: ["mock-layout", "task", workspaceId, taskId],
enabled: Boolean(taskId),
staleTime: 1_000,
refetchOnWindowFocus: true,
refetchInterval: (query) => (query.state.data?.activeSandboxId ? false : 2_000),
queryFn: async () => {
if (!taskId) {
throw new Error("Cannot load terminal state without a task.");
}
return await backendClient.getTask(workspaceId, taskId);
},
});
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
const taskSummary = useMemo(
() => (taskId ? (workspaceState.data?.taskSummaries.find((task) => task.id === taskId) ?? null) : null),
[taskId, workspaceState.data?.taskSummaries],
);
const taskState = useInterest(
interestManager,
"task",
taskSummary
? {
workspaceId,
repoId: taskSummary.repoId,
taskId: taskSummary.id,
}
: null,
);
const activeSandbox = useMemo(() => {
const task = taskQuery.data;
const task = taskState.data;
if (!task?.activeSandboxId) {
return null;
}
return task.sandboxes.find((sandbox) => sandbox.sandboxId === task.activeSandboxId) ?? null;
}, [taskQuery.data]);
}, [taskState.data]);
const connectionQuery = useQuery({
queryKey: ["mock-layout", "sandbox-agent-connection", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
@ -220,30 +224,17 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
},
});
const processesQuery = useQuery({
queryKey: ["mock-layout", "sandbox-processes", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""],
enabled: Boolean(activeSandbox?.sandboxId),
staleTime: 0,
refetchOnWindowFocus: true,
refetchInterval: activeSandbox?.sandboxId ? 3_000 : false,
queryFn: async () => {
if (!activeSandbox) {
throw new Error("Cannot load processes without an active sandbox.");
}
return await backendClient.listSandboxProcesses(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId);
},
});
useEffect(() => {
if (!activeSandbox?.sandboxId) {
return;
}
return backendClient.subscribeSandboxProcesses(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, () => {
void processesQuery.refetch();
});
}, [activeSandbox?.providerId, activeSandbox?.sandboxId, processesQuery, workspaceId]);
const processesState = useInterest(
interestManager,
"sandboxProcesses",
activeSandbox
? {
workspaceId,
providerId: activeSandbox.providerId,
sandboxId: activeSandbox.sandboxId,
}
: null,
);
useEffect(() => {
if (!connectionQuery.data) {
@ -314,7 +305,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
setProcessTabs([]);
}, [taskId]);
const processes = processesQuery.data?.processes ?? [];
const processes = processesState.data ?? [];
const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
setProcessTabs((current) => {
@ -360,12 +351,11 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
sandboxId: activeSandbox.sandboxId,
request: defaultShellRequest(activeSandbox.cwd),
});
await processesQuery.refetch();
openTerminalTab(created);
} finally {
setCreatingProcess(false);
}
}, [activeSandbox, openTerminalTab, processesQuery, workspaceId]);
}, [activeSandbox, openTerminalTab, workspaceId]);
const processTabsById = useMemo(() => new Map(processTabs.map((tab) => [tab.id, tab])), [processTabs]);
const activeProcessTab = activeTabId ? (processTabsById.get(activeTabId) ?? null) : null;
@ -465,9 +455,6 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
height: "100%",
padding: "18px 16px 14px",
}}
onExit={() => {
void processesQuery.refetch();
}}
/>
</div>
);
@ -484,7 +471,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
);
}
if (taskQuery.isLoading) {
if (taskState.status === "loading") {
return (
<div className={emptyBodyClassName}>
<div className={emptyCopyClassName}>
@ -494,12 +481,12 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
);
}
if (taskQuery.error) {
if (taskState.error) {
return (
<div className={emptyBodyClassName}>
<div className={emptyCopyClassName}>
<strong>Could not load task state.</strong>
<span>{taskQuery.error.message}</span>
<span>{taskState.error.message}</span>
</div>
</div>
);

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, TaskRecord, TaskSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/foundry-shared";
import { groupTaskStatus, type SandboxSessionEventRecord } from "@sandbox-agent/foundry-client";
import type { AgentType, RepoBranchRecord, RepoOverview, RepoStackAction, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared";
import { useInterest } from "@sandbox-agent/foundry-client";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { Button } from "baseui/button";
@ -17,6 +17,7 @@ import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizon
import { formatDiffStat } from "../features/tasks/model";
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
import { backendClient } from "../lib/backend";
import { interestManager } from "../lib/interest";
interface WorkspaceDashboardProps {
workspaceId: string;
@ -96,11 +97,9 @@ const AGENT_OPTIONS: SelectItem[] = [
{ id: "claude", label: "claude" },
];
function statusKind(status: TaskSummary["status"]): StatusTagKind {
const group = groupTaskStatus(status);
if (group === "running") return "positive";
if (group === "queued") return "warning";
if (group === "error") return "negative";
function statusKind(status: WorkbenchTaskStatus): StatusTagKind {
if (status === "running") return "positive";
if (status === "new") return "warning";
return "neutral";
}
@ -135,26 +134,6 @@ function branchTestIdToken(value: string): string {
return token || "branch";
}
function useSessionEvents(
task: TaskRecord | null,
sessionId: string | null,
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
return useQuery({
queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""],
enabled: Boolean(task?.activeSandboxId && sessionId),
refetchInterval: 2_500,
queryFn: async () => {
if (!task?.activeSandboxId || !sessionId) {
return { items: [] };
}
return backendClient.listSandboxSessionEvents(task.workspaceId, task.providerId, task.activeSandboxId, {
sessionId,
limit: 120,
});
},
});
}
function repoSummary(overview: RepoOverview | undefined): {
total: number;
mapped: number;
@ -382,37 +361,26 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
});
const [createError, setCreateError] = useState<string | null>(null);
const tasksQuery = useQuery({
queryKey: ["workspace", workspaceId, "tasks"],
queryFn: async () => backendClient.listTasks(workspaceId),
refetchInterval: 2_500,
});
const taskDetailQuery = useQuery({
queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId],
enabled: Boolean(selectedTaskId && !repoOverviewMode),
refetchInterval: 2_500,
queryFn: async () => {
if (!selectedTaskId) {
throw new Error("No task selected");
}
return backendClient.getTask(workspaceId, selectedTaskId);
},
});
const reposQuery = useQuery({
queryKey: ["workspace", workspaceId, "repos"],
queryFn: async () => backendClient.listRepos(workspaceId),
refetchInterval: 10_000,
});
const repos = reposQuery.data ?? [];
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
const repos = workspaceState.data?.repos ?? [];
const rows = workspaceState.data?.taskSummaries ?? [];
const selectedSummary = useMemo(() => rows.find((row) => row.id === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
const taskState = useInterest(
interestManager,
"task",
!repoOverviewMode && selectedSummary
? {
workspaceId,
repoId: selectedSummary.repoId,
taskId: selectedSummary.id,
}
: null,
);
const activeRepoId = selectedRepoId ?? createRepoId;
const repoOverviewQuery = useQuery({
queryKey: ["workspace", workspaceId, "repo-overview", activeRepoId],
enabled: Boolean(repoOverviewMode && activeRepoId),
refetchInterval: 5_000,
queryFn: async () => {
if (!activeRepoId) {
throw new Error("No repo selected");
@ -427,7 +395,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
return;
}
if (!createRepoId && repos.length > 0) {
setCreateRepoId(repos[0]!.repoId);
setCreateRepoId(repos[0]!.id);
}
}, [createRepoId, repoOverviewMode, repos, selectedRepoId]);
@ -439,9 +407,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
}
}, [newAgentType]);
const rows = tasksQuery.data ?? [];
const repoGroups = useMemo(() => {
const byRepo = new Map<string, TaskSummary[]>();
const byRepo = new Map<string, typeof rows>();
for (const row of rows) {
const bucket = byRepo.get(row.repoId);
if (bucket) {
@ -453,12 +420,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
return repos
.map((repo) => {
const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
const latestTaskAt = tasks[0]?.updatedAt ?? 0;
const tasks = [...(byRepo.get(repo.id) ?? [])].sort((a, b) => b.updatedAtMs - a.updatedAtMs);
const latestTaskAt = tasks[0]?.updatedAtMs ?? 0;
return {
repoId: repo.repoId,
repoRemote: repo.remoteUrl,
latestActivityAt: Math.max(repo.updatedAt, latestTaskAt),
repoId: repo.id,
repoLabel: repo.label,
latestActivityAt: Math.max(repo.latestActivityMs, latestTaskAt),
tasks,
};
})
@ -466,13 +433,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
if (a.latestActivityAt !== b.latestActivityAt) {
return b.latestActivityAt - a.latestActivityAt;
}
return a.repoRemote.localeCompare(b.repoRemote);
return a.repoLabel.localeCompare(b.repoLabel);
});
}, [repos, rows]);
const selectedSummary = useMemo(() => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]);
const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null);
const selectedForSession = repoOverviewMode ? null : (taskState.data ?? null);
const activeSandbox = useMemo(() => {
if (!selectedForSession) return null;
@ -488,7 +453,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
taskId: rows[0]!.taskId,
taskId: rows[0]!.id,
},
search: { sessionId: undefined },
replace: true,
@ -499,35 +464,39 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
useEffect(() => {
setActiveSessionId(null);
setDraft("");
}, [selectedForSession?.taskId]);
}, [selectedForSession?.id]);
const sessionsQuery = useQuery({
queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"],
enabled: Boolean(activeSandbox?.sandboxId && selectedForSession),
refetchInterval: 3_000,
queryFn: async () => {
if (!activeSandbox?.sandboxId || !selectedForSession) {
return { items: [] };
}
return backendClient.listSandboxSessions(workspaceId, activeSandbox.providerId, activeSandbox.sandboxId, {
limit: 30,
});
},
});
const sessionRows = sessionsQuery.data?.items ?? [];
const sessionRows = selectedForSession?.sessionsSummary ?? [];
const sessionSelection = useMemo(
() =>
resolveSessionSelection({
explicitSessionId: activeSessionId,
taskSessionId: selectedForSession?.activeSessionId ?? null,
sessions: sessionRows,
sessions: sessionRows.map((session) => ({
id: session.id,
agent: session.agent,
agentSessionId: session.sessionId ?? "",
lastConnectionId: "",
createdAt: 0,
status: session.status,
})),
}),
[activeSessionId, selectedForSession?.activeSessionId, sessionRows],
);
const resolvedSessionId = sessionSelection.sessionId;
const staleSessionId = sessionSelection.staleSessionId;
const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId);
const sessionState = useInterest(
interestManager,
"session",
selectedForSession && resolvedSessionId
? {
workspaceId,
repoId: selectedForSession.repoId,
taskId: selectedForSession.id,
sessionId: resolvedSessionId,
}
: null,
);
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
@ -546,9 +515,8 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
const createSession = useMutation({
mutationFn: async () => startSessionFromTask(),
onSuccess: async (session) => {
onSuccess: (session) => {
setActiveSessionId(session.id);
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
},
});
@ -558,7 +526,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
}
const created = await startSessionFromTask();
setActiveSessionId(created.id);
await sessionsQuery.refetch();
return created.id;
};
@ -576,13 +543,12 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
prompt,
});
},
onSuccess: async () => {
onSuccess: () => {
setDraft("");
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
},
});
const transcript = buildTranscript(eventsQuery.data?.items ?? []);
const transcript = buildTranscript(sessionState.data?.transcript ?? []);
const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0;
const createTask = useMutation({
@ -613,8 +579,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setNewBranchName("");
setCreateOnBranch(null);
setCreateTaskOpen(false);
await tasksQuery.refetch();
await repoOverviewQuery.refetch();
await navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
@ -641,7 +605,6 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setAddRepoError(null);
setAddRepoRemote("");
setAddRepoOpen(false);
await reposQuery.refetch();
setCreateRepoId(created.repoId);
if (repoOverviewMode) {
await navigate({
@ -679,7 +642,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setStackActionMessage(null);
setStackActionError(result.message);
}
await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]);
await repoOverviewQuery.refetch();
},
onError: (error) => {
setStackActionMessage(null);
@ -698,7 +661,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
setCreateTaskOpen(true);
};
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
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(
@ -706,7 +669,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
[overviewFilter],
);
const sessionOptions = useMemo(
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.id} (${session.status ?? "running"})` })),
() => sessionRows.map((session) => createOption({ id: session.id, label: `${session.sessionName} (${session.status})` })),
[sessionRows],
);
const selectedSessionOption = sessionOptions.find((option) => option.id === resolvedSessionId) ?? null;
@ -839,13 +802,15 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
</PanelHeader>
<ScrollBody>
{tasksQuery.isLoading ? (
{workspaceState.status === "loading" ? (
<>
<Skeleton rows={3} height="72px" />
</>
) : null}
{!tasksQuery.isLoading && repoGroups.length === 0 ? <EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState> : null}
{workspaceState.status !== "loading" && repoGroups.length === 0 ? (
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
) : null}
{repoGroups.map((group) => (
<section
@ -876,7 +841,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
})}
data-testid={group.repoId === activeRepoId ? "repo-overview-open" : `repo-overview-open-${group.repoId}`}
>
{group.repoRemote}
{group.repoLabel}
</Link>
<div
@ -887,14 +852,14 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
})}
>
{group.tasks
.filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId)
.filter((task) => task.status !== "archived" || task.id === selectedSummary?.id)
.map((task) => {
const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId;
const isActive = !repoOverviewMode && task.id === selectedSummary?.id;
return (
<Link
key={task.taskId}
key={task.id}
to="/workspaces/$workspaceId/tasks/$taskId"
params={{ workspaceId, taskId: task.taskId }}
params={{ workspaceId, taskId: task.id }}
search={{ sessionId: undefined }}
className={css({
display: "block",
@ -927,7 +892,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
color="contentSecondary"
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
>
{task.branchName ?? "Determining branch..."}
{task.branch ?? "Determining branch..."}
</ParagraphSmall>
<StatusPill kind={statusKind(task.status)}>{task.status}</StatusPill>
</div>
@ -1396,11 +1361,11 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
backgroundColor: theme.colors.backgroundPrimary,
})}
>
{eventsQuery.isLoading ? <Skeleton rows={2} height="90px" /> : null}
{resolvedSessionId && sessionState.status === "loading" ? <Skeleton rows={2} height="90px" /> : null}
{transcript.length === 0 && !eventsQuery.isLoading ? (
{transcript.length === 0 && !(resolvedSessionId && sessionState.status === "loading") ? (
<EmptyState testId="session-transcript-empty">
{groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
{selectedForSession.runtimeStatus === "error" && selectedForSession.statusMessage
? `Session failed: ${selectedForSession.statusMessage}`
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
@ -1597,7 +1562,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Task" value={selectedForSession.taskId} mono />
<MetaRow label="Task" value={selectedForSession.id} mono />
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
</div>
@ -1615,7 +1580,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Branch" value={selectedForSession.branchName ?? "-"} mono />
<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 ?? "-"} />
@ -1641,7 +1606,7 @@ export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId
</div>
</section>
{groupTaskStatus(selectedForSession.status) === "error" ? (
{selectedForSession.runtimeStatus === "error" ? (
<div
className={css({
padding: "12px",

View file

@ -0,0 +1,5 @@
import { MockInterestManager, RemoteInterestManager } from "@sandbox-agent/foundry-client";
import { backendClient } from "./backend";
import { frontendClientMode } from "./env";
export const interestManager = frontendClientMode === "mock" ? new MockInterestManager() : new RemoteInterestManager(backendClient);

View file

@ -1,23 +1,100 @@
import { useSyncExternalStore } from "react";
import {
createFoundryAppClient,
useInterest,
currentFoundryOrganization,
currentFoundryUser,
eligibleFoundryOrganizations,
type FoundryAppClient,
} from "@sandbox-agent/foundry-client";
import type { FoundryAppSnapshot, FoundryOrganization } from "@sandbox-agent/foundry-shared";
import type { FoundryAppSnapshot, FoundryBillingPlanId, FoundryOrganization, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared";
import { backendClient } from "./backend";
import { interestManager } from "./interest";
import { frontendClientMode } from "./env";
const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-foundry:remote-app-session";
const appClient: FoundryAppClient = createFoundryAppClient({
const EMPTY_APP_SNAPSHOT: FoundryAppSnapshot = {
auth: { status: "signed_out", currentUserId: null },
activeOrganizationId: null,
onboarding: {
starterRepo: {
repoFullName: "rivet-dev/sandbox-agent",
repoUrl: "https://github.com/rivet-dev/sandbox-agent",
status: "pending",
starredAt: null,
skippedAt: null,
},
},
users: [],
organizations: [],
};
const legacyAppClient: FoundryAppClient = createFoundryAppClient({
mode: frontendClientMode,
backend: frontendClientMode === "remote" ? backendClient : undefined,
});
const remoteAppClient: FoundryAppClient = {
getSnapshot(): FoundryAppSnapshot {
return interestManager.getSnapshot("app", {}) ?? EMPTY_APP_SNAPSHOT;
},
subscribe(listener: () => void): () => void {
return interestManager.subscribe("app", {}, listener);
},
async signInWithGithub(userId?: string): Promise<void> {
void userId;
await backendClient.signInWithGithub();
},
async signOut(): Promise<void> {
await backendClient.signOutApp();
},
async skipStarterRepo(): Promise<void> {
await backendClient.skipAppStarterRepo();
},
async starStarterRepo(organizationId: string): Promise<void> {
await backendClient.starAppStarterRepo(organizationId);
},
async selectOrganization(organizationId: string): Promise<void> {
await backendClient.selectAppOrganization(organizationId);
},
async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise<void> {
await backendClient.updateAppOrganizationProfile(input);
},
async triggerGithubSync(organizationId: string): Promise<void> {
await backendClient.triggerAppRepoImport(organizationId);
},
async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise<void> {
await backendClient.completeAppHostedCheckout(organizationId, planId);
},
async openBillingPortal(organizationId: string): Promise<void> {
await backendClient.openAppBillingPortal(organizationId);
},
async cancelScheduledRenewal(organizationId: string): Promise<void> {
await backendClient.cancelAppScheduledRenewal(organizationId);
},
async resumeSubscription(organizationId: string): Promise<void> {
await backendClient.resumeAppSubscription(organizationId);
},
async reconnectGithub(organizationId: string): Promise<void> {
await backendClient.reconnectAppGithub(organizationId);
},
async recordSeatUsage(workspaceId: string): Promise<void> {
await backendClient.recordAppSeatUsage(workspaceId);
},
};
const appClient: FoundryAppClient = frontendClientMode === "remote" ? remoteAppClient : legacyAppClient;
export function useMockAppSnapshot(): FoundryAppSnapshot {
if (frontendClientMode === "remote") {
const app = useInterest(interestManager, "app", {});
if (app.status !== "loading") {
firstSnapshotDelivered = true;
}
return app.data ?? EMPTY_APP_SNAPSHOT;
}
return useSyncExternalStore(appClient.subscribe.bind(appClient), appClient.getSnapshot.bind(appClient), appClient.getSnapshot.bind(appClient));
}

View file

@ -1,20 +0,0 @@
import { createTaskWorkbenchClient, type TaskWorkbenchClient } from "@sandbox-agent/foundry-client";
import { backendClient } from "./backend";
import { frontendClientMode } from "./env";
const workbenchClients = new Map<string, TaskWorkbenchClient>();
export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient {
const existing = workbenchClients.get(workspaceId);
if (existing) {
return existing;
}
const created = createTaskWorkbenchClient({
mode: frontendClientMode,
backend: backendClient,
workspaceId,
});
workbenchClients.set(workspaceId, created);
return created;
}