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

@ -65,6 +65,58 @@ Use `pnpm` workspaces and Turborepo.
- When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow.
- If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it.
## Realtime Data Architecture
### Core pattern: fetch initial state + subscribe to deltas
All client data flows follow the same pattern:
1. **Connect** to the actor via WebSocket.
2. **Fetch initial state** via an action call to get the current materialized snapshot.
3. **Subscribe to events** on the connection. Events carry **full replacement payloads** for the changed entity (not empty notifications, not patches — the complete new state of the thing that changed).
4. **Unsubscribe** after a 30-second grace period when interest ends (screen navigation, component unmount). The grace period prevents thrashing during screen transitions and React double-renders.
Do not use polling (`refetchInterval`), empty "go re-fetch" broadcast events, or full-snapshot re-fetches on every mutation. Every mutation broadcasts the new absolute state of the changed entity to connected clients.
### Materialized state in coordinator actors
- **Workspace actor** materializes sidebar-level data in its own SQLite: repo catalog, task summaries (title, status, branch, PR, updatedAt), repo summaries (overview/branch state), and session summaries (id, name, status, unread, model — no transcript). Task actors push summary changes to the workspace actor when they mutate. The workspace actor broadcasts the updated entity to connected clients. `getWorkspaceSummary` reads from local tables only — no fan-out to child actors.
- **Task actor** materializes its own detail state (session summaries, sandbox info, diffs, file tree). `getTaskDetail` reads from the task actor's own SQLite. The task actor broadcasts updates directly to clients connected to it.
- **Session data** lives on the task actor but is a separate subscription topic. The task topic includes `sessions_summary` (list without content). The `session` topic provides full transcript and draft state. Clients subscribe to the `session` topic for whichever session tab is active, and filter `sessionUpdated` events by session ID (ignoring events for other sessions on the same actor).
- The expensive fan-out (querying every project/task actor) only exists as a background reconciliation/rebuild path, never on the hot read path.
### Interest manager
The interest manager (`packages/client`) is a global singleton that manages WebSocket connections, cached state, and subscriptions for all topics. It:
- **Deduplicates** — multiple subscribers to the same topic share one connection and one cached state.
- **Grace period (30s)** — when the last subscriber leaves, the connection and state stay alive for 30 seconds before teardown. This keeps data warm for back-navigation and prevents thrashing.
- **Exposes a single hook**`useInterest(topicKey, params)` returns `{ data, status, error }`. Null params = no subscription (conditional interest).
- **Shared harness, separate implementations** — the `InterestManager` interface is shared between mock and remote implementations. The mock implementation uses in-memory state. The remote implementation uses WebSocket connections. The API/client exposure is identical for both.
### Topics
Each topic maps to one actor connection and one event stream:
| Topic | Actor | Event | Data |
|---|---|---|---|
| `app` | Workspace `"app"` | `appUpdated` | Auth, orgs, onboarding |
| `workspace` | Workspace `{workspaceId}` | `workspaceUpdated` | Repo catalog, task summaries, repo summaries |
| `task` | Task `{workspaceId, repoId, taskId}` | `taskUpdated` | Session summaries, sandbox info, diffs, file tree |
| `session` | Task `{workspaceId, repoId, taskId}` (filtered by sessionId) | `sessionUpdated` | Transcript, draft state |
| `sandboxProcesses` | SandboxInstance | `processesUpdated` | Process list |
The client subscribes to `app` always, `workspace` when entering a workspace, `task` when viewing a task, and `session` when viewing a specific session tab. At most 4 actor connections at a time (app + workspace + task + sandbox if terminal is open). The `session` topic reuses the task actor connection and filters by session ID.
### Rules
- Do not add `useQuery` with `refetchInterval` for data that should be push-based.
- Do not broadcast empty notification events. Events must carry the full new state of the changed entity.
- Do not re-fetch full snapshots after mutations. The mutation triggers a server-side broadcast with the new entity state; the client replaces it in local state.
- All event subscriptions go through the interest manager. Do not create ad-hoc `handle.connect()` + `conn.on()` patterns.
- Backend mutations that affect sidebar data (task title, status, branch, PR state) must push the updated summary to the parent workspace actor, which broadcasts to workspace subscribers.
- Comment architecture-related code: add doc comments explaining the materialized state pattern, why deltas flow the way they do, and the relationship between parent/child actor broadcasts. New contributors should understand the data flow from comments alone.
## Runtime Policy
- Runtime is Bun-native.

View file

@ -278,10 +278,12 @@ async function getSandboxAgentClient(c: any) {
});
}
function broadcastProcessesUpdated(c: any): void {
async function broadcastProcessesUpdated(c: any): Promise<void> {
const client = await getSandboxAgentClient(c);
const { processes } = await client.listProcesses();
c.broadcast("processesUpdated", {
sandboxId: c.state.sandboxId,
at: Date.now(),
type: "processesUpdated",
processes,
});
}
@ -475,7 +477,7 @@ export const sandboxInstance = actor({
async createProcess(c: any, request: ProcessCreateRequest): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const created = await client.createProcess(request);
broadcastProcessesUpdated(c);
await broadcastProcessesUpdated(c);
return created;
},
@ -492,21 +494,21 @@ export const sandboxInstance = actor({
async stopProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const stopped = await client.stopProcess(request.processId, request.query);
broadcastProcessesUpdated(c);
await broadcastProcessesUpdated(c);
return stopped;
},
async killProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise<ProcessInfo> {
const client = await getSandboxAgentClient(c);
const killed = await client.killProcess(request.processId, request.query);
broadcastProcessesUpdated(c);
await broadcastProcessesUpdated(c);
return killed;
},
async deleteProcess(c: any, request: { processId: string }): Promise<void> {
const client = await getSandboxAgentClient(c);
await client.deleteProcess(request.processId);
broadcastProcessesUpdated(c);
await broadcastProcessesUpdated(c);
},
async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> {

View file

@ -19,7 +19,9 @@ import {
changeWorkbenchModel,
closeWorkbenchSession,
createWorkbenchSession,
getWorkbenchTask,
getSessionDetail,
getTaskDetail,
getTaskSummary,
markWorkbenchUnread,
publishWorkbenchPr,
renameWorkbenchBranch,
@ -228,8 +230,16 @@ export const task = actor({
return await getCurrentRecord({ db: c.db, state: c.state });
},
async getWorkbench(c) {
return await getWorkbenchTask(c);
async getTaskSummary(c) {
return await getTaskSummary(c);
},
async getTaskDetail(c) {
return await getTaskDetail(c);
},
async getSessionDetail(c, input: { sessionId: string }) {
return await getSessionDetail(c, input.sessionId);
},
async markWorkbenchUnread(c): Promise<void> {

View file

@ -286,11 +286,6 @@ async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
return meta;
}
async function notifyWorkbenchUpdated(c: any): Promise<void> {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
}
function shellFragment(parts: string[]): string {
return parts.join(" && ");
}
@ -600,42 +595,60 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
return record;
}
export async function getWorkbenchTask(c: any): Promise<any> {
function buildSessionSummary(record: any, meta: any): any {
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
const sessionStatus =
meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle";
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
let unread = Boolean(meta.unread);
if (thinkingSinceMs && sessionStatus !== "running") {
thinkingSinceMs = null;
unread = true;
}
return {
id: meta.id,
sessionId: derivedSandboxSessionId,
sessionName: meta.sessionName,
agent: agentKindForModel(meta.model),
model: meta.model,
status: sessionStatus,
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
unread,
created: Boolean(meta.created || derivedSandboxSessionId),
};
}
function buildSessionDetailFromMeta(record: any, meta: any): any {
const summary = buildSessionSummary(record, meta);
return {
sessionId: meta.tabId,
tabId: meta.tabId,
sandboxSessionId: summary.sessionId,
sessionName: summary.sessionName,
agent: summary.agent,
model: summary.model,
status: summary.status,
thinkingSinceMs: summary.thinkingSinceMs,
unread: summary.unread,
created: summary.created,
draft: {
text: meta.draftText ?? "",
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
updatedAtMs: meta.draftUpdatedAtMs ?? null,
},
transcript: meta.transcript ?? [],
};
}
/**
* Builds a WorkbenchTaskSummary from local task actor state. Task actors push
* this to the parent workspace actor so workspace sidebar reads stay local.
*/
export async function buildTaskSummary(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await readCachedGitState(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
const tabs = [];
for (const meta of sessions) {
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
const sessionStatus =
meta.status === "ready" && derivedSandboxSessionId ? activeSessionStatus(record, derivedSandboxSessionId) : meta.status === "error" ? "error" : "idle";
let thinkingSinceMs = meta.thinkingSinceMs ?? null;
let unread = Boolean(meta.unread);
if (thinkingSinceMs && sessionStatus !== "running") {
thinkingSinceMs = null;
unread = true;
}
tabs.push({
id: meta.id,
sessionId: derivedSandboxSessionId,
sessionName: meta.sessionName,
agent: agentKindForModel(meta.model),
model: meta.model,
status: sessionStatus,
thinkingSinceMs: sessionStatus === "running" ? thinkingSinceMs : null,
unread,
created: Boolean(meta.created || derivedSandboxSessionId),
draft: {
text: meta.draftText ?? "",
attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [],
updatedAtMs: meta.draftUpdatedAtMs ?? null,
},
transcript: meta.transcript ?? [],
});
}
return {
id: c.state.taskId,
@ -646,19 +659,98 @@ export async function getWorkbenchTask(c: any): Promise<any> {
updatedAtMs: record.updatedAt,
branch: record.branchName,
pullRequest: await readPullRequestSummary(c, record.branchName),
tabs,
sessionsSummary: sessions.map((meta) => buildSessionSummary(record, meta)),
};
}
/**
* Builds a WorkbenchTaskDetail from local task actor state for direct task
* subscribers. This is a full replacement payload, not a patch.
*/
export async function buildTaskDetail(c: any): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await readCachedGitState(c);
const sessions = await listSessionMetaRows(c);
await maybeScheduleWorkbenchRefreshes(c, record, sessions);
const summary = await buildTaskSummary(c);
return {
...summary,
task: record.task,
agentType: record.agentType === "claude" || record.agentType === "codex" ? record.agentType : null,
runtimeStatus: record.status,
statusMessage: record.statusMessage ?? null,
activeSessionId: record.activeSessionId ?? null,
diffStat: record.diffStat ?? null,
prUrl: record.prUrl ?? null,
reviewStatus: record.reviewStatus ?? null,
fileChanges: gitState.fileChanges,
diffs: gitState.diffs,
fileTree: gitState.fileTree,
minutesUsed: 0,
sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({
providerId: sandbox.providerId,
sandboxId: sandbox.sandboxId,
cwd: sandbox.cwd ?? null,
})),
activeSandboxId: record.activeSandboxId ?? null,
};
}
/**
* Builds a WorkbenchSessionDetail for a specific session tab.
*/
export async function buildSessionDetail(c: any, tabId: string): Promise<any> {
const record = await ensureWorkbenchSeeded(c);
const meta = await readSessionMeta(c, tabId);
if (!meta || meta.closed) {
throw new Error(`Unknown workbench session tab: ${tabId}`);
}
return buildSessionDetailFromMeta(record, meta);
}
export async function getTaskSummary(c: any): Promise<any> {
return await buildTaskSummary(c);
}
export async function getTaskDetail(c: any): Promise<any> {
return await buildTaskDetail(c);
}
export async function getSessionDetail(c: any, tabId: string): Promise<any> {
return await buildSessionDetail(c, tabId);
}
/**
* Replaces the old notifyWorkbenchUpdated pattern.
*
* The task actor emits two kinds of updates:
* - Push summary state up to the parent workspace actor so the sidebar
* materialized projection stays current.
* - Broadcast full detail/session payloads down to direct task subscribers.
*/
export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise<void> {
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) });
c.broadcast("taskUpdated", {
type: "taskDetailUpdated",
detail: await buildTaskDetail(c),
});
if (options?.sessionId) {
c.broadcast("sessionUpdated", {
type: "sessionUpdated",
session: await buildSessionDetail(c, options.sessionId),
});
}
}
export async function refreshWorkbenchDerivedState(c: any): Promise<void> {
const record = await ensureWorkbenchSeeded(c);
const gitState = await collectWorkbenchGitState(c, record);
await writeCachedGitState(c, gitState);
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise<void> {
@ -670,7 +762,7 @@ export async function refreshWorkbenchSessionTranscript(c: any, sessionId: strin
const transcript = await readSessionTranscript(c, record, meta.sandboxSessionId);
await writeSessionTranscript(c, meta.tabId, transcript);
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
}
export async function renameWorkbenchTask(c: any, value: string): Promise<void> {
@ -688,7 +780,7 @@ export async function renameWorkbenchTask(c: any, value: string): Promise<void>
.where(eq(taskTable.id, 1))
.run();
c.state.title = nextTitle;
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function renameWorkbenchBranch(c: any, value: string): Promise<void> {
@ -739,7 +831,7 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise<void
taskId: c.state.taskId,
branchName: nextBranch,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> {
@ -755,7 +847,7 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
sessionName: "Session 1",
status: "ready",
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: record.activeSessionId });
return { tabId: record.activeSessionId };
}
}
@ -780,7 +872,7 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
wait: false,
},
);
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: tabId });
return { tabId };
}
@ -815,7 +907,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: record.activeSessionId,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: tabId });
return;
}
@ -827,7 +919,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: meta.sandboxSessionId,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: tabId });
return;
}
@ -838,7 +930,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
status: "error",
errorMessage: "cannot create session without a sandbox cwd",
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: tabId });
return;
}
@ -873,7 +965,7 @@ export async function ensureWorkbenchSession(c: any, tabId: string, model?: stri
});
}
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: tabId });
}
export async function enqueuePendingWorkbenchSessions(c: any): Promise<void> {
@ -904,14 +996,14 @@ export async function renameWorkbenchSession(c: any, sessionId: string, title: s
await updateSessionMeta(c, sessionId, {
sessionName: trimmed,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise<void> {
await updateSessionMeta(c, sessionId, {
unread: unread ? 1 : 0,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
@ -920,14 +1012,14 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
draftAttachmentsJson: JSON.stringify(attachments),
draftUpdatedAt: Date.now(),
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
await updateSessionMeta(c, sessionId, {
model,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
@ -984,7 +1076,7 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", {
sessionId: meta.sandboxSessionId,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function stopWorkbenchSession(c: any, sessionId: string): Promise<void> {
@ -998,7 +1090,7 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise<v
await updateSessionMeta(c, sessionId, {
thinkingSinceMs: null,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId });
}
export async function syncWorkbenchSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise<void> {
@ -1063,7 +1155,7 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat
});
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
}
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: meta.tabId });
}
}
@ -1096,7 +1188,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise<
.where(eq(taskRuntime.id, 1))
.run();
}
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function markWorkbenchUnread(c: any): Promise<void> {
@ -1108,7 +1200,7 @@ export async function markWorkbenchUnread(c: any): Promise<void> {
await updateSessionMeta(c, latest.tabId, {
unread: 1,
});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c, { sessionId: latest.tabId });
}
export async function publishWorkbenchPr(c: any): Promise<void> {
@ -1129,7 +1221,7 @@ export async function publishWorkbenchPr(c: any): Promise<void> {
})
.where(eq(taskTable.id, 1))
.run();
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}
export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
@ -1152,5 +1244,5 @@ export async function revertWorkbenchFile(c: any, path: string): Promise<void> {
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
}
await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {});
await notifyWorkbenchUpdated(c);
await broadcastTaskUpdate(c);
}

View file

@ -1,9 +1,9 @@
// @ts-nocheck
import { eq } from "drizzle-orm";
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
import { getOrCreateWorkspace } from "../../handles.js";
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
import { historyKey } from "../../keys.js";
import { broadcastTaskUpdate } from "../workbench.js";
export const TASK_ROW_ID = 1;
@ -83,8 +83,7 @@ export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?:
.run();
}
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
await broadcastTaskUpdate(ctx);
}
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
@ -176,6 +175,5 @@ export async function appendHistory(ctx: any, kind: string, payload: Record<stri
payload,
});
const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId);
await workspace.notifyWorkbenchUpdated({});
await broadcastTaskUpdate(ctx);
}

View file

@ -35,6 +35,13 @@ function debugInit(loopCtx: any, message: string, context?: Record<string, unkno
});
}
async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {});
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
}
async function withActivityTimeout<T>(timeoutMs: number, label: string, run: () => Promise<T>): Promise<T> {
let timer: ReturnType<typeof setTimeout> | null = null;
try {
@ -61,6 +68,8 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming";
try {
await ensureTaskRuntimeCacheColumns(db);
await db
.insert(taskTable)
.values({

View file

@ -4,6 +4,17 @@ import { Loop } from "rivetkit/workflow";
import type {
AddRepoInput,
CreateTaskInput,
HistoryEvent,
HistoryQueryInput,
ListTasksInput,
ProviderId,
RepoOverview,
RepoRecord,
RepoStackActionInput,
RepoStackActionResult,
StarSandboxAgentRepoInput,
StarSandboxAgentRepoResult,
SwitchResult,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
@ -14,20 +25,13 @@ import type {
TaskWorkbenchSelectInput,
TaskWorkbenchSetSessionUnreadInput,
TaskWorkbenchSendMessageInput,
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
HistoryEvent,
HistoryQueryInput,
ListTasksInput,
ProviderId,
RepoOverview,
RepoStackActionInput,
RepoStackActionResult,
RepoRecord,
StarSandboxAgentRepoInput,
StarSandboxAgentRepoResult,
SwitchResult,
WorkbenchRepoSummary,
WorkbenchSessionSummary,
WorkbenchTaskSummary,
WorkspaceEvent,
WorkspaceSummarySnapshot,
WorkspaceUseInput,
} from "@sandbox-agent/foundry-shared";
import { getActorRuntimeContext } from "../context.js";
@ -35,7 +39,7 @@ import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "
import { logActorWarning, resolveErrorMessage } from "../logging.js";
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { taskLookup, repos, providerProfiles } from "./db/schema.js";
import { taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
import { agentTypeForModel } from "../task/workbench.js";
import { expectQueueResponse } from "../../services/queue.js";
import { workspaceAppActions } from "./app-shell.js";
@ -109,6 +113,18 @@ async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Prom
.run();
}
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
async function collectAllTaskSummaries(c: any): Promise<TaskSummary[]> {
const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all();
@ -145,17 +161,55 @@ function repoLabelFromRemote(remoteUrl: string): string {
return remoteUrl;
}
async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkbenchTaskSummary[]): WorkbenchRepoSummary {
const repoTasks = taskRows.filter((task) => task.repoId === repoRow.repoId);
const latestActivityMs = repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), repoRow.updatedAt);
return {
id: repoRow.repoId,
label: repoLabelFromRemote(repoRow.remoteUrl),
taskCount: repoTasks.length,
latestActivityMs,
};
}
function taskSummaryRowFromSummary(taskSummary: WorkbenchTaskSummary) {
return {
taskId: taskSummary.id,
repoId: taskSummary.repoId,
title: taskSummary.title,
status: taskSummary.status,
repoName: taskSummary.repoName,
updatedAtMs: taskSummary.updatedAtMs,
branch: taskSummary.branch,
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary),
};
}
function taskSummaryFromRow(row: any): WorkbenchTaskSummary {
return {
id: row.taskId,
repoId: row.repoId,
title: row.title,
status: row.status,
repoName: row.repoName,
updatedAtMs: row.updatedAtMs,
branch: row.branch ?? null,
pullRequest: parseJsonValue(row.pullRequestJson, null),
sessionsSummary: parseJsonValue<WorkbenchSessionSummary[]>(row.sessionsSummaryJson, []),
};
}
async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySnapshot> {
const repoRows = await c.db
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const tasks: Array<any> = [];
const projects: Array<any> = [];
const taskRows: WorkbenchTaskSummary[] = [];
for (const row of repoRows) {
const projectTasks: Array<any> = [];
try {
const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl);
const summaries = await project.listTaskSummaries({ includeArchived: true });
@ -163,11 +217,18 @@ async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
try {
await upsertTaskLookupRow(c, summary.taskId, row.repoId);
const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId);
const snapshot = await task.getWorkbench({});
tasks.push(snapshot);
projectTasks.push(snapshot);
const taskSummary = await task.getTaskSummary({});
taskRows.push(taskSummary);
await c.db
.insert(taskSummaries)
.values(taskSummaryRowFromSummary(taskSummary))
.onConflictDoUpdate({
target: taskSummaries.taskId,
set: taskSummaryRowFromSummary(taskSummary),
})
.run();
} catch (error) {
logActorWarning("workspace", "failed collecting workbench task", {
logActorWarning("workspace", "failed collecting task summary during reconciliation", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
taskId: summary.taskId,
@ -175,17 +236,8 @@ async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
});
}
}
if (projectTasks.length > 0) {
projects.push({
id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl),
updatedAtMs: projectTasks[0]?.updatedAtMs ?? row.updatedAt,
tasks: projectTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
});
}
} catch (error) {
logActorWarning("workspace", "failed collecting workbench repo snapshot", {
logActorWarning("workspace", "failed collecting repo during workbench reconciliation", {
workspaceId: c.state.workspaceId,
repoId: row.repoId,
error: resolveErrorMessage(error),
@ -193,16 +245,11 @@ async function buildWorkbenchSnapshot(c: any): Promise<TaskWorkbenchSnapshot> {
}
}
tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
taskRows.sort((left, right) => right.updatedAtMs - left.updatedAtMs);
return {
workspaceId: c.state.workspaceId,
repos: repoRows.map((row) => ({
id: row.repoId,
label: repoLabelFromRemote(row.remoteUrl),
})),
projects,
tasks,
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: taskRows,
};
}
@ -211,6 +258,41 @@ async function requireWorkbenchTask(c: any, taskId: string) {
return getTask(c, c.state.workspaceId, repoId, taskId);
}
/**
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
* only. Task actors push summary updates into `task_summaries`, so clients do
* not need this action to fan out to every child actor on the hot read path.
*/
async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnapshot> {
const repoRows = await c.db
.select({
repoId: repos.repoId,
remoteUrl: repos.remoteUrl,
updatedAt: repos.updatedAt,
})
.from(repos)
.orderBy(desc(repos.updatedAt))
.all();
const taskRows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all();
const summaries = taskRows.map(taskSummaryFromRow);
return {
workspaceId: c.state.workspaceId,
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
taskSummaries: summaries,
};
}
async function broadcastRepoSummary(
c: any,
type: "repoAdded" | "repoUpdated",
repoRow: { repoId: string; remoteUrl: string; updatedAt: number },
): Promise<void> {
const matchingTaskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoRow.repoId)).all();
const repo = buildRepoSummary(repoRow, matchingTaskRows.map(taskSummaryFromRow));
c.broadcast("workspaceUpdated", { type, repo } satisfies WorkspaceEvent);
}
async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord> {
assertWorkspace(c, input.workspaceId);
@ -225,6 +307,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
const repoId = repoIdFromRemote(remoteUrl);
const now = Date.now();
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, repoId)).get();
await c.db
.insert(repos)
@ -243,7 +326,11 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise<RepoRecord>
})
.run();
await workspaceActions.notifyWorkbenchUpdated(c);
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", {
repoId,
remoteUrl,
updatedAt: now,
});
return {
workspaceId: c.state.workspaceId,
repoId,
@ -306,7 +393,20 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise<TaskR
})
.run();
await workspaceActions.notifyWorkbenchUpdated(c);
try {
const task = getTask(c, c.state.workspaceId, repoId, created.taskId);
await workspaceActions.applyTaskSummaryUpdate(c, {
taskSummary: await task.getTaskSummary({}),
});
} catch (error) {
logActorWarning("workspace", "failed seeding task summary after task creation", {
workspaceId: c.state.workspaceId,
repoId,
taskId: created.taskId,
error: resolveErrorMessage(error),
});
}
return created;
}
@ -462,13 +562,37 @@ export const workspaceActions = {
};
},
async getWorkbench(c: any, input: WorkspaceUseInput): Promise<TaskWorkbenchSnapshot> {
assertWorkspace(c, input.workspaceId);
return await buildWorkbenchSnapshot(c);
/**
* Called by task actors when their summary-level state changes.
* This is the write path for the local materialized projection; clients read
* the projection via `getWorkspaceSummary`, but only task actors should push
* rows into it.
*/
async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkbenchTaskSummary }): Promise<void> {
await c.db
.insert(taskSummaries)
.values(taskSummaryRowFromSummary(input.taskSummary))
.onConflictDoUpdate({
target: taskSummaries.taskId,
set: taskSummaryRowFromSummary(input.taskSummary),
})
.run();
c.broadcast("workspaceUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary } satisfies WorkspaceEvent);
},
async notifyWorkbenchUpdated(c: any): Promise<void> {
c.broadcast("workbenchUpdated", { at: Date.now() });
async removeTaskSummary(c: any, input: { taskId: string }): Promise<void> {
await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run();
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent);
},
async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
assertWorkspace(c, input.workspaceId);
return await getWorkspaceSummarySnapshot(c);
},
async reconcileWorkbenchState(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
assertWorkspace(c, input.workspaceId);
return await reconcileWorkbenchProjection(c);
},
async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; tabId?: string }> {

View file

@ -152,6 +152,10 @@ function encodeEligibleOrganizationIds(value: string[]): string {
return JSON.stringify([...new Set(value)]);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function seatsIncludedForPlan(planId: FoundryBillingPlanId): number {
switch (planId) {
case "free":
@ -217,7 +221,76 @@ async function getOrganizationState(workspace: any) {
return await workspace.getOrganizationShellState({});
}
async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSnapshot> {
async function getOrganizationStateIfInitialized(workspace: any) {
return await workspace.getOrganizationShellStateIfInitialized({});
}
async function listSnapshotOrganizations(c: any, sessionId: string, organizationIds: string[]) {
const results = await Promise.all(
organizationIds.map(async (organizationId) => {
const organizationStartedAt = performance.now();
try {
const workspace = await getOrCreateWorkspace(c, organizationId);
const organizationState = await getOrganizationStateIfInitialized(workspace);
if (!organizationState) {
logger.warn(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_uninitialized",
);
return { organizationId, snapshot: null, status: "uninitialized" as const };
}
logger.info(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_completed",
);
return { organizationId, snapshot: organizationState.snapshot, status: "ok" as const };
} catch (error) {
const message = errorMessage(error);
if (!message.includes("Actor not found")) {
logger.error(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
errorMessage: message,
errorStack: error instanceof Error ? error.stack : undefined,
},
"build_app_snapshot_organization_failed",
);
throw error;
}
logger.info(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_missing",
);
return { organizationId, snapshot: null, status: "missing" as const };
}
}),
);
return {
organizations: results.map((result) => result.snapshot).filter((organization): organization is FoundryOrganization => organization !== null),
uninitializedOrganizationIds: results.filter((result) => result.status === "uninitialized").map((result) => result.organizationId),
};
}
async function buildAppSnapshot(c: any, sessionId: string, allowOrganizationRepair = true): Promise<FoundryAppSnapshot> {
assertAppWorkspace(c);
const startedAt = performance.now();
const auth = getBetterAuthService();
@ -252,53 +325,31 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
"build_app_snapshot_started",
);
const organizations = (
await Promise.all(
eligibleOrganizationIds.map(async (organizationId) => {
const organizationStartedAt = performance.now();
try {
const workspace = await getOrCreateWorkspace(c, organizationId);
const organizationState = await getOrganizationState(workspace);
logger.info(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_completed",
);
return organizationState.snapshot;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (!message.includes("Actor not found")) {
logger.error(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
errorMessage: message,
errorStack: error instanceof Error ? error.stack : undefined,
},
"build_app_snapshot_organization_failed",
);
throw error;
}
logger.info(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationId,
durationMs: roundDurationMs(organizationStartedAt),
},
"build_app_snapshot_organization_missing",
);
return null;
}
}),
)
).filter((organization): organization is FoundryOrganization => organization !== null);
let { organizations, uninitializedOrganizationIds } = await listSnapshotOrganizations(c, sessionId, eligibleOrganizationIds);
if (allowOrganizationRepair && uninitializedOrganizationIds.length > 0) {
const token = await auth.getAccessTokenForSession(sessionId);
if (token?.accessToken) {
logger.info(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationIds: uninitializedOrganizationIds,
},
"build_app_snapshot_repairing_organizations",
);
await syncGithubOrganizationsInternal(c, { sessionId, accessToken: token.accessToken }, { broadcast: false });
return await buildAppSnapshot(c, sessionId, false);
}
logger.warn(
{
sessionId,
workspaceId: c.state.workspaceId,
organizationIds: uninitializedOrganizationIds,
},
"build_app_snapshot_repair_skipped_no_access_token",
);
}
const currentUser: FoundryUser | null = user
? {
@ -466,6 +517,10 @@ async function safeListInstallations(accessToken: string): Promise<any[]> {
* already returned a redirect to the browser.
*/
export async function syncGithubOrganizations(c: any, input: { sessionId: string; accessToken: string }): Promise<void> {
await syncGithubOrganizationsInternal(c, input, { broadcast: true });
}
async function syncGithubOrganizationsInternal(c: any, input: { sessionId: string; accessToken: string }, options: { broadcast: boolean }): Promise<void> {
assertAppWorkspace(c);
const auth = getBetterAuthService();
const { appShell } = getActorRuntimeContext();
@ -532,7 +587,13 @@ export async function syncGithubOrganizations(c: any, input: { sessionId: string
roleLabel: "GitHub user",
eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds),
});
c.broadcast("appUpdated", { at: Date.now(), sessionId });
if (!options.broadcast) {
return;
}
c.broadcast("appUpdated", {
type: "appUpdated",
snapshot: await buildAppSnapshot(c, sessionId),
});
}
export async function syncGithubOrganizationRepos(c: any, input: { sessionId: string; organizationId: string }): Promise<void> {
@ -639,6 +700,19 @@ async function listOrganizationRepoCatalog(c: any): Promise<string[]> {
async function buildOrganizationState(c: any) {
const startedAt = performance.now();
const row = await requireOrganizationProfileRow(c);
return await buildOrganizationStateFromRow(c, row, startedAt);
}
async function buildOrganizationStateIfInitialized(c: any) {
const startedAt = performance.now();
const row = await readOrganizationProfileRow(c);
if (!row) {
return null;
}
return await buildOrganizationStateFromRow(c, row, startedAt);
}
async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number) {
const repoCatalog = await listOrganizationRepoCatalog(c);
const members = await listOrganizationMembers(c);
const seatAssignmentEmails = await listOrganizationSeatAssignments(c);
@ -1579,6 +1653,11 @@ export const workspaceAppActions = {
return await buildOrganizationState(c);
},
async getOrganizationShellStateIfInitialized(c: any): Promise<any | null> {
assertOrganizationWorkspace(c);
return await buildOrganizationStateIfInitialized(c);
},
async updateOrganizationShellProfile(c: any, input: Pick<UpdateFoundryOrganizationProfileInput, "displayName" | "slug" | "primaryDomain">): Promise<void> {
assertOrganizationWorkspace(c);
const existing = await requireOrganizationProfileRow(c);

View file

@ -16,6 +16,12 @@ const journal = {
tag: "0001_auth_index_tables",
breakpoints: true,
},
{
idx: 2,
when: 1773720000000,
tag: "0002_task_summaries",
breakpoints: true,
},
],
} as const;
@ -150,6 +156,18 @@ CREATE TABLE IF NOT EXISTS \`auth_verification\` (
\`created_at\` integer NOT NULL,
\`updated_at\` integer NOT NULL
);
`,
m0002: `CREATE TABLE IF NOT EXISTS \`task_summaries\` (
\`task_id\` text PRIMARY KEY NOT NULL,
\`repo_id\` text NOT NULL,
\`title\` text NOT NULL,
\`status\` text NOT NULL,
\`repo_name\` text NOT NULL,
\`updated_at_ms\` integer NOT NULL,
\`branch\` text,
\`pull_request_json\` text,
\`sessions_summary_json\` text DEFAULT '[]' NOT NULL
);
`,
} as const,
};

View file

@ -20,6 +20,23 @@ export const taskLookup = sqliteTable("task_lookup", {
repoId: text("repo_id").notNull(),
});
/**
* Materialized sidebar projection maintained by task actors.
* The source of truth still lives on each task actor; this table exists so
* workspace reads can stay local and avoid fan-out across child actors.
*/
export const taskSummaries = sqliteTable("task_summaries", {
taskId: text("task_id").notNull().primaryKey(),
repoId: text("repo_id").notNull(),
title: text("title").notNull(),
status: text("status").notNull(),
repoName: text("repo_name").notNull(),
updatedAtMs: integer("updated_at_ms").notNull(),
branch: text("branch"),
pullRequestJson: text("pull_request_json"),
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
});
export const organizationProfile = sqliteTable("organization_profile", {
id: text("id").notNull().primaryKey(),
kind: text("kind").notNull(),

View file

@ -15,10 +15,12 @@
},
"dependencies": {
"@sandbox-agent/foundry-shared": "workspace:*",
"react": "^19.1.1",
"rivetkit": "2.1.6",
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/react": "^19.1.12",
"tsup": "^8.5.0"
}
}

View file

@ -6,6 +6,9 @@ import type {
FoundryAppSnapshot,
FoundryBillingPlanId,
CreateTaskInput,
AppEvent,
SessionEvent,
SandboxProcessesEvent,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
@ -20,6 +23,12 @@ import type {
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
TaskEvent,
WorkbenchTaskDetail,
WorkbenchTaskSummary,
WorkbenchSessionDetail,
WorkspaceEvent,
WorkspaceSummarySnapshot,
HistoryEvent,
HistoryQueryInput,
ProviderId,
@ -34,7 +43,7 @@ import type {
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import { createMockBackendClient } from "./mock/backend-client.js";
import { sandboxInstanceKey, workspaceKey } from "./keys.js";
import { sandboxInstanceKey, taskKey, workspaceKey } from "./keys.js";
export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill";
@ -60,7 +69,14 @@ export interface SandboxSessionEventRecord {
export type SandboxProcessRecord = ProcessInfo;
export interface ActorConn {
on(event: string, listener: (payload: any) => void): () => void;
onError(listener: (error: unknown) => void): () => void;
dispose(): Promise<void>;
}
interface WorkspaceHandle {
connect(): ActorConn;
addRepo(input: AddRepoInput): Promise<RepoRecord>;
listRepos(input: { workspaceId: string }): Promise<RepoRecord[]>;
createTask(input: CreateTaskInput): Promise<TaskRecord>;
@ -78,7 +94,10 @@ interface WorkspaceHandle {
killTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<void>;
useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>;
starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult>;
getWorkbench(input: { workspaceId: string }): Promise<TaskWorkbenchSnapshot>;
getWorkspaceSummary(input: { workspaceId: string }): Promise<WorkspaceSummarySnapshot>;
applyTaskSummaryUpdate(input: { taskSummary: WorkbenchTaskSummary }): Promise<void>;
removeTaskSummary(input: { taskId: string }): Promise<void>;
reconcileWorkbenchState(input: { workspaceId: string }): Promise<WorkspaceSummarySnapshot>;
createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise<void>;
renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise<void>;
@ -95,7 +114,15 @@ interface WorkspaceHandle {
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
}
interface TaskHandle {
getTaskSummary(): Promise<WorkbenchTaskSummary>;
getTaskDetail(): Promise<WorkbenchTaskDetail>;
getSessionDetail(input: { sessionId: string }): Promise<WorkbenchSessionDetail>;
connect(): ActorConn;
}
interface SandboxInstanceHandle {
connect(): ActorConn;
createSession(input: {
prompt: string;
cwd?: string;
@ -119,6 +146,10 @@ interface RivetClient {
workspace: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): WorkspaceHandle;
};
task: {
get(key?: string | string[]): TaskHandle;
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): TaskHandle;
};
sandboxInstance: {
getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): SandboxInstanceHandle;
};
@ -132,6 +163,9 @@ export interface BackendClientOptions {
export interface BackendClient {
getAppSnapshot(): Promise<FoundryAppSnapshot>;
connectWorkspace(workspaceId: string): Promise<ActorConn>;
connectTask(workspaceId: string, repoId: string, taskId: string): Promise<ActorConn>;
connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn>;
subscribeApp(listener: () => void): () => void;
signInWithGithub(): Promise<void>;
signOutApp(): Promise<FoundryAppSnapshot>;
@ -222,6 +256,9 @@ export interface BackendClient {
sandboxId: string,
): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>;
getSandboxAgentConnection(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>;
getWorkspaceSummary(workspaceId: string): Promise<WorkspaceSummarySnapshot>;
getTaskDetail(workspaceId: string, repoId: string, taskId: string): Promise<WorkbenchTaskDetail>;
getSessionDetail(workspaceId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail>;
getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot>;
subscribeWorkbench(workspaceId: string, listener: () => void): () => void;
createWorkbenchTask(workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse>;
@ -337,6 +374,8 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
createWithInput: workspaceId,
});
const task = async (workspaceId: string, repoId: string, taskId: string): Promise<TaskHandle> => client.task.get(taskKey(workspaceId, repoId, taskId));
const sandboxByKey = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<SandboxInstanceHandle> => {
return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId));
};
@ -400,6 +439,91 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
}
};
const connectWorkspace = async (workspaceId: string): Promise<ActorConn> => {
return (await workspace(workspaceId)).connect() as ActorConn;
};
const connectTask = async (workspaceId: string, repoId: string, taskIdValue: string): Promise<ActorConn> => {
return (await task(workspaceId, repoId, taskIdValue)).connect() as ActorConn;
};
const connectSandbox = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn> => {
try {
return (await sandboxByKey(workspaceId, providerId, sandboxId)).connect() as ActorConn;
} catch (error) {
if (!isActorNotFoundError(error)) {
throw error;
}
const fallback = await sandboxByActorIdFromTask(workspaceId, providerId, sandboxId);
if (!fallback) {
throw error;
}
return fallback.connect() as ActorConn;
}
};
const getWorkbenchCompat = async (workspaceId: string): Promise<TaskWorkbenchSnapshot> => {
const summary = await (await workspace(workspaceId)).getWorkspaceSummary({ workspaceId });
const tasks = await Promise.all(
summary.taskSummaries.map(async (taskSummary) => {
const detail = await (await task(workspaceId, taskSummary.repoId, taskSummary.id)).getTaskDetail();
const sessionDetails = await Promise.all(
detail.sessionsSummary.map(async (session) => {
const full = await (await task(workspaceId, detail.repoId, detail.id)).getSessionDetail({ sessionId: session.id });
return [session.id, full] as const;
}),
);
const sessionDetailsById = new Map(sessionDetails);
return {
id: detail.id,
repoId: detail.repoId,
title: detail.title,
status: detail.status,
repoName: detail.repoName,
updatedAtMs: detail.updatedAtMs,
branch: detail.branch,
pullRequest: detail.pullRequest,
tabs: detail.sessionsSummary.map((session) => {
const full = sessionDetailsById.get(session.id);
return {
id: session.id,
sessionId: session.sessionId,
sessionName: session.sessionName,
agent: session.agent,
model: session.model,
status: session.status,
thinkingSinceMs: session.thinkingSinceMs,
unread: session.unread,
created: session.created,
draft: full?.draft ?? { text: "", attachments: [], updatedAtMs: null },
transcript: full?.transcript ?? [],
};
}),
fileChanges: detail.fileChanges,
diffs: detail.diffs,
fileTree: detail.fileTree,
minutesUsed: detail.minutesUsed,
};
}),
);
const projects = summary.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), repo.latestActivityMs),
tasks: tasks.filter((task) => task.repoId === repo.id).sort((left, right) => right.updatedAtMs - left.updatedAtMs),
}))
.filter((repo) => repo.tasks.length > 0);
return {
workspaceId,
repos: summary.repos.map((repo) => ({ id: repo.id, label: repo.label })),
projects,
tasks: tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs),
};
};
const subscribeWorkbench = (workspaceId: string, listener: () => void): (() => void) => {
let entry = workbenchSubscriptions.get(workspaceId);
if (!entry) {
@ -544,6 +668,18 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await appRequest<FoundryAppSnapshot>("/app/snapshot");
},
async connectWorkspace(workspaceId: string): Promise<ActorConn> {
return await connectWorkspace(workspaceId);
},
async connectTask(workspaceId: string, repoId: string, taskIdValue: string): Promise<ActorConn> {
return await connectTask(workspaceId, repoId, taskIdValue);
},
async connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn> {
return await connectSandbox(workspaceId, providerId, sandboxId);
},
subscribeApp(listener: () => void): () => void {
return subscribeApp(listener);
},
@ -861,8 +997,20 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.sandboxAgentConnection());
},
async getWorkspaceSummary(workspaceId: string): Promise<WorkspaceSummarySnapshot> {
return (await workspace(workspaceId)).getWorkspaceSummary({ workspaceId });
},
async getTaskDetail(workspaceId: string, repoId: string, taskIdValue: string): Promise<WorkbenchTaskDetail> {
return (await task(workspaceId, repoId, taskIdValue)).getTaskDetail();
},
async getSessionDetail(workspaceId: string, repoId: string, taskIdValue: string, sessionId: string): Promise<WorkbenchSessionDetail> {
return (await task(workspaceId, repoId, taskIdValue)).getSessionDetail({ sessionId });
},
async getWorkbench(workspaceId: string): Promise<TaskWorkbenchSnapshot> {
return (await workspace(workspaceId)).getWorkbench({ workspaceId });
return await getWorkbenchCompat(workspaceId);
},
subscribeWorkbench(workspaceId: string, listener: () => void): () => void {

View file

@ -1,5 +1,10 @@
export * from "./app-client.js";
export * from "./backend-client.js";
export * from "./interest/manager.js";
export * from "./interest/mock-manager.js";
export * from "./interest/remote-manager.js";
export * from "./interest/topics.js";
export * from "./interest/use-interest.js";
export * from "./keys.js";
export * from "./mock-app.js";
export * from "./view-model.js";

View file

@ -0,0 +1,24 @@
import type { TopicData, TopicKey, TopicParams } from "./topics.js";
export type TopicStatus = "loading" | "connected" | "error";
export interface TopicState<K extends TopicKey> {
data: TopicData<K> | undefined;
status: TopicStatus;
error: Error | null;
}
/**
* The InterestManager owns all realtime actor connections and cached state.
*
* Multiple subscribers to the same topic share one connection and one cache
* entry. After the last subscriber leaves, a short grace period keeps the
* connection warm so navigation does not thrash actor connections.
*/
export interface InterestManager {
subscribe<K extends TopicKey>(topicKey: K, params: TopicParams<K>, listener: () => void): () => void;
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined;
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus;
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null;
dispose(): void;
}

View file

@ -0,0 +1,12 @@
import { createMockBackendClient } from "../mock/backend-client.js";
import { RemoteInterestManager } from "./remote-manager.js";
/**
* Mock implementation shares the same interest-manager harness as the remote
* path, but uses the in-memory mock backend that synthesizes actor events.
*/
export class MockInterestManager extends RemoteInterestManager {
constructor() {
super(createMockBackendClient());
}
}

View file

@ -0,0 +1,167 @@
import type { BackendClient } from "../backend-client.js";
import type { InterestManager, TopicStatus } from "./manager.js";
import { topicDefinitions, type TopicData, type TopicDefinition, type TopicKey, type TopicParams } from "./topics.js";
const GRACE_PERIOD_MS = 30_000;
/**
* Remote implementation of InterestManager.
* Each cache entry owns one actor connection plus one materialized snapshot.
*/
export class RemoteInterestManager implements InterestManager {
private entries = new Map<string, TopicEntry<any, any, any>>();
constructor(private readonly backend: BackendClient) {}
subscribe<K extends TopicKey>(topicKey: K, params: TopicParams<K>, listener: () => void): () => void {
const definition = topicDefinitions[topicKey] as unknown as TopicDefinition<any, any, any>;
const cacheKey = definition.key(params as any);
let entry = this.entries.get(cacheKey);
if (!entry) {
entry = new TopicEntry(definition, this.backend, params as any);
this.entries.set(cacheKey, entry);
}
entry.cancelTeardown();
entry.addListener(listener);
entry.ensureStarted();
return () => {
const current = this.entries.get(cacheKey);
if (!current) {
return;
}
current.removeListener(listener);
if (current.listenerCount === 0) {
current.scheduleTeardown(GRACE_PERIOD_MS, () => {
this.entries.delete(cacheKey);
});
}
};
}
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined {
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.data as TopicData<K> | undefined;
}
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus {
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.status ?? "loading";
}
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null {
return this.entries.get((topicDefinitions[topicKey] as any).key(params))?.error ?? null;
}
dispose(): void {
for (const entry of this.entries.values()) {
entry.dispose();
}
this.entries.clear();
}
}
class TopicEntry<TData, TParams, TEvent> {
data: TData | undefined;
status: TopicStatus = "loading";
error: Error | null = null;
listenerCount = 0;
private readonly listeners = new Set<() => void>();
private conn: Awaited<ReturnType<TopicDefinition<TData, TParams, TEvent>["connect"]>> | null = null;
private unsubscribeEvent: (() => void) | null = null;
private unsubscribeError: (() => void) | null = null;
private teardownTimer: ReturnType<typeof setTimeout> | null = null;
private startPromise: Promise<void> | null = null;
private started = false;
constructor(
private readonly definition: TopicDefinition<TData, TParams, TEvent>,
private readonly backend: BackendClient,
private readonly params: TParams,
) {}
addListener(listener: () => void): void {
this.listeners.add(listener);
this.listenerCount = this.listeners.size;
}
removeListener(listener: () => void): void {
this.listeners.delete(listener);
this.listenerCount = this.listeners.size;
}
ensureStarted(): void {
if (this.started || this.startPromise) {
return;
}
this.startPromise = this.start().finally(() => {
this.startPromise = null;
});
}
scheduleTeardown(ms: number, onTeardown: () => void): void {
this.teardownTimer = setTimeout(() => {
this.dispose();
onTeardown();
}, ms);
}
cancelTeardown(): void {
if (this.teardownTimer) {
clearTimeout(this.teardownTimer);
this.teardownTimer = null;
}
}
dispose(): void {
this.cancelTeardown();
this.unsubscribeEvent?.();
this.unsubscribeError?.();
if (this.conn) {
void this.conn.dispose();
}
this.conn = null;
this.data = undefined;
this.status = "loading";
this.error = null;
this.started = false;
}
private async start(): Promise<void> {
this.status = "loading";
this.error = null;
this.notify();
try {
this.conn = await this.definition.connect(this.backend, this.params);
this.unsubscribeEvent = this.conn.on(this.definition.event, (event: TEvent) => {
if (this.data === undefined) {
return;
}
this.data = this.definition.applyEvent(this.data, event);
this.notify();
});
this.unsubscribeError = this.conn.onError((error: unknown) => {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
this.notify();
});
this.data = await this.definition.fetchInitial(this.backend, this.params);
this.status = "connected";
this.started = true;
this.notify();
} catch (error) {
this.status = "error";
this.error = error instanceof Error ? error : new Error(String(error));
this.started = false;
this.notify();
}
}
private notify(): void {
for (const listener of [...this.listeners]) {
listener();
}
}
}

View file

@ -0,0 +1,131 @@
import type {
AppEvent,
FoundryAppSnapshot,
ProviderId,
SandboxProcessesEvent,
SessionEvent,
TaskEvent,
WorkbenchSessionDetail,
WorkbenchTaskDetail,
WorkspaceEvent,
WorkspaceSummarySnapshot,
} from "@sandbox-agent/foundry-shared";
import type { ActorConn, BackendClient, SandboxProcessRecord } from "../backend-client.js";
/**
* Topic definitions for the interest manager.
*
* Each topic describes one actor connection plus one materialized read model.
* Events always carry full replacement payloads for the changed entity so the
* client can replace cached state directly instead of reconstructing patches.
*/
export interface TopicDefinition<TData, TParams, TEvent> {
key: (params: TParams) => string;
event: string;
connect: (backend: BackendClient, params: TParams) => Promise<ActorConn>;
fetchInitial: (backend: BackendClient, params: TParams) => Promise<TData>;
applyEvent: (current: TData, event: TEvent) => TData;
}
export interface AppTopicParams {}
export interface WorkspaceTopicParams {
workspaceId: string;
}
export interface TaskTopicParams {
workspaceId: string;
repoId: string;
taskId: string;
}
export interface SessionTopicParams {
workspaceId: string;
repoId: string;
taskId: string;
sessionId: string;
}
export interface SandboxProcessesTopicParams {
workspaceId: string;
providerId: ProviderId;
sandboxId: string;
}
function upsertById<T extends { id: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
const filtered = items.filter((item) => item.id !== nextItem.id);
return [...filtered, nextItem].sort(sort);
}
export const topicDefinitions = {
app: {
key: () => "app",
event: "appUpdated",
connect: (backend: BackendClient, _params: AppTopicParams) => backend.connectWorkspace("app"),
fetchInitial: (backend: BackendClient, _params: AppTopicParams) => backend.getAppSnapshot(),
applyEvent: (_current: FoundryAppSnapshot, event: AppEvent) => event.snapshot,
} satisfies TopicDefinition<FoundryAppSnapshot, AppTopicParams, AppEvent>,
workspace: {
key: (params: WorkspaceTopicParams) => `workspace:${params.workspaceId}`,
event: "workspaceUpdated",
connect: (backend: BackendClient, params: WorkspaceTopicParams) => backend.connectWorkspace(params.workspaceId),
fetchInitial: (backend: BackendClient, params: WorkspaceTopicParams) => backend.getWorkspaceSummary(params.workspaceId),
applyEvent: (current: WorkspaceSummarySnapshot, event: WorkspaceEvent) => {
switch (event.type) {
case "taskSummaryUpdated":
return {
...current,
taskSummaries: upsertById(current.taskSummaries, event.taskSummary, (left, right) => right.updatedAtMs - left.updatedAtMs),
};
case "taskRemoved":
return {
...current,
taskSummaries: current.taskSummaries.filter((task) => task.id !== event.taskId),
};
case "repoAdded":
case "repoUpdated":
return {
...current,
repos: upsertById(current.repos, event.repo, (left, right) => right.latestActivityMs - left.latestActivityMs),
};
case "repoRemoved":
return {
...current,
repos: current.repos.filter((repo) => repo.id !== event.repoId),
};
}
},
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
task: {
key: (params: TaskTopicParams) => `task:${params.workspaceId}:${params.taskId}`,
event: "taskUpdated",
connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.workspaceId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.workspaceId, params.repoId, params.taskId),
applyEvent: (_current: WorkbenchTaskDetail, event: TaskEvent) => event.detail,
} satisfies TopicDefinition<WorkbenchTaskDetail, TaskTopicParams, TaskEvent>,
session: {
key: (params: SessionTopicParams) => `session:${params.workspaceId}:${params.taskId}:${params.sessionId}`,
event: "sessionUpdated",
connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.workspaceId, params.repoId, params.taskId),
fetchInitial: (backend: BackendClient, params: SessionTopicParams) =>
backend.getSessionDetail(params.workspaceId, params.repoId, params.taskId, params.sessionId),
applyEvent: (current: WorkbenchSessionDetail, event: SessionEvent) => {
if (event.session.sessionId !== current.sessionId) {
return current;
}
return event.session;
},
} satisfies TopicDefinition<WorkbenchSessionDetail, SessionTopicParams, SessionEvent>,
sandboxProcesses: {
key: (params: SandboxProcessesTopicParams) => `sandbox:${params.workspaceId}:${params.providerId}:${params.sandboxId}`,
event: "processesUpdated",
connect: (backend: BackendClient, params: SandboxProcessesTopicParams) => backend.connectSandbox(params.workspaceId, params.providerId, params.sandboxId),
fetchInitial: async (backend: BackendClient, params: SandboxProcessesTopicParams) =>
(await backend.listSandboxProcesses(params.workspaceId, params.providerId, params.sandboxId)).processes,
applyEvent: (_current: SandboxProcessRecord[], event: SandboxProcessesEvent) => event.processes,
} satisfies TopicDefinition<SandboxProcessRecord[], SandboxProcessesTopicParams, SandboxProcessesEvent>,
} as const;
export type TopicKey = keyof typeof topicDefinitions;
export type TopicParams<K extends TopicKey> = Parameters<(typeof topicDefinitions)[K]["fetchInitial"]>[1];
export type TopicData<K extends TopicKey> = Awaited<ReturnType<(typeof topicDefinitions)[K]["fetchInitial"]>>;

View file

@ -0,0 +1,56 @@
import { useMemo, useRef, useSyncExternalStore } from "react";
import type { InterestManager, TopicState } from "./manager.js";
import { topicDefinitions, type TopicKey, type TopicParams } from "./topics.js";
/**
* React bridge for the interest manager.
*
* `null` params disable the subscription entirely, which is how screens express
* conditional interest in task/session/sandbox topics.
*/
export function useInterest<K extends TopicKey>(manager: InterestManager, topicKey: K, params: TopicParams<K> | null): TopicState<K> {
const paramsKey = params ? (topicDefinitions[topicKey] as any).key(params) : null;
const paramsRef = useRef<TopicParams<K> | null>(params);
paramsRef.current = params;
const subscribe = useMemo(() => {
return (listener: () => void) => {
const currentParams = paramsRef.current;
if (!currentParams) {
return () => {};
}
return manager.subscribe(topicKey, currentParams, listener);
};
}, [manager, topicKey, paramsKey]);
const getSnapshot = useMemo(() => {
let lastSnapshot: TopicState<K> | null = null;
return (): TopicState<K> => {
const currentParams = paramsRef.current;
const nextSnapshot: TopicState<K> = currentParams
? {
data: manager.getSnapshot(topicKey, currentParams),
status: manager.getStatus(topicKey, currentParams),
error: manager.getError(topicKey, currentParams),
}
: {
data: undefined,
status: "loading",
error: null,
};
// `useSyncExternalStore` requires referentially-stable snapshots when the
// underlying store has not changed. Reuse the previous object whenever
// the topic data/status/error triplet is unchanged.
if (lastSnapshot && lastSnapshot.data === nextSnapshot.data && lastSnapshot.status === nextSnapshot.status && lastSnapshot.error === nextSnapshot.error) {
return lastSnapshot;
}
lastSnapshot = nextSnapshot;
return nextSnapshot;
};
}, [manager, topicKey, paramsKey]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View file

@ -1,7 +1,10 @@
import type {
AddRepoInput,
AppEvent,
CreateTaskInput,
FoundryAppSnapshot,
SandboxProcessesEvent,
SessionEvent,
TaskRecord,
TaskSummary,
TaskWorkbenchChangeModelInput,
@ -16,6 +19,12 @@ import type {
TaskWorkbenchSnapshot,
TaskWorkbenchTabInput,
TaskWorkbenchUpdateDraftInput,
TaskEvent,
WorkbenchSessionDetail,
WorkbenchTaskDetail,
WorkbenchTaskSummary,
WorkspaceEvent,
WorkspaceSummarySnapshot,
HistoryEvent,
HistoryQueryInput,
ProviderId,
@ -27,7 +36,7 @@ import type {
SwitchResult,
} from "@sandbox-agent/foundry-shared";
import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent";
import type { BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js";
import { getSharedMockWorkbenchClient } from "./workbench-client.js";
interface MockProcessRecord extends SandboxProcessRecord {
@ -86,6 +95,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
const workbench = getSharedMockWorkbenchClient();
const listenersBySandboxId = new Map<string, Set<() => void>>();
const processesBySandboxId = new Map<string, MockProcessRecord[]>();
const connectionListeners = new Map<string, Set<(payload: any) => void>>();
let nextPid = 4000;
let nextProcessId = 1;
@ -110,11 +120,174 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
const notifySandbox = (sandboxId: string): void => {
const listeners = listenersBySandboxId.get(sandboxId);
if (!listeners) {
emitSandboxProcessesUpdate(sandboxId);
return;
}
for (const listener of [...listeners]) {
listener();
}
emitSandboxProcessesUpdate(sandboxId);
};
const connectionChannel = (scope: string, event: string): string => `${scope}:${event}`;
const emitConnectionEvent = (scope: string, event: string, payload: any): void => {
const listeners = connectionListeners.get(connectionChannel(scope, event));
if (!listeners) {
return;
}
for (const listener of [...listeners]) {
listener(payload);
}
};
const createConn = (scope: string): ActorConn => ({
on(event: string, listener: (payload: any) => void): () => void {
const channel = connectionChannel(scope, event);
let listeners = connectionListeners.get(channel);
if (!listeners) {
listeners = new Set();
connectionListeners.set(channel, listeners);
}
listeners.add(listener);
return () => {
const current = connectionListeners.get(channel);
if (!current) {
return;
}
current.delete(listener);
if (current.size === 0) {
connectionListeners.delete(channel);
}
};
},
onError(): () => void {
return () => {};
},
async dispose(): Promise<void> {},
});
const buildTaskSummary = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskSummary => ({
id: task.id,
repoId: task.repoId,
title: task.title,
status: task.status,
repoName: task.repoName,
updatedAtMs: task.updatedAtMs,
branch: task.branch,
pullRequest: task.pullRequest,
sessionsSummary: task.tabs.map((tab) => ({
id: tab.id,
sessionId: tab.sessionId,
sessionName: tab.sessionName,
agent: tab.agent,
model: tab.model,
status: tab.status,
thinkingSinceMs: tab.thinkingSinceMs,
unread: tab.unread,
created: tab.created,
})),
});
const buildTaskDetail = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskDetail => ({
...buildTaskSummary(task),
task: task.title,
agentType: task.tabs[0]?.agent === "Codex" ? "codex" : "claude",
runtimeStatus: toTaskStatus(task.status === "archived" ? "archived" : "running", task.status === "archived"),
statusMessage: task.status === "archived" ? "archived" : "mock sandbox ready",
activeSessionId: task.tabs[0]?.sessionId ?? null,
diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0",
prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null,
reviewStatus: null,
fileChanges: task.fileChanges,
diffs: task.diffs,
fileTree: task.fileTree,
minutesUsed: task.minutesUsed,
sandboxes: [
{
providerId: "local",
sandboxId: task.id,
cwd: mockCwd(task.repoName, task.id),
},
],
activeSandboxId: task.id,
});
const buildSessionDetail = (task: TaskWorkbenchSnapshot["tasks"][number], tabId: string): WorkbenchSessionDetail => {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`Unknown mock tab ${tabId} for task ${task.id}`);
}
return {
sessionId: tab.id,
tabId: tab.id,
sandboxSessionId: tab.sessionId,
sessionName: tab.sessionName,
agent: tab.agent,
model: tab.model,
status: tab.status,
thinkingSinceMs: tab.thinkingSinceMs,
unread: tab.unread,
created: tab.created,
draft: tab.draft,
transcript: tab.transcript,
};
};
const buildWorkspaceSummary = (): WorkspaceSummarySnapshot => {
const snapshot = workbench.getSnapshot();
const taskSummaries = snapshot.tasks.map(buildTaskSummary);
return {
workspaceId: defaultWorkspaceId,
repos: snapshot.repos.map((repo) => {
const repoTasks = taskSummaries.filter((task) => task.repoId === repo.id);
return {
id: repo.id,
label: repo.label,
taskCount: repoTasks.length,
latestActivityMs: repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), 0),
};
}),
taskSummaries,
};
};
const workspaceScope = (workspaceId: string): string => `workspace:${workspaceId}`;
const taskScope = (workspaceId: string, repoId: string, taskId: string): string => `task:${workspaceId}:${repoId}:${taskId}`;
const sandboxScope = (workspaceId: string, providerId: string, sandboxId: string): string => `sandbox:${workspaceId}:${providerId}:${sandboxId}`;
const emitWorkspaceSnapshot = (): void => {
const summary = buildWorkspaceSummary();
const latestTask = [...summary.taskSummaries].sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0] ?? null;
if (latestTask) {
emitConnectionEvent(workspaceScope(defaultWorkspaceId), "workspaceUpdated", {
type: "taskSummaryUpdated",
taskSummary: latestTask,
} satisfies WorkspaceEvent);
}
};
const emitTaskUpdate = (taskId: string): void => {
const task = requireTask(taskId);
emitConnectionEvent(taskScope(defaultWorkspaceId, task.repoId, task.id), "taskUpdated", {
type: "taskDetailUpdated",
detail: buildTaskDetail(task),
} satisfies TaskEvent);
};
const emitSessionUpdate = (taskId: string, tabId: string): void => {
const task = requireTask(taskId);
emitConnectionEvent(taskScope(defaultWorkspaceId, task.repoId, task.id), "sessionUpdated", {
type: "sessionUpdated",
session: buildSessionDetail(task, tabId),
} satisfies SessionEvent);
};
const emitSandboxProcessesUpdate = (sandboxId: string): void => {
emitConnectionEvent(sandboxScope(defaultWorkspaceId, "local", sandboxId), "processesUpdated", {
type: "processesUpdated",
processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)),
} satisfies SandboxProcessesEvent);
};
const buildTaskRecord = (taskId: string): TaskRecord => {
@ -192,7 +365,19 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
return unsupportedAppSnapshot();
},
subscribeApp(_listener: () => void): () => void {
async connectWorkspace(workspaceId: string): Promise<ActorConn> {
return createConn(workspaceScope(workspaceId));
},
async connectTask(workspaceId: string, repoId: string, taskId: string): Promise<ActorConn> {
return createConn(taskScope(workspaceId, repoId, taskId));
},
async connectSandbox(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<ActorConn> {
return createConn(sandboxScope(workspaceId, providerId, sandboxId));
},
subscribeApp(): () => void {
return () => {};
},
@ -462,6 +647,18 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
return { endpoint: "mock://terminal-unavailable" };
},
async getWorkspaceSummary(): Promise<WorkspaceSummarySnapshot> {
return buildWorkspaceSummary();
},
async getTaskDetail(_workspaceId: string, _repoId: string, taskId: string): Promise<WorkbenchTaskDetail> {
return buildTaskDetail(requireTask(taskId));
},
async getSessionDetail(_workspaceId: string, _repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail> {
return buildSessionDetail(requireTask(taskId), sessionId);
},
async getWorkbench(): Promise<TaskWorkbenchSnapshot> {
return workbench.getSnapshot();
},
@ -471,59 +668,99 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
},
async createWorkbenchTask(_workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise<TaskWorkbenchCreateTaskResponse> {
return await workbench.createTask(input);
const created = await workbench.createTask(input);
emitWorkspaceSnapshot();
emitTaskUpdate(created.taskId);
if (created.tabId) {
emitSessionUpdate(created.taskId, created.tabId);
}
return created;
},
async markWorkbenchUnread(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await workbench.markTaskUnread(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
},
async renameWorkbenchTask(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await workbench.renameTask(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
},
async renameWorkbenchBranch(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise<void> {
await workbench.renameBranch(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
},
async createWorkbenchSession(_workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> {
return await workbench.addTab(input);
const created = await workbench.addTab(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, created.tabId);
return created;
},
async renameWorkbenchSession(_workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise<void> {
await workbench.renameSession(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.tabId);
},
async setWorkbenchSessionUnread(_workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
await workbench.setSessionUnread(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.tabId);
},
async updateWorkbenchDraft(_workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
await workbench.updateDraft(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.tabId);
},
async changeWorkbenchModel(_workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise<void> {
await workbench.changeModel(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.tabId);
},
async sendWorkbenchMessage(_workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise<void> {
await workbench.sendMessage(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.tabId);
},
async stopWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await workbench.stopAgent(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
emitSessionUpdate(input.taskId, input.tabId);
},
async closeWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise<void> {
await workbench.closeTab(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
},
async publishWorkbenchPr(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void> {
await workbench.publishPr(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
},
async revertWorkbenchFile(_workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void> {
await workbench.revertFile(input);
emitWorkspaceSnapshot();
emitTaskUpdate(input.taskId);
},
async health(): Promise<{ ok: true }> {

View file

@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { WorkspaceEvent, WorkspaceSummarySnapshot } from "@sandbox-agent/foundry-shared";
import type { ActorConn, BackendClient } from "../src/backend-client.js";
import { RemoteInterestManager } from "../src/interest/remote-manager.js";
class FakeActorConn implements ActorConn {
private readonly listeners = new Map<string, Set<(payload: any) => void>>();
private readonly errorListeners = new Set<(error: unknown) => void>();
disposeCount = 0;
on(event: string, listener: (payload: any) => void): () => void {
let current = this.listeners.get(event);
if (!current) {
current = new Set();
this.listeners.set(event, current);
}
current.add(listener);
return () => {
current?.delete(listener);
if (current?.size === 0) {
this.listeners.delete(event);
}
};
}
onError(listener: (error: unknown) => void): () => void {
this.errorListeners.add(listener);
return () => {
this.errorListeners.delete(listener);
};
}
emit(event: string, payload: unknown): void {
for (const listener of this.listeners.get(event) ?? []) {
listener(payload);
}
}
emitError(error: unknown): void {
for (const listener of this.errorListeners) {
listener(error);
}
}
async dispose(): Promise<void> {
this.disposeCount += 1;
}
}
function workspaceSnapshot(): WorkspaceSummarySnapshot {
return {
workspaceId: "ws-1",
repos: [{ id: "repo-1", label: "repo-1", taskCount: 1, latestActivityMs: 10 }],
taskSummaries: [
{
id: "task-1",
repoId: "repo-1",
title: "Initial task",
status: "idle",
repoName: "repo-1",
updatedAtMs: 10,
branch: "main",
pullRequest: null,
sessionsSummary: [],
},
],
};
}
function createBackend(conn: FakeActorConn, snapshot: WorkspaceSummarySnapshot): BackendClient {
return {
connectWorkspace: vi.fn(async () => conn),
getWorkspaceSummary: vi.fn(async () => snapshot),
} as unknown as BackendClient;
}
async function flushAsyncWork(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
describe("RemoteInterestManager", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("shares one connection per topic key and applies incoming events", async () => {
const conn = new FakeActorConn();
const backend = createBackend(conn, workspaceSnapshot());
const manager = new RemoteInterestManager(backend);
const params = { workspaceId: "ws-1" } as const;
const listenerA = vi.fn();
const listenerB = vi.fn();
const unsubscribeA = manager.subscribe("workspace", params, listenerA);
const unsubscribeB = manager.subscribe("workspace", params, listenerB);
await flushAsyncWork();
expect(backend.connectWorkspace).toHaveBeenCalledTimes(1);
expect(backend.getWorkspaceSummary).toHaveBeenCalledTimes(1);
expect(manager.getStatus("workspace", params)).toBe("connected");
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Initial task");
conn.emit("workspaceUpdated", {
type: "taskSummaryUpdated",
taskSummary: {
id: "task-1",
repoId: "repo-1",
title: "Updated task",
status: "running",
repoName: "repo-1",
updatedAtMs: 20,
branch: "feature/live",
pullRequest: null,
sessionsSummary: [],
},
} satisfies WorkspaceEvent);
expect(manager.getSnapshot("workspace", params)?.taskSummaries[0]?.title).toBe("Updated task");
expect(listenerA).toHaveBeenCalled();
expect(listenerB).toHaveBeenCalled();
unsubscribeA();
unsubscribeB();
manager.dispose();
});
it("keeps a topic warm during the grace period and tears it down afterwards", async () => {
const conn = new FakeActorConn();
const backend = createBackend(conn, workspaceSnapshot());
const manager = new RemoteInterestManager(backend);
const params = { workspaceId: "ws-1" } as const;
const unsubscribeA = manager.subscribe("workspace", params, () => {});
await flushAsyncWork();
unsubscribeA();
vi.advanceTimersByTime(29_000);
const unsubscribeB = manager.subscribe("workspace", params, () => {});
await flushAsyncWork();
expect(backend.connectWorkspace).toHaveBeenCalledTimes(1);
expect(conn.disposeCount).toBe(0);
unsubscribeB();
vi.advanceTimersByTime(30_000);
expect(conn.disposeCount).toBe(1);
expect(manager.getSnapshot("workspace", params)).toBeUndefined();
});
it("surfaces connection errors to subscribers", async () => {
const conn = new FakeActorConn();
const backend = createBackend(conn, workspaceSnapshot());
const manager = new RemoteInterestManager(backend);
const params = { workspaceId: "ws-1" } as const;
manager.subscribe("workspace", params, () => {});
await flushAsyncWork();
conn.emitError(new Error("socket dropped"));
expect(manager.getStatus("workspace", params)).toBe("error");
expect(manager.getError("workspace", params)?.message).toBe("socket dropped");
});
});

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;
}

View file

@ -12,6 +12,7 @@
},
"dependencies": {
"pino": "^10.3.1",
"sandbox-agent": "workspace:*",
"zod": "^4.1.5"
},
"devDependencies": {

View file

@ -2,5 +2,6 @@ export * from "./app-shell.js";
export * from "./contracts.js";
export * from "./config.js";
export * from "./logging.js";
export * from "./realtime-events.js";
export * from "./workbench.js";
export * from "./workspace.js";

View file

@ -0,0 +1,36 @@
import type { FoundryAppSnapshot } from "./app-shell.js";
import type { WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
export interface SandboxProcessSnapshot {
id: string;
command: string;
args: string[];
createdAtMs: number;
cwd?: string | null;
exitCode?: number | null;
exitedAtMs?: number | null;
interactive: boolean;
pid?: number | null;
status: "running" | "exited";
tty: boolean;
}
/** Workspace-level events broadcast by the workspace actor. */
export type WorkspaceEvent =
| { type: "taskSummaryUpdated"; taskSummary: WorkbenchTaskSummary }
| { type: "taskRemoved"; taskId: string }
| { type: "repoAdded"; repo: WorkbenchRepoSummary }
| { type: "repoUpdated"; repo: WorkbenchRepoSummary }
| { type: "repoRemoved"; repoId: string };
/** Task-level events broadcast by the task actor. */
export type TaskEvent = { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail };
/** Session-level events broadcast by the task actor and filtered by sessionId on the client. */
export type SessionEvent = { type: "sessionUpdated"; session: WorkbenchSessionDetail };
/** App-level events broadcast by the app workspace actor. */
export type AppEvent = { type: "appUpdated"; snapshot: FoundryAppSnapshot };
/** Sandbox process events broadcast by the sandbox instance actor. */
export type SandboxProcessesEvent = { type: "processesUpdated"; processes: SandboxProcessSnapshot[] };

View file

@ -1,3 +1,5 @@
import type { AgentType, ProviderId, TaskStatus } from "./contracts.js";
export type WorkbenchTaskStatus = "running" | "idle" | "new" | "archived";
export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor";
export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3";
@ -18,7 +20,8 @@ export interface WorkbenchComposerDraft {
updatedAtMs: number | null;
}
export interface WorkbenchAgentTab {
/** Session metadata without transcript content. */
export interface WorkbenchSessionSummary {
id: string;
sessionId: string | null;
sessionName: string;
@ -28,6 +31,21 @@ export interface WorkbenchAgentTab {
thinkingSinceMs: number | null;
unread: boolean;
created: boolean;
}
/** Full session content — only fetched when viewing a specific session tab. */
export interface WorkbenchSessionDetail {
/** Stable UI tab id used for the session topic key and routing. */
sessionId: string;
tabId: string;
sandboxSessionId: string | null;
sessionName: string;
agent: WorkbenchAgentKind;
model: WorkbenchModelId;
status: "running" | "idle" | "error";
thinkingSinceMs: number | null;
unread: boolean;
created: boolean;
draft: WorkbenchComposerDraft;
transcript: WorkbenchTranscriptEvent[];
}
@ -76,6 +94,73 @@ export interface WorkbenchPullRequestSummary {
status: "draft" | "ready";
}
export interface WorkbenchSandboxSummary {
providerId: ProviderId;
sandboxId: string;
cwd: string | null;
}
/** Sidebar-level task data. Materialized in the workspace actor's SQLite. */
export interface WorkbenchTaskSummary {
id: string;
repoId: string;
title: string;
status: WorkbenchTaskStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null;
/** Summary of sessions — no transcript content. */
sessionsSummary: WorkbenchSessionSummary[];
}
/** Full task detail — only fetched when viewing a specific task. */
export interface WorkbenchTaskDetail extends WorkbenchTaskSummary {
/** Original task prompt/instructions shown in the detail view. */
task: string;
/** Agent choice used when creating new sandbox sessions for this task. */
agentType: AgentType | null;
/** Underlying task runtime status preserved for detail views and error handling. */
runtimeStatus: TaskStatus;
statusMessage: string | null;
activeSessionId: string | null;
diffStat: string | null;
prUrl: string | null;
reviewStatus: string | null;
fileChanges: WorkbenchFileChange[];
diffs: Record<string, string>;
fileTree: WorkbenchFileTreeNode[];
minutesUsed: number;
/** Sandbox info for this task. */
sandboxes: WorkbenchSandboxSummary[];
activeSandboxId: string | null;
}
/** Repo-level summary for workspace sidebar. */
export interface WorkbenchRepoSummary {
id: string;
label: string;
/** Aggregated branch/task overview state (replaces getRepoOverview polling). */
taskCount: number;
latestActivityMs: number;
}
/** Workspace-level snapshot — initial fetch for the workspace topic. */
export interface WorkspaceSummarySnapshot {
workspaceId: string;
repos: WorkbenchRepoSummary[];
taskSummaries: WorkbenchTaskSummary[];
}
/**
* Deprecated compatibility aliases for older mock/view-model code.
* New code should use the summary/detail/topic-specific types above.
*/
export interface WorkbenchAgentTab extends WorkbenchSessionSummary {
draft: WorkbenchComposerDraft;
transcript: WorkbenchTranscriptEvent[];
}
export interface WorkbenchTask {
id: string;
repoId: string;

View file

@ -0,0 +1,919 @@
# Realtime Interest Manager — Implementation Spec
## Overview
Replace the current polling + empty-notification + full-refetch architecture with a push-based realtime system. The client subscribes to topics, receives the initial state, and then receives full replacement payloads for changed entities over WebSocket. No polling. No re-fetching.
This spec covers three layers: backend (materialized state + broadcast), client library (interest manager), and frontend (hook consumption). Comment architecture-related code throughout so new contributors can understand the data flow from comments alone.
---
## 1. Data Model: What Changes
### 1.1 Split `WorkbenchTask` into summary and detail types
**File:** `packages/shared/src/workbench.ts`
Currently `WorkbenchTask` is a single flat type carrying everything (sidebar fields + transcripts + diffs + file tree). Split it:
```typescript
/** Sidebar-level task data. Materialized in the workspace actor's SQLite. */
export interface WorkbenchTaskSummary {
id: string;
repoId: string;
title: string;
status: WorkbenchTaskStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null;
/** Summary of sessions — no transcript content. */
sessionsSummary: WorkbenchSessionSummary[];
}
/** Session metadata without transcript content. */
export interface WorkbenchSessionSummary {
id: string;
sessionId: string | null;
sessionName: string;
agent: WorkbenchAgentKind;
model: WorkbenchModelId;
status: "running" | "idle" | "error";
thinkingSinceMs: number | null;
unread: boolean;
created: boolean;
}
/** Repo-level summary for workspace sidebar. */
export interface WorkbenchRepoSummary {
id: string;
label: string;
/** Aggregated branch/task overview state (replaces getRepoOverview polling). */
taskCount: number;
latestActivityMs: number;
}
/** Full task detail — only fetched when viewing a specific task. */
export interface WorkbenchTaskDetail {
id: string;
repoId: string;
title: string;
status: WorkbenchTaskStatus;
repoName: string;
updatedAtMs: number;
branch: string | null;
pullRequest: WorkbenchPullRequestSummary | null;
sessionsSummary: WorkbenchSessionSummary[];
fileChanges: WorkbenchFileChange[];
diffs: Record<string, string>;
fileTree: WorkbenchFileTreeNode[];
minutesUsed: number;
/** Sandbox info for this task. */
sandboxes: WorkbenchSandboxSummary[];
activeSandboxId: string | null;
}
export interface WorkbenchSandboxSummary {
providerId: string;
sandboxId: string;
cwd: string | null;
}
/** Full session content — only fetched when viewing a specific session tab. */
export interface WorkbenchSessionDetail {
sessionId: string;
tabId: string;
sessionName: string;
agent: WorkbenchAgentKind;
model: WorkbenchModelId;
status: "running" | "idle" | "error";
thinkingSinceMs: number | null;
unread: boolean;
draft: WorkbenchComposerDraft;
transcript: WorkbenchTranscriptEvent[];
}
/** Workspace-level snapshot — initial fetch for the workspace topic. */
export interface WorkspaceSummarySnapshot {
workspaceId: string;
repos: WorkbenchRepoSummary[];
taskSummaries: WorkbenchTaskSummary[];
}
```
Remove the old `TaskWorkbenchSnapshot` type and `WorkbenchTask` type once migration is complete.
### 1.2 Event payload types
**File:** `packages/shared/src/realtime-events.ts` (new file)
Each event carries the full new state of the changed entity — not a patch, not an empty notification.
```typescript
/** Workspace-level events broadcast by the workspace actor. */
export type WorkspaceEvent =
| { type: "taskSummaryUpdated"; taskSummary: WorkbenchTaskSummary }
| { type: "taskRemoved"; taskId: string }
| { type: "repoAdded"; repo: WorkbenchRepoSummary }
| { type: "repoUpdated"; repo: WorkbenchRepoSummary }
| { type: "repoRemoved"; repoId: string };
/** Task-level events broadcast by the task actor. */
export type TaskEvent =
| { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail };
/** Session-level events broadcast by the task actor, filtered by sessionId on the client. */
export type SessionEvent =
| { type: "sessionUpdated"; session: WorkbenchSessionDetail };
/** App-level events broadcast by the app workspace actor. */
export type AppEvent =
| { type: "appUpdated"; snapshot: FoundryAppSnapshot };
/** Sandbox process events broadcast by the sandbox instance actor. */
export type SandboxProcessesEvent =
| { type: "processesUpdated"; processes: SandboxProcessRecord[] };
```
---
## 2. Backend: Materialized State + Broadcasts
### 2.1 Workspace actor — materialized sidebar state
**Files:**
- `packages/backend/src/actors/workspace/db/schema.ts` — add tables
- `packages/backend/src/actors/workspace/actions.ts` — replace `buildWorkbenchSnapshot`, add delta handlers
Add to workspace actor SQLite schema:
```typescript
export const taskSummaries = sqliteTable("task_summaries", {
taskId: text("task_id").primaryKey(),
repoId: text("repo_id").notNull(),
title: text("title").notNull(),
status: text("status").notNull(), // WorkbenchTaskStatus
repoName: text("repo_name").notNull(),
updatedAtMs: integer("updated_at_ms").notNull(),
branch: text("branch"),
pullRequestJson: text("pull_request_json"), // JSON-serialized WorkbenchPullRequestSummary | null
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), // JSON array of WorkbenchSessionSummary
});
```
New workspace actions:
```typescript
/**
* Called by task actors when their summary-level state changes.
* Upserts the task summary row and broadcasts the update to all connected clients.
*
* This is the core of the materialized state pattern: task actors push their
* summary changes here instead of requiring clients to fan out to every task.
*/
async applyTaskSummaryUpdate(c, input: { taskSummary: WorkbenchTaskSummary }) {
// Upsert into taskSummaries table
await c.db.insert(taskSummaries).values(toRow(input.taskSummary))
.onConflictDoUpdate({ target: taskSummaries.taskId, set: toRow(input.taskSummary) }).run();
// Broadcast to connected clients
c.broadcast("workspaceUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary });
}
async removeTaskSummary(c, input: { taskId: string }) {
await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run();
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId });
}
/**
* Initial fetch for the workspace topic.
* Reads entirely from local SQLite — no fan-out to child actors.
*/
async getWorkspaceSummary(c, input: { workspaceId: string }): Promise<WorkspaceSummarySnapshot> {
const repoRows = await c.db.select().from(repos).orderBy(desc(repos.updatedAt)).all();
const taskRows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all();
return {
workspaceId: c.state.workspaceId,
repos: repoRows.map(toRepoSummary),
taskSummaries: taskRows.map(toTaskSummary),
};
}
```
Replace `buildWorkbenchSnapshot` (the fan-out) — keep it only as a `reconcileWorkbenchState` background action for recovery/rebuild.
### 2.2 Task actor — push summaries to workspace + broadcast detail
**Files:**
- `packages/backend/src/actors/task/workbench.ts` — replace `notifyWorkbenchUpdated` calls
Every place that currently calls `notifyWorkbenchUpdated(c)` (there are ~20 call sites) must instead:
1. Build the current `WorkbenchTaskSummary` from local state.
2. Push it to the workspace actor: `workspace.applyTaskSummaryUpdate({ taskSummary })`.
3. Build the current `WorkbenchTaskDetail` from local state.
4. Broadcast to directly-connected clients: `c.broadcast("taskUpdated", { type: "taskDetailUpdated", detail })`.
5. If session state changed, also broadcast: `c.broadcast("sessionUpdated", { type: "sessionUpdated", session: buildSessionDetail(c, sessionId) })`.
Add helper functions:
```typescript
/**
* Builds a WorkbenchTaskSummary from local task actor state.
* This is what gets pushed to the workspace actor for sidebar materialization.
*/
function buildTaskSummary(c: any): WorkbenchTaskSummary { ... }
/**
* Builds a WorkbenchTaskDetail from local task actor state.
* This is broadcast to clients directly connected to this task.
*/
function buildTaskDetail(c: any): WorkbenchTaskDetail { ... }
/**
* Builds a WorkbenchSessionDetail for a specific session.
* Broadcast to clients subscribed to this session's updates.
*/
function buildSessionDetail(c: any, sessionId: string): WorkbenchSessionDetail { ... }
/**
* Replaces the old notifyWorkbenchUpdated pattern.
* Pushes summary to workspace actor + broadcasts detail to direct subscribers.
*/
async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }) {
// Push summary to parent workspace actor
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
await workspace.applyTaskSummaryUpdate({ taskSummary: buildTaskSummary(c) });
// Broadcast detail to clients connected to this task
c.broadcast("taskUpdated", { type: "taskDetailUpdated", detail: buildTaskDetail(c) });
// If a specific session changed, broadcast session detail
if (options?.sessionId) {
c.broadcast("sessionUpdated", {
type: "sessionUpdated",
session: buildSessionDetail(c, options.sessionId),
});
}
}
```
### 2.3 Task actor — new actions for initial fetch
```typescript
/**
* Initial fetch for the task topic.
* Reads from local SQLite only — no cross-actor calls.
*/
async getTaskDetail(c): Promise<WorkbenchTaskDetail> { ... }
/**
* Initial fetch for the session topic.
* Returns full session content including transcript.
*/
async getSessionDetail(c, input: { sessionId: string }): Promise<WorkbenchSessionDetail> { ... }
```
### 2.4 App workspace actor
**File:** `packages/backend/src/actors/workspace/app-shell.ts`
Change `c.broadcast("appUpdated", { at: Date.now(), sessionId })` to:
```typescript
c.broadcast("appUpdated", { type: "appUpdated", snapshot: await buildAppSnapshot(c, sessionId) });
```
### 2.5 Sandbox instance actor
**File:** `packages/backend/src/actors/sandbox-instance/index.ts`
Change `broadcastProcessesUpdated` to include the process list:
```typescript
function broadcastProcessesUpdated(c: any): void {
const processes = /* read from local DB */;
c.broadcast("processesUpdated", { type: "processesUpdated", processes });
}
```
---
## 3. Client Library: Interest Manager
### 3.1 Topic definitions
**File:** `packages/client/src/interest/topics.ts` (new)
```typescript
/**
* Topic definitions for the interest manager.
*
* Each topic defines how to connect to an actor, fetch initial state,
* which event to listen for, and how to apply incoming events to cached state.
*
* The interest manager uses these definitions to manage WebSocket connections,
* cached state, and subscriptions for all realtime data flows.
*/
export interface TopicDefinition<TData, TParams, TEvent> {
/** Derive a unique cache key from params. */
key: (params: TParams) => string;
/** Which broadcast event name to listen for on the actor connection. */
event: string;
/** Open a WebSocket connection to the actor. */
connect: (backend: BackendClient, params: TParams) => Promise<ActorConn>;
/** Fetch the initial snapshot from the actor. */
fetchInitial: (backend: BackendClient, params: TParams) => Promise<TData>;
/** Apply an incoming event to the current cached state. Returns the new state. */
applyEvent: (current: TData, event: TEvent) => TData;
}
export interface AppTopicParams {}
export interface WorkspaceTopicParams { workspaceId: string }
export interface TaskTopicParams { workspaceId: string; repoId: string; taskId: string }
export interface SessionTopicParams { workspaceId: string; repoId: string; taskId: string; sessionId: string }
export interface SandboxProcessesTopicParams { workspaceId: string; providerId: string; sandboxId: string }
export const topicDefinitions = {
app: {
key: () => "app",
event: "appUpdated",
connect: (b, _p) => b.connectWorkspace("app"),
fetchInitial: (b, _p) => b.getAppSnapshot(),
applyEvent: (_current, event: AppEvent) => event.snapshot,
} satisfies TopicDefinition<FoundryAppSnapshot, AppTopicParams, AppEvent>,
workspace: {
key: (p) => `workspace:${p.workspaceId}`,
event: "workspaceUpdated",
connect: (b, p) => b.connectWorkspace(p.workspaceId),
fetchInitial: (b, p) => b.getWorkspaceSummary(p.workspaceId),
applyEvent: (current, event: WorkspaceEvent) => {
switch (event.type) {
case "taskSummaryUpdated":
return {
...current,
taskSummaries: upsertById(current.taskSummaries, event.taskSummary),
};
case "taskRemoved":
return {
...current,
taskSummaries: current.taskSummaries.filter(t => t.id !== event.taskId),
};
case "repoAdded":
case "repoUpdated":
return {
...current,
repos: upsertById(current.repos, event.repo),
};
case "repoRemoved":
return {
...current,
repos: current.repos.filter(r => r.id !== event.repoId),
};
}
},
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
task: {
key: (p) => `task:${p.workspaceId}:${p.taskId}`,
event: "taskUpdated",
connect: (b, p) => b.connectTask(p.workspaceId, p.repoId, p.taskId),
fetchInitial: (b, p) => b.getTaskDetail(p.workspaceId, p.repoId, p.taskId),
applyEvent: (_current, event: TaskEvent) => event.detail,
} satisfies TopicDefinition<WorkbenchTaskDetail, TaskTopicParams, TaskEvent>,
session: {
key: (p) => `session:${p.workspaceId}:${p.taskId}:${p.sessionId}`,
event: "sessionUpdated",
// Reuses the task actor connection — same actor, different event.
connect: (b, p) => b.connectTask(p.workspaceId, p.repoId, p.taskId),
fetchInitial: (b, p) => b.getSessionDetail(p.workspaceId, p.repoId, p.taskId, p.sessionId),
applyEvent: (current, event: SessionEvent) => {
// Filter: only apply if this event is for our session
if (event.session.sessionId !== current.sessionId) return current;
return event.session;
},
} satisfies TopicDefinition<WorkbenchSessionDetail, SessionTopicParams, SessionEvent>,
sandboxProcesses: {
key: (p) => `sandbox:${p.workspaceId}:${p.sandboxId}`,
event: "processesUpdated",
connect: (b, p) => b.connectSandbox(p.workspaceId, p.providerId, p.sandboxId),
fetchInitial: (b, p) => b.listSandboxProcesses(p.workspaceId, p.providerId, p.sandboxId),
applyEvent: (_current, event: SandboxProcessesEvent) => event.processes,
} satisfies TopicDefinition<SandboxProcessRecord[], SandboxProcessesTopicParams, SandboxProcessesEvent>,
} as const;
/** Derive TypeScript types from the topic registry. */
export type TopicKey = keyof typeof topicDefinitions;
export type TopicParams<K extends TopicKey> = Parameters<(typeof topicDefinitions)[K]["fetchInitial"]>[1];
export type TopicData<K extends TopicKey> = Awaited<ReturnType<(typeof topicDefinitions)[K]["fetchInitial"]>>;
```
### 3.2 Interest manager interface
**File:** `packages/client/src/interest/manager.ts` (new)
```typescript
/**
* The InterestManager owns all realtime actor connections and cached state.
*
* Architecture:
* - Each topic (app, workspace, task, session, sandboxProcesses) maps to an actor + event.
* - On first subscription, the manager opens a WebSocket connection, fetches initial state,
* and listens for events. Events carry full replacement payloads for the changed entity.
* - Multiple subscribers to the same topic share one connection and one cached state.
* - When the last subscriber leaves, a 30-second grace period keeps the connection alive
* to avoid thrashing during screen navigation or React double-renders.
* - The interface is identical for mock and remote implementations.
*/
export interface InterestManager {
/**
* Subscribe to a topic. Returns an unsubscribe function.
* On first subscriber: opens connection, fetches initial state, starts listening.
* On last unsubscribe: starts 30s grace period before teardown.
*/
subscribe<K extends TopicKey>(
topicKey: K,
params: TopicParams<K>,
listener: () => void,
): () => void;
/** Get the current cached state for a topic. Returns undefined if not yet loaded. */
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined;
/** Get the connection/loading status for a topic. */
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus;
/** Get the error (if any) for a topic. */
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null;
/** Dispose all connections and cached state. */
dispose(): void;
}
export type TopicStatus = "loading" | "connected" | "error";
export interface TopicState<K extends TopicKey> {
data: TopicData<K> | undefined;
status: TopicStatus;
error: Error | null;
}
```
### 3.3 Remote implementation
**File:** `packages/client/src/interest/remote-manager.ts` (new)
```typescript
const GRACE_PERIOD_MS = 30_000;
/**
* Remote implementation of InterestManager.
* Manages WebSocket connections to RivetKit actors via BackendClient.
*/
export class RemoteInterestManager implements InterestManager {
private entries = new Map<string, TopicEntry<any, any, any>>();
constructor(private backend: BackendClient) {}
subscribe<K extends TopicKey>(topicKey: K, params: TopicParams<K>, listener: () => void): () => void {
const def = topicDefinitions[topicKey];
const cacheKey = def.key(params);
let entry = this.entries.get(cacheKey);
if (!entry) {
entry = new TopicEntry(def, this.backend, params);
this.entries.set(cacheKey, entry);
}
entry.cancelTeardown();
entry.addListener(listener);
entry.ensureStarted();
return () => {
entry!.removeListener(listener);
if (entry!.listenerCount === 0) {
entry!.scheduleTeardown(GRACE_PERIOD_MS, () => {
this.entries.delete(cacheKey);
});
}
};
}
getSnapshot<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicData<K> | undefined {
const cacheKey = topicDefinitions[topicKey].key(params);
return this.entries.get(cacheKey)?.data;
}
getStatus<K extends TopicKey>(topicKey: K, params: TopicParams<K>): TopicStatus {
const cacheKey = topicDefinitions[topicKey].key(params);
return this.entries.get(cacheKey)?.status ?? "loading";
}
getError<K extends TopicKey>(topicKey: K, params: TopicParams<K>): Error | null {
const cacheKey = topicDefinitions[topicKey].key(params);
return this.entries.get(cacheKey)?.error ?? null;
}
dispose(): void {
for (const entry of this.entries.values()) {
entry.dispose();
}
this.entries.clear();
}
}
/**
* Internal entry managing one topic's connection, state, and listeners.
*
* Lifecycle:
* 1. ensureStarted() — opens WebSocket, fetches initial state, subscribes to events.
* 2. Events arrive — applyEvent() updates cached state, notifies listeners.
* 3. Last listener leaves — scheduleTeardown() starts 30s timer.
* 4. Timer fires or dispose() called — closes WebSocket, drops state.
* 5. If a new subscriber arrives during grace period — cancelTeardown(), reuse connection.
*/
class TopicEntry<TData, TParams, TEvent> {
data: TData | undefined = undefined;
status: TopicStatus = "loading";
error: Error | null = null;
listenerCount = 0;
private listeners = new Set<() => void>();
private conn: ActorConn | null = null;
private unsubscribeEvent: (() => void) | null = null;
private teardownTimer: ReturnType<typeof setTimeout> | null = null;
private started = false;
private startPromise: Promise<void> | null = null;
constructor(
private def: TopicDefinition<TData, TParams, TEvent>,
private backend: BackendClient,
private params: TParams,
) {}
addListener(listener: () => void) {
this.listeners.add(listener);
this.listenerCount = this.listeners.size;
}
removeListener(listener: () => void) {
this.listeners.delete(listener);
this.listenerCount = this.listeners.size;
}
ensureStarted() {
if (this.started || this.startPromise) return;
this.startPromise = this.start().finally(() => { this.startPromise = null; });
}
private async start() {
try {
// Open connection
this.conn = await this.def.connect(this.backend, this.params);
// Subscribe to events
this.unsubscribeEvent = this.conn.on(this.def.event, (event: TEvent) => {
if (this.data !== undefined) {
this.data = this.def.applyEvent(this.data, event);
this.notify();
}
});
// Fetch initial state
this.data = await this.def.fetchInitial(this.backend, this.params);
this.status = "connected";
this.started = true;
this.notify();
} catch (err) {
this.status = "error";
this.error = err instanceof Error ? err : new Error(String(err));
this.notify();
}
}
scheduleTeardown(ms: number, onTeardown: () => void) {
this.teardownTimer = setTimeout(() => {
this.dispose();
onTeardown();
}, ms);
}
cancelTeardown() {
if (this.teardownTimer) {
clearTimeout(this.teardownTimer);
this.teardownTimer = null;
}
}
dispose() {
this.cancelTeardown();
this.unsubscribeEvent?.();
if (this.conn) {
void (this.conn as any).dispose?.();
}
this.conn = null;
this.data = undefined;
this.status = "loading";
this.started = false;
}
private notify() {
for (const listener of [...this.listeners]) {
listener();
}
}
}
```
### 3.4 Mock implementation
**File:** `packages/client/src/interest/mock-manager.ts` (new)
Same `InterestManager` interface. Uses in-memory state. Topic definitions provide mock data. Mutations call `applyEvent` directly on the entry to simulate broadcasts. No WebSocket connections.
### 3.5 React hook
**File:** `packages/client/src/interest/use-interest.ts` (new)
```typescript
import { useSyncExternalStore, useMemo } from "react";
/**
* Subscribe to a realtime topic. Returns the current state, loading status, and error.
*
* - Pass `null` as params to disable the subscription (conditional interest).
* - Data is cached for 30 seconds after the last subscriber leaves.
* - Multiple components subscribing to the same topic share one connection.
*
* @example
* // Subscribe to workspace sidebar data
* const workspace = useInterest("workspace", { workspaceId });
*
* // Subscribe to task detail (only when viewing a task)
* const task = useInterest("task", selectedTaskId ? { workspaceId, repoId, taskId } : null);
*
* // Subscribe to active session content
* const session = useInterest("session", activeSessionId ? { workspaceId, repoId, taskId, sessionId } : null);
*/
export function useInterest<K extends TopicKey>(
manager: InterestManager,
topicKey: K,
params: TopicParams<K> | null,
): TopicState<K> {
// Stabilize params reference to avoid unnecessary resubscriptions
const paramsKey = params ? topicDefinitions[topicKey].key(params) : null;
const subscribe = useMemo(() => {
return (listener: () => void) => {
if (!params) return () => {};
return manager.subscribe(topicKey, params, listener);
};
}, [manager, topicKey, paramsKey]);
const getSnapshot = useMemo(() => {
return (): TopicState<K> => {
if (!params) return { data: undefined, status: "loading", error: null };
return {
data: manager.getSnapshot(topicKey, params),
status: manager.getStatus(topicKey, params),
error: manager.getError(topicKey, params),
};
};
}, [manager, topicKey, paramsKey]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
```
### 3.6 BackendClient additions
**File:** `packages/client/src/backend-client.ts`
Add to the `BackendClient` interface:
```typescript
// New connection methods (return WebSocket-based ActorConn)
connectWorkspace(workspaceId: string): Promise<ActorConn>;
connectTask(workspaceId: string, repoId: string, taskId: string): Promise<ActorConn>;
connectSandbox(workspaceId: string, providerId: string, sandboxId: string): Promise<ActorConn>;
// New fetch methods (read from materialized state)
getWorkspaceSummary(workspaceId: string): Promise<WorkspaceSummarySnapshot>;
getTaskDetail(workspaceId: string, repoId: string, taskId: string): Promise<WorkbenchTaskDetail>;
getSessionDetail(workspaceId: string, repoId: string, taskId: string, sessionId: string): Promise<WorkbenchSessionDetail>;
```
Remove:
- `subscribeWorkbench`, `subscribeApp`, `subscribeSandboxProcesses` (replaced by interest manager)
- `getWorkbench` (replaced by `getWorkspaceSummary` + `getTaskDetail`)
---
## 4. Frontend: Hook Consumption
### 4.1 Provider setup
**File:** `packages/frontend/src/lib/interest.ts` (new)
```typescript
import { RemoteInterestManager } from "@sandbox-agent/foundry-client";
import { backendClient } from "./backend";
export const interestManager = new RemoteInterestManager(backendClient);
```
Or for mock mode:
```typescript
import { MockInterestManager } from "@sandbox-agent/foundry-client";
export const interestManager = new MockInterestManager();
```
### 4.2 Replace MockLayout workbench subscription
**File:** `packages/frontend/src/components/mock-layout.tsx`
Before:
```typescript
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
const viewModel = useSyncExternalStore(
taskWorkbenchClient.subscribe.bind(taskWorkbenchClient),
taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient),
);
const tasks = viewModel.tasks ?? [];
```
After:
```typescript
const workspace = useInterest(interestManager, "workspace", { workspaceId });
const taskSummaries = workspace.data?.taskSummaries ?? [];
const repos = workspace.data?.repos ?? [];
```
### 4.3 Replace MockLayout task detail
When a task is selected, subscribe to its detail:
```typescript
const taskDetail = useInterest(interestManager, "task",
selectedTaskId ? { workspaceId, repoId: activeRepoId, taskId: selectedTaskId } : null
);
```
### 4.4 Replace session subscription
When a session tab is active:
```typescript
const sessionDetail = useInterest(interestManager, "session",
activeSessionId ? { workspaceId, repoId, taskId, sessionId: activeSessionId } : null
);
```
### 4.5 Replace workspace-dashboard.tsx polling
Remove ALL `useQuery` with `refetchInterval` in this file:
- `tasksQuery` (2.5s polling) → `useInterest("workspace", ...)`
- `taskDetailQuery` (2.5s polling) → `useInterest("task", ...)`
- `reposQuery` (10s polling) → `useInterest("workspace", ...)`
- `repoOverviewQuery` (5s polling) → `useInterest("workspace", ...)`
- `sessionsQuery` (3s polling) → `useInterest("task", ...)` (sessionsSummary field)
- `eventsQuery` (2.5s polling) → `useInterest("session", ...)`
### 4.6 Replace terminal-pane.tsx polling
- `taskQuery` (2s polling) → `useInterest("task", ...)`
- `processesQuery` (3s polling) → `useInterest("sandboxProcesses", ...)`
- Remove `subscribeSandboxProcesses` useEffect
### 4.7 Replace app client subscription
**File:** `packages/frontend/src/lib/mock-app.ts`
Before:
```typescript
export function useMockAppSnapshot(): FoundryAppSnapshot {
return useSyncExternalStore(appClient.subscribe.bind(appClient), appClient.getSnapshot.bind(appClient));
}
```
After:
```typescript
export function useAppSnapshot(): FoundryAppSnapshot {
const app = useInterest(interestManager, "app", {});
return app.data ?? DEFAULT_APP_SNAPSHOT;
}
```
### 4.8 Mutations
Mutations (`createTask`, `renameTask`, `sendMessage`, etc.) no longer need manual `refetch()` or `refresh()` calls after completion. The backend mutation triggers a broadcast, which the interest manager receives and applies automatically.
Before:
```typescript
const createSession = useMutation({
mutationFn: async () => startSessionFromTask(),
onSuccess: async (session) => {
setActiveSessionId(session.id);
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
},
});
```
After:
```typescript
const createSession = useMutation({
mutationFn: async () => startSessionFromTask(),
onSuccess: (session) => {
setActiveSessionId(session.id);
// No refetch needed — server broadcast updates the task and session topics automatically
},
});
```
---
## 5. Files to Delete / Remove
| File/Code | Reason |
|---|---|
| `packages/client/src/remote/workbench-client.ts` | Replaced by interest manager `workspace` + `task` topics |
| `packages/client/src/remote/app-client.ts` | Replaced by interest manager `app` topic |
| `packages/client/src/workbench-client.ts` | Factory for above — no longer needed |
| `packages/client/src/app-client.ts` | Factory for above — no longer needed |
| `packages/frontend/src/lib/workbench.ts` | Workbench client singleton — replaced by interest manager |
| `subscribeWorkbench` in `backend-client.ts` | Replaced by `connectWorkspace` + interest manager |
| `subscribeSandboxProcesses` in `backend-client.ts` | Replaced by `connectSandbox` + interest manager |
| `subscribeApp` in `backend-client.ts` | Replaced by `connectWorkspace("app")` + interest manager |
| `buildWorkbenchSnapshot` in `workspace/actions.ts` | Replaced by `getWorkspaceSummary` (local reads). Keep as `reconcileWorkbenchState` for recovery only. |
| `notifyWorkbenchUpdated` in `workspace/actions.ts` | Replaced by `applyTaskSummaryUpdate` + `c.broadcast` with payload |
| `notifyWorkbenchUpdated` in `task/workbench.ts` | Replaced by `broadcastTaskUpdate` helper |
| `TaskWorkbenchSnapshot` in `shared/workbench.ts` | Replaced by `WorkspaceSummarySnapshot` + `WorkbenchTaskDetail` |
| `WorkbenchTask` in `shared/workbench.ts` | Split into `WorkbenchTaskSummary` + `WorkbenchTaskDetail` |
| `getWorkbench` action on workspace actor | Replaced by `getWorkspaceSummary` |
| `TaskWorkbenchClient` interface | Replaced by `InterestManager` + `useInterest` hook |
| All `useQuery` with `refetchInterval` in `workspace-dashboard.tsx` | Replaced by `useInterest` |
| All `useQuery` with `refetchInterval` in `terminal-pane.tsx` | Replaced by `useInterest` |
| Mock workbench client (`packages/client/src/mock/workbench-client.ts`) | Replaced by `MockInterestManager` |
---
## 6. Migration Order
Implement in this order to keep the system working at each step:
### Phase 1: Types and backend materialization
1. Add new types to `packages/shared` (`WorkbenchTaskSummary`, `WorkbenchTaskDetail`, `WorkbenchSessionSummary`, `WorkbenchSessionDetail`, `WorkspaceSummarySnapshot`, event types).
2. Add `taskSummaries` table to workspace actor schema.
3. Add `applyTaskSummaryUpdate`, `removeTaskSummary`, `getWorkspaceSummary` actions to workspace actor.
4. Add `getTaskDetail`, `getSessionDetail` actions to task actor.
5. Replace all `notifyWorkbenchUpdated` call sites with `broadcastTaskUpdate` that pushes summary + broadcasts detail with payload.
6. Change app actor broadcast to include snapshot payload.
7. Change sandbox actor broadcast to include process list payload.
8. Add one-time reconciliation action to populate `taskSummaries` table from existing task actors (run on startup or on-demand).
### Phase 2: Client interest manager
9. Add `InterestManager` interface, `RemoteInterestManager`, `MockInterestManager` to `packages/client`.
10. Add topic definitions registry.
11. Add `useInterest` hook.
12. Add `connectWorkspace`, `connectTask`, `connectSandbox`, `getWorkspaceSummary`, `getTaskDetail`, `getSessionDetail` to `BackendClient`.
### Phase 3: Frontend migration
13. Replace `useMockAppSnapshot` with `useInterest("app", ...)`.
14. Replace `MockLayout` workbench subscription with `useInterest("workspace", ...)`.
15. Replace task detail view with `useInterest("task", ...)` + `useInterest("session", ...)`.
16. Replace `workspace-dashboard.tsx` polling queries with `useInterest`.
17. Replace `terminal-pane.tsx` polling queries with `useInterest`.
18. Remove manual `refetch()` calls from mutations.
### Phase 4: Cleanup
19. Delete old files (workbench-client, app-client, old subscribe functions, old types).
20. Remove `buildWorkbenchSnapshot` from hot path (keep as `reconcileWorkbenchState`).
21. Verify `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test` pass.
---
## 7. Architecture Comments
Add doc comments at these locations:
- **Topic definitions** — explain the materialized state pattern, why events carry full entity state instead of patches, and the relationship between topics.
- **`broadcastTaskUpdate` helper** — explain the dual-broadcast pattern (push summary to workspace + broadcast detail to direct subscribers).
- **`InterestManager` interface** — explain the grace period, deduplication, and why mock/remote share the same interface.
- **`useInterest` hook** — explain `useSyncExternalStore` integration, null params for conditional interest, and how params key stabilization works.
- **Workspace actor `taskSummaries` table** — explain this is a materialized read projection maintained by task actor pushes, not a source of truth.
- **`applyTaskSummaryUpdate` action** — explain this is the write path for the materialized projection, called by task actors, not by clients.
- **`getWorkspaceSummary` action** — explain this reads from local SQLite only, no fan-out, and why that's the correct pattern.
---
## 8. Testing
- Interest manager unit tests: subscribe/unsubscribe lifecycle, grace period, deduplication, event application.
- Mock implementation tests: verify same behavior as remote through shared test suite against the `InterestManager` interface.
- Backend integration: verify `applyTaskSummaryUpdate` correctly materializes and broadcasts.
- E2E: verify that a task mutation (e.g. rename) updates the sidebar in realtime without polling.