feat(factory): finish workbench milestone pass

This commit is contained in:
Nathan Flurry 2026-03-09 16:34:27 -07:00
parent bf282199b5
commit 49cba9e6c2
137 changed files with 819 additions and 338 deletions

View file

@ -10,7 +10,7 @@
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenHandoff</title>
<title>Sandbox Agent Factory</title>
</head>
<body>
<div id="root"></div>

View file

@ -1,5 +1,5 @@
{
"name": "@openhandoff/frontend",
"name": "@sandbox-agent/factory-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
@ -10,9 +10,9 @@
"test": "vitest run"
},
"dependencies": {
"@openhandoff/client": "workspace:*",
"@openhandoff/frontend-errors": "workspace:*",
"@openhandoff/shared": "workspace:*",
"@sandbox-agent/factory-client": "workspace:*",
"@sandbox-agent/factory-frontend-errors": "workspace:*",
"@sandbox-agent/factory-shared": "workspace:*",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-router": "^1.132.23",
"baseui": "^16.1.1",

View file

@ -1,5 +1,5 @@
import { useEffect } from "react";
import { setFrontendErrorContext } from "@openhandoff/frontend-errors/client";
import { useEffect, useSyncExternalStore } from "react";
import { setFrontendErrorContext } from "@sandbox-agent/factory-frontend-errors/client";
import {
Navigate,
Outlet,
@ -10,7 +10,7 @@ import {
} from "@tanstack/react-router";
import { MockLayout } from "../components/mock-layout";
import { defaultWorkspaceId } from "../lib/env";
import { handoffWorkbenchClient } from "../lib/workbench";
import { getHandoffWorkbenchClient, resolveRepoRouteHandoffId } from "../lib/workbench";
const rootRoute = createRootRoute({
component: RootLayout,
@ -74,18 +74,27 @@ function WorkspaceLayoutRoute() {
function WorkspaceRoute() {
const { workspaceId } = workspaceRoute.useParams();
const client = getHandoffWorkbenchClient(workspaceId);
useEffect(() => {
setFrontendErrorContext({
workspaceId,
handoffId: undefined,
});
}, [workspaceId]);
return <MockLayout workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />;
return (
<MockLayout
client={client}
workspaceId={workspaceId}
selectedHandoffId={null}
selectedSessionId={null}
/>
);
}
function HandoffRoute() {
const { workspaceId, handoffId } = handoffRoute.useParams();
const { sessionId } = handoffRoute.useSearch();
const client = getHandoffWorkbenchClient(workspaceId);
useEffect(() => {
setFrontendErrorContext({
workspaceId,
@ -93,11 +102,24 @@ function HandoffRoute() {
repoId: undefined,
});
}, [handoffId, workspaceId]);
return <MockLayout workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />;
return (
<MockLayout
client={client}
workspaceId={workspaceId}
selectedHandoffId={handoffId}
selectedSessionId={sessionId ?? null}
/>
);
}
function RepoRoute() {
const { workspaceId, repoId } = repoRoute.useParams();
const client = getHandoffWorkbenchClient(workspaceId);
const snapshot = useSyncExternalStore(
client.subscribe.bind(client),
client.getSnapshot.bind(client),
client.getSnapshot.bind(client),
);
useEffect(() => {
setFrontendErrorContext({
workspaceId,
@ -105,9 +127,7 @@ function RepoRoute() {
repoId,
});
}, [repoId, workspaceId]);
const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find(
(handoff) => handoff.repoId === repoId,
)?.id;
const activeHandoffId = resolveRepoRouteHandoffId(snapshot, repoId);
if (!activeHandoffId) {
return (
<Navigate

View file

@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client";
import { useNavigate } from "@tanstack/react-router";
import { DiffContent } from "./mock-layout/diff-content";
@ -22,7 +23,6 @@ import {
type Message,
type ModelId,
} from "./mock-layout/view-model";
import { handoffWorkbenchClient } from "../lib/workbench";
function firstAgentTabId(handoff: Handoff): string | null {
return handoff.tabs[0]?.id ?? null;
@ -63,6 +63,7 @@ function sanitizeActiveTabId(
}
const TranscriptPanel = memo(function TranscriptPanel({
client,
handoff,
activeTabId,
lastAgentTabId,
@ -72,6 +73,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSetLastAgentTabId,
onSetOpenDiffs,
}: {
client: HandoffWorkbenchClient;
handoff: Handoff;
activeTabId: string | null;
lastAgentTabId: string | null;
@ -172,12 +174,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
return;
}
void handoffWorkbenchClient.setSessionUnread({
void client.setSessionUnread({
handoffId: handoff.id,
tabId: activeAgentTab.id,
unread: false,
});
}, [activeAgentTab?.id, activeAgentTab?.unread, handoff.id]);
}, [activeAgentTab?.id, activeAgentTab?.unread, client, handoff.id]);
const startEditingField = useCallback((field: "title" | "branch", value: string) => {
setEditingField(field);
@ -197,13 +199,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
}
if (field === "title") {
void handoffWorkbenchClient.renameHandoff({ handoffId: handoff.id, value });
void client.renameHandoff({ handoffId: handoff.id, value });
} else {
void handoffWorkbenchClient.renameBranch({ handoffId: handoff.id, value });
void client.renameBranch({ handoffId: handoff.id, value });
}
setEditingField(null);
},
[editValue, handoff.id],
[client, editValue, handoff.id],
);
const updateDraft = useCallback(
@ -212,14 +214,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
return;
}
void handoffWorkbenchClient.updateDraft({
void client.updateDraft({
handoffId: handoff.id,
tabId: promptTab.id,
text: nextText,
attachments: nextAttachments,
});
},
[handoff.id, promptTab],
[client, handoff.id, promptTab],
);
const sendMessage = useCallback(() => {
@ -230,24 +232,24 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSetActiveTabId(promptTab.id);
onSetLastAgentTabId(promptTab.id);
void handoffWorkbenchClient.sendMessage({
void client.sendMessage({
handoffId: handoff.id,
tabId: promptTab.id,
text,
attachments,
});
}, [attachments, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
}, [attachments, client, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]);
const stopAgent = useCallback(() => {
if (!promptTab) {
return;
}
void handoffWorkbenchClient.stopAgent({
void client.stopAgent({
handoffId: handoff.id,
tabId: promptTab.id,
});
}, [handoff.id, promptTab]);
}, [client, handoff.id, promptTab]);
const switchTab = useCallback(
(tabId: string) => {
@ -257,7 +259,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSetLastAgentTabId(tabId);
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
if (tab?.unread) {
void handoffWorkbenchClient.setSessionUnread({
void client.setSessionUnread({
handoffId: handoff.id,
tabId,
unread: false,
@ -266,14 +268,14 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSyncRouteSession(handoff.id, tabId);
}
},
[handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
[client, handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
);
const setTabUnread = useCallback(
(tabId: string, unread: boolean) => {
void handoffWorkbenchClient.setSessionUnread({ handoffId: handoff.id, tabId, unread });
void client.setSessionUnread({ handoffId: handoff.id, tabId, unread });
},
[handoff.id],
[client, handoff.id],
);
const startRenamingTab = useCallback(
@ -305,13 +307,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
return;
}
void handoffWorkbenchClient.renameSession({
void client.renameSession({
handoffId: handoff.id,
tabId: editingSessionTabId,
title: trimmedName,
});
cancelTabRename();
}, [cancelTabRename, editingSessionName, editingSessionTabId, handoff.id]);
}, [cancelTabRename, client, editingSessionName, editingSessionTabId, handoff.id]);
const closeTab = useCallback(
(tabId: string) => {
@ -326,9 +328,9 @@ const TranscriptPanel = memo(function TranscriptPanel({
}
onSyncRouteSession(handoff.id, nextTabId);
void handoffWorkbenchClient.closeTab({ handoffId: handoff.id, tabId });
void client.closeTab({ handoffId: handoff.id, tabId });
},
[activeTabId, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
[activeTabId, client, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession],
);
const closeDiffTab = useCallback(
@ -346,12 +348,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
const addTab = useCallback(() => {
void (async () => {
const { tabId } = await handoffWorkbenchClient.addTab({ handoffId: handoff.id });
const { tabId } = await client.addTab({ handoffId: handoff.id });
onSetLastAgentTabId(tabId);
onSetActiveTabId(tabId);
onSyncRouteSession(handoff.id, tabId);
})();
}, [handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
}, [client, handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]);
const changeModel = useCallback(
(model: ModelId) => {
@ -359,13 +361,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`);
}
void handoffWorkbenchClient.changeModel({
void client.changeModel({
handoffId: handoff.id,
tabId: promptTab.id,
model,
});
},
[handoff.id, promptTab],
[client, handoff.id, promptTab],
);
const addAttachment = useCallback(
@ -551,17 +553,18 @@ const TranscriptPanel = memo(function TranscriptPanel({
});
interface MockLayoutProps {
client: HandoffWorkbenchClient;
workspaceId: string;
selectedHandoffId?: string | null;
selectedSessionId?: string | null;
}
export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) {
export function MockLayout({ client, workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) {
const navigate = useNavigate();
const viewModel = useSyncExternalStore(
handoffWorkbenchClient.subscribe.bind(handoffWorkbenchClient),
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient),
client.subscribe.bind(client),
client.getSnapshot.bind(client),
client.getSnapshot.bind(client),
);
const handoffs = viewModel.handoffs ?? [];
const projects = viewModel.projects ?? [];
@ -668,7 +671,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
const title = window.prompt("Optional handoff title", "")?.trim() || undefined;
const branch = window.prompt("Optional branch name", "")?.trim() || undefined;
const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({
const { handoffId, tabId } = await client.createHandoff({
repoId,
task,
model: "gpt-4o",
@ -684,7 +687,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
search: { sessionId: tabId ?? undefined },
});
})();
}, [activeHandoff?.repoId, navigate, viewModel.repos, workspaceId]);
}, [activeHandoff?.repoId, client, navigate, viewModel.repos, workspaceId]);
const openDiffTab = useCallback(
(path: string) => {
@ -726,8 +729,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
);
const markHandoffUnread = useCallback((id: string) => {
void handoffWorkbenchClient.markHandoffUnread({ handoffId: id });
}, []);
void client.markHandoffUnread({ handoffId: id });
}, [client]);
const renameHandoff = useCallback(
(id: string) => {
@ -746,9 +749,9 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
return;
}
void handoffWorkbenchClient.renameHandoff({ handoffId: id, value: trimmedTitle });
void client.renameHandoff({ handoffId: id, value: trimmedTitle });
},
[handoffs],
[client, handoffs],
);
const renameBranch = useCallback(
@ -768,24 +771,31 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
return;
}
void handoffWorkbenchClient.renameBranch({ handoffId: id, value: trimmedBranch });
void client.renameBranch({ handoffId: id, value: trimmedBranch });
},
[handoffs],
[client, handoffs],
);
const archiveHandoff = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot archive without an active handoff");
}
void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id });
}, [activeHandoff]);
void client.archiveHandoff({ handoffId: activeHandoff.id });
}, [activeHandoff, client]);
const publishPr = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot publish PR without an active handoff");
}
void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id });
}, [activeHandoff]);
void client.publishPr({ handoffId: activeHandoff.id });
}, [activeHandoff, client]);
const pushHandoff = useCallback(() => {
if (!activeHandoff) {
throw new Error("Cannot push without an active handoff");
}
void client.pushHandoff({ handoffId: activeHandoff.id });
}, [activeHandoff, client]);
const revertFile = useCallback(
(path: string) => {
@ -804,18 +814,20 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
: current[activeHandoff.id] ?? null,
}));
void handoffWorkbenchClient.revertFile({
void client.revertFile({
handoffId: activeHandoff.id,
path,
});
},
[activeHandoff, lastAgentTabIdByHandoff],
[activeHandoff, client, lastAgentTabIdByHandoff],
);
if (!activeHandoff) {
return (
<Shell>
<Sidebar
workspaceId={workspaceId}
repoCount={viewModel.repos.length}
projects={projects}
activeId=""
onSelect={selectHandoff}
@ -879,6 +891,8 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
return (
<Shell>
<Sidebar
workspaceId={workspaceId}
repoCount={viewModel.repos.length}
projects={projects}
activeId={activeHandoff.id}
onSelect={selectHandoff}
@ -888,6 +902,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
onRenameBranch={renameBranch}
/>
<TranscriptPanel
client={client}
handoff={activeHandoff}
activeTabId={activeTabId}
lastAgentTabId={lastAgentTabId}
@ -908,6 +923,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
activeTabId={activeTabId}
onOpenDiff={openDiffTab}
onArchive={archiveHandoff}
onPush={pushHandoff}
onRevertFile={revertFile}
onPublishPr={publishPr}
/>

View file

@ -15,6 +15,49 @@ import {
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { type FileTreeNode, type Handoff, diffTabId } from "./view-model";
const StatusCard = memo(function StatusCard({
label,
value,
mono = false,
}: {
label: string;
value: string;
mono?: boolean;
}) {
const [css, theme] = useStyletron();
return (
<div
className={css({
padding: "10px 12px",
borderRadius: "8px",
backgroundColor: theme.colors.backgroundSecondary,
border: `1px solid ${theme.colors.borderOpaque}`,
display: "flex",
flexDirection: "column",
gap: "4px",
})}
>
<LabelSmall color={theme.colors.contentTertiary} $style={{ fontSize: "10px", fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase" }}>
{label}
</LabelSmall>
<div
className={css({
color: theme.colors.contentPrimary,
fontSize: "12px",
fontWeight: 600,
fontFamily: mono ? '"IBM Plex Mono", monospace' : undefined,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{value}
</div>
</div>
);
});
const FileTree = memo(function FileTree({
nodes,
depth,
@ -106,6 +149,7 @@ export const RightSidebar = memo(function RightSidebar({
activeTabId,
onOpenDiff,
onArchive,
onPush,
onRevertFile,
onPublishPr,
}: {
@ -113,6 +157,7 @@ export const RightSidebar = memo(function RightSidebar({
activeTabId: string | null;
onOpenDiff: (path: string) => void;
onArchive: () => void;
onPush: () => void;
onRevertFile: (path: string) => void;
onPublishPr: () => void;
}) {
@ -121,7 +166,12 @@ export const RightSidebar = memo(function RightSidebar({
const contextMenu = useContextMenu();
const changedPaths = useMemo(() => new Set(handoff.fileChanges.map((file) => file.path)), [handoff.fileChanges]);
const isTerminal = handoff.status === "archived";
const canPush = !isTerminal && Boolean(handoff.branch);
const pullRequestUrl = handoff.pullRequest != null ? `https://github.com/${handoff.repoName}/pull/${handoff.pullRequest.number}` : null;
const pullRequestStatus =
handoff.pullRequest == null
? "Not published"
: `#${handoff.pullRequest.number} ${handoff.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
const copyFilePath = useCallback(async (path: string) => {
try {
@ -183,6 +233,7 @@ export const RightSidebar = memo(function RightSidebar({
{pullRequestUrl ? "Open PR" : "Publish PR"}
</button>
<button
onClick={canPush ? onPush : undefined}
className={css({
all: "unset",
display: "flex",
@ -192,8 +243,9 @@ export const RightSidebar = memo(function RightSidebar({
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
color: "#e4e4e7",
cursor: "pointer",
color: canPush ? "#e4e4e7" : theme.colors.contentTertiary,
cursor: canPush ? "pointer" : "not-allowed",
opacity: canPush ? 1 : 0.5,
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
@ -303,6 +355,10 @@ export const RightSidebar = memo(function RightSidebar({
</div>
<ScrollBody>
<div className={css({ padding: "12px 14px 0", display: "grid", gap: "8px" })}>
<StatusCard label="Branch" value={handoff.branch ?? "Not created"} mono />
<StatusCard label="Pull Request" value={pullRequestStatus} />
</div>
{rightTab === "changes" ? (
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
{handoff.fileChanges.length === 0 ? (

View file

@ -14,6 +14,8 @@ import {
} from "./ui";
export const Sidebar = memo(function Sidebar({
workspaceId,
repoCount,
projects,
activeId,
onSelect,
@ -22,6 +24,8 @@ export const Sidebar = memo(function Sidebar({
onRenameHandoff,
onRenameBranch,
}: {
workspaceId: string;
repoCount: number;
projects: ProjectSection[];
activeId: string;
onSelect: (id: string) => void;
@ -37,11 +41,17 @@ export const Sidebar = memo(function Sidebar({
return (
<SPanel>
<PanelHeaderBar>
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, flex: 1, fontSize: "13px" }}>
Handoffs
</LabelSmall>
<div className={css({ flex: 1, minWidth: 0 })}>
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, fontSize: "13px" }}>
{workspaceId}
</LabelSmall>
<LabelXSmall color={theme.colors.contentTertiary}>
{repoCount} {repoCount === 1 ? "repo" : "repos"}
</LabelXSmall>
</div>
<button
onClick={onCreate}
aria-label="Create handoff"
className={css({
all: "unset",
width: "24px",
@ -92,10 +102,22 @@ export const Sidebar = memo(function Sidebar({
{project.label}
</LabelSmall>
<LabelXSmall color={theme.colors.contentTertiary}>
{formatRelativeAge(project.updatedAtMs)}
{project.updatedAtMs > 0 ? formatRelativeAge(project.updatedAtMs) : "No handoffs"}
</LabelXSmall>
</div>
{project.handoffs.length === 0 ? (
<div
className={css({
padding: "0 12px 10px 34px",
color: theme.colors.contentTertiary,
fontSize: "12px",
})}
>
No handoffs yet
</div>
) : null}
{project.handoffs.slice(0, visibleCount).map((handoff) => {
const isActive = handoff.id === activeId;
const isDim = handoff.status === "archived";

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { WorkbenchAgentTab } from "@openhandoff/shared";
import type { WorkbenchAgentTab } from "@sandbox-agent/factory-shared";
import { buildDisplayMessages } from "./view-model";
function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab {

View file

@ -12,7 +12,7 @@ import type {
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchProjectSection as ProjectSection,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@openhandoff/shared";
} from "@sandbox-agent/factory-shared";
import { extractEventText } from "../../features/sessions/model";
export type { ProjectSection };

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@openhandoff/shared";
import { groupHandoffStatus, type SandboxSessionEventRecord } from "@openhandoff/client";
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared";
import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client";
import { groupHandoffStatus } from "@sandbox-agent/factory-client/view-model";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Link, useNavigate } from "@tanstack/react-router";
import { Button } from "baseui/button";

View file

@ -0,0 +1,7 @@
declare module "@sandbox-agent/factory-client/view-model" {
export {
HANDOFF_STATUS_GROUPS,
groupHandoffStatus,
} from "@sandbox-agent/factory-client";
export type { HandoffStatusGroup } from "@sandbox-agent/factory-client";
}

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@openhandoff/shared";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
import { formatDiffStat, groupHandoffsByRepo } from "./model";
const base: HandoffRecord = {

View file

@ -1,4 +1,4 @@
import type { HandoffRecord } from "@openhandoff/shared";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
export interface RepoGroup {
repoId: string;

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { SandboxSessionRecord } from "@openhandoff/client";
import type { SandboxSessionRecord } from "@sandbox-agent/factory-client";
import { buildTranscript, extractEventText, resolveSessionSelection } from "./model";
describe("extractEventText", () => {

View file

@ -1,5 +1,5 @@
import type { SandboxSessionEventRecord } from "@openhandoff/client";
import type { SandboxSessionRecord } from "@openhandoff/client";
import type { SandboxSessionEventRecord } from "@sandbox-agent/factory-client";
import type { SandboxSessionRecord } from "@sandbox-agent/factory-client";
function fromPromptArray(value: unknown): string | null {
if (!Array.isArray(value)) {

View file

@ -1,4 +1,4 @@
import { createBackendClient } from "@openhandoff/client";
import { createBackendClient } from "@sandbox-agent/factory-client/backend";
import { backendEndpoint, defaultWorkspaceId } from "./env";
export const backendClient = createBackendClient({

View file

@ -6,7 +6,7 @@ function resolveDefaultBackendEndpoint(): string {
}
type FrontendImportMetaEnv = ImportMetaEnv & {
OPENHANDOFF_FRONTEND_CLIENT_MODE?: string;
FACTORY_FRONTEND_CLIENT_MODE?: string;
};
const frontendEnv = import.meta.env as FrontendImportMetaEnv;
@ -17,7 +17,7 @@ export const backendEndpoint =
export const defaultWorkspaceId = import.meta.env.VITE_HF_WORKSPACE?.trim() || "default";
function resolveFrontendClientMode(): "mock" | "remote" {
const raw = frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
const raw = frontendEnv.FACTORY_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
if (raw === "mock") {
return "mock";
}
@ -25,7 +25,7 @@ function resolveFrontendClientMode(): "mock" | "remote" {
return "remote";
}
throw new Error(
`Unsupported OPENHANDOFF_FRONTEND_CLIENT_MODE value "${frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
`Unsupported FACTORY_FRONTEND_CLIENT_MODE value "${frontendEnv.FACTORY_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
);
}

View file

@ -0,0 +1,8 @@
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
export function resolveRepoRouteHandoffId(
snapshot: HandoffWorkbenchSnapshot,
repoId: string,
): string | null {
return snapshot.handoffs.find((handoff) => handoff.repoId === repoId)?.id ?? null;
}

View file

@ -0,0 +1,11 @@
import {
createHandoffWorkbenchClient,
type HandoffWorkbenchClient,
} from "@sandbox-agent/factory-client/workbench";
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
return createHandoffWorkbenchClient({
mode: "mock",
workspaceId,
});
}

View file

@ -0,0 +1,13 @@
import {
createHandoffWorkbenchClient,
type HandoffWorkbenchClient,
} from "@sandbox-agent/factory-client/workbench";
import { backendClient } from "./backend";
export function createWorkbenchRuntimeClient(workspaceId: string): HandoffWorkbenchClient {
return createHandoffWorkbenchClient({
mode: "remote",
backend: backendClient,
workspaceId,
});
}

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
import { resolveRepoRouteHandoffId } from "./workbench-routing";
const snapshot: HandoffWorkbenchSnapshot = {
workspaceId: "default",
repos: [
{ id: "repo-a", label: "acme/repo-a" },
{ id: "repo-b", label: "acme/repo-b" },
],
projects: [],
handoffs: [
{
id: "handoff-a",
repoId: "repo-a",
title: "Alpha",
status: "idle",
repoName: "acme/repo-a",
updatedAtMs: 20,
branch: "feature/alpha",
pullRequest: null,
tabs: [],
fileChanges: [],
diffs: {},
fileTree: [],
},
],
};
describe("resolveRepoRouteHandoffId", () => {
it("finds the active handoff for a repo route", () => {
expect(resolveRepoRouteHandoffId(snapshot, "repo-a")).toBe("handoff-a");
});
it("returns null when a repo has no handoff yet", () => {
expect(resolveRepoRouteHandoffId(snapshot, "repo-b")).toBeNull();
});
});

View file

@ -1,9 +1,18 @@
import { createHandoffWorkbenchClient } from "@openhandoff/client";
import { backendClient } from "./backend";
import { defaultWorkspaceId, frontendClientMode } from "./env";
import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client/workbench";
import { createWorkbenchRuntimeClient } from "@workbench-runtime";
import { frontendClientMode } from "./env";
export { resolveRepoRouteHandoffId } from "./workbench-routing";
export const handoffWorkbenchClient = createHandoffWorkbenchClient({
mode: frontendClientMode,
backend: backendClient,
workspaceId: defaultWorkspaceId,
});
const workbenchClientCache = new Map<string, HandoffWorkbenchClient>();
export function getHandoffWorkbenchClient(workspaceId: string): HandoffWorkbenchClient {
const cacheKey = `${frontendClientMode}:${workspaceId}`;
const existing = workbenchClientCache.get(cacheKey);
if (existing) {
return existing;
}
const client = createWorkbenchRuntimeClient(workspaceId);
workbenchClientCache.set(cacheKey, client);
return client;
}

View file

@ -6,7 +6,11 @@
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"declaration": false,
"types": ["vite/client", "vitest/globals"]
"types": ["vite/client", "vitest/globals"],
"baseUrl": ".",
"paths": {
"@workbench-runtime": ["./src/lib/workbench-runtime.remote.ts"]
}
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}

View file

@ -1,17 +1,31 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { frontendErrorCollectorVitePlugin } from "@openhandoff/frontend-errors/vite";
import { frontendErrorCollectorVitePlugin } from "@sandbox-agent/factory-frontend-errors/vite";
const backendProxyTarget = process.env.HF_BACKEND_HTTP?.trim() || "http://127.0.0.1:7741";
const cacheDir = process.env.HF_VITE_CACHE_DIR?.trim() || undefined;
const frontendClientMode = process.env.FACTORY_FRONTEND_CLIENT_MODE?.trim() || "remote";
export default defineConfig({
define: {
"import.meta.env.OPENHANDOFF_FRONTEND_CLIENT_MODE": JSON.stringify(
process.env.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim() || "remote",
"import.meta.env.FACTORY_FRONTEND_CLIENT_MODE": JSON.stringify(
frontendClientMode,
),
},
plugins: [react(), frontendErrorCollectorVitePlugin()],
cacheDir,
resolve: {
alias: {
"@workbench-runtime": fileURLToPath(
new URL(
frontendClientMode === "mock"
? "./src/lib/workbench-runtime.mock.ts"
: "./src/lib/workbench-runtime.remote.ts",
import.meta.url,
),
),
},
},
server: {
port: 4173,
proxy: {