factory: rename project and handoff actors

This commit is contained in:
Nathan Flurry 2026-03-10 21:55:30 -07:00
parent 3022bce2ad
commit ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions

View file

@ -14,7 +14,6 @@ import { MockLayout } from "../components/mock-layout";
import {
MockHostedCheckoutPage,
MockOrganizationBillingPage,
MockOrganizationImportPage,
MockOrganizationSelectorPage,
MockOrganizationSettingsPage,
MockSignInPage,
@ -24,10 +23,12 @@ import {
activeMockOrganization,
activeMockUser,
getMockOrganizationById,
isAppSnapshotBootstrapping,
eligibleOrganizations,
useMockAppClient,
useMockAppSnapshot,
} from "../lib/mock-app";
import { getHandoffWorkbenchClient, resolveRepoRouteHandoffId } from "../lib/workbench";
import { getTaskWorkbenchClient, resolveRepoRouteTaskId } from "../lib/workbench";
const rootRoute = createRootRoute({
component: RootLayout,
@ -51,12 +52,6 @@ const organizationsRoute = createRoute({
component: OrganizationsRoute,
});
const organizationImportRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/organizations/$organizationId/import",
component: OrganizationImportRoute,
});
const organizationSettingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/organizations/$organizationId/settings",
@ -87,13 +82,13 @@ const workspaceIndexRoute = createRoute({
component: WorkspaceRoute,
});
const handoffRoute = createRoute({
const taskRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "handoffs/$handoffId",
path: "tasks/$taskId",
validateSearch: (search: Record<string, unknown>) => ({
sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined,
}),
component: HandoffRoute,
component: TaskRoute,
});
const repoRoute = createRoute({
@ -106,11 +101,10 @@ const routeTree = rootRoute.addChildren([
indexRoute,
signInRoute,
organizationsRoute,
organizationImportRoute,
organizationSettingsRoute,
organizationBillingRoute,
organizationCheckoutRoute,
workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute]),
workspaceRoute.addChildren([workspaceIndexRoute, taskRoute, repoRoute]),
]);
export const router = createRouter({ routeTree });
@ -132,6 +126,9 @@ function IndexRoute() {
function SignInRoute() {
const snapshot = useMockAppSnapshot();
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_in") {
return <NavigateToMockHome snapshot={snapshot} replace />;
@ -142,6 +139,9 @@ function SignInRoute() {
function OrganizationsRoute() {
const snapshot = useMockAppSnapshot();
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
@ -150,24 +150,12 @@ function OrganizationsRoute() {
return <MockOrganizationSelectorPage />;
}
function OrganizationImportRoute() {
const snapshot = useMockAppSnapshot();
const organization = useGuardedMockOrganization(organizationImportRoute.useParams().organizationId);
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
}
if (!organization) {
return <Navigate to="/organizations" replace />;
}
return <MockOrganizationImportPage organization={organization} />;
}
function OrganizationSettingsRoute() {
const snapshot = useMockAppSnapshot();
const organization = useGuardedMockOrganization(organizationSettingsRoute.useParams().organizationId);
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
@ -183,6 +171,9 @@ function OrganizationSettingsRoute() {
function OrganizationBillingRoute() {
const snapshot = useMockAppSnapshot();
const organization = useGuardedMockOrganization(organizationBillingRoute.useParams().organizationId);
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
@ -199,6 +190,9 @@ function OrganizationCheckoutRoute() {
const { organizationId, planId } = organizationCheckoutRoute.useParams();
const snapshot = useMockAppSnapshot();
const organization = useGuardedMockOrganization(organizationId);
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
@ -226,18 +220,18 @@ function WorkspaceRoute() {
return (
<MockWorkspaceGate workspaceId={workspaceId}>
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={null} selectedSessionId={null} />
<WorkspaceView workspaceId={workspaceId} selectedTaskId={null} selectedSessionId={null} />
</MockWorkspaceGate>
);
}
function HandoffRoute() {
const { workspaceId, handoffId } = handoffRoute.useParams();
const { sessionId } = handoffRoute.useSearch();
function TaskRoute() {
const { workspaceId, taskId } = taskRoute.useParams();
const { sessionId } = taskRoute.useSearch();
return (
<MockWorkspaceGate workspaceId={workspaceId}>
<WorkspaceView workspaceId={workspaceId} selectedHandoffId={handoffId} selectedSessionId={sessionId ?? null} />
<WorkspaceView workspaceId={workspaceId} selectedTaskId={taskId} selectedSessionId={sessionId ?? null} />
</MockWorkspaceGate>
);
}
@ -253,7 +247,7 @@ function RepoRoute() {
}
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
const client = getHandoffWorkbenchClient(workspaceId);
const client = getTaskWorkbenchClient(workspaceId);
const snapshot = useSyncExternalStore(
client.subscribe.bind(client),
client.getSnapshot.bind(client),
@ -263,13 +257,13 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
useEffect(() => {
setFrontendErrorContext({
workspaceId,
handoffId: undefined,
taskId: undefined,
repoId,
});
}, [repoId, workspaceId]);
const activeHandoffId = resolveRepoRouteHandoffId(snapshot, repoId);
if (!activeHandoffId) {
const activeTaskId = resolveRepoRouteTaskId(snapshot, repoId);
if (!activeTaskId) {
return (
<Navigate
to="/workspaces/$workspaceId"
@ -280,10 +274,10 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
}
return (
<Navigate
to="/workspaces/$workspaceId/handoffs/$handoffId"
to="/workspaces/$workspaceId/tasks/$taskId"
params={{
workspaceId,
handoffId: activeHandoffId,
taskId: activeTaskId,
}}
search={{ sessionId: undefined }}
replace
@ -293,14 +287,15 @@ function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId:
function WorkspaceView({
workspaceId,
selectedHandoffId,
selectedTaskId,
selectedSessionId,
}: {
workspaceId: string;
selectedHandoffId: string | null;
selectedTaskId: string | null;
selectedSessionId: string | null;
}) {
const client = getHandoffWorkbenchClient(workspaceId);
const appClient = useMockAppClient();
const client = getTaskWorkbenchClient(workspaceId);
const navigate = useNavigate();
const snapshot = useMockAppSnapshot();
const organization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
@ -308,16 +303,16 @@ function WorkspaceView({
useEffect(() => {
setFrontendErrorContext({
workspaceId,
handoffId: selectedHandoffId ?? undefined,
taskId: selectedTaskId ?? undefined,
repoId: undefined,
});
}, [selectedHandoffId, workspaceId]);
}, [selectedTaskId, workspaceId]);
return (
<MockLayout
client={client}
workspaceId={workspaceId}
selectedHandoffId={selectedHandoffId}
selectedTaskId={selectedTaskId}
selectedSessionId={selectedSessionId}
sidebarTitle={organization?.settings.displayName}
sidebarSubtitle={
@ -325,6 +320,9 @@ function WorkspaceView({
? `${organization.billing.planId} plan · ${organization.seatAssignments.length}/${organization.billing.seatsIncluded} seats`
: undefined
}
organizationGithub={organization?.github}
onRetryGithubSync={organization ? () => void appClient.triggerGithubSync(organization.id) : undefined}
onReconnectGithub={organization ? () => void appClient.reconnectGithub(organization.id) : undefined}
sidebarActions={
organization
? [
@ -363,6 +361,9 @@ function MockWorkspaceGate({
children: React.ReactNode;
}) {
const snapshot = useMockAppSnapshot();
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
@ -379,16 +380,6 @@ function MockWorkspaceGate({
return <Navigate to="/organizations" replace />;
}
if (workspaceOrganization.repoImportStatus !== "ready") {
return (
<Navigate
to="/organizations/$organizationId/import"
params={{ organizationId: workspaceOrganization.id }}
replace
/>
);
}
return <>{children}</>;
}
@ -399,6 +390,10 @@ function NavigateToMockHome({
snapshot: ReturnType<typeof useMockAppSnapshot>;
replace?: boolean;
}) {
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
const activeOrganization = activeMockOrganization(snapshot);
const organizations = eligibleOrganizations(snapshot);
const targetOrganization =
@ -420,16 +415,6 @@ function NavigateToMockHome({
);
}
if (targetOrganization.repoImportStatus !== "ready") {
return (
<Navigate
to="/organizations/$organizationId/import"
params={{ organizationId: targetOrganization.id }}
replace={replace}
/>
);
}
return (
<Navigate
to="/workspaces/$workspaceId"
@ -456,7 +441,7 @@ function useGuardedMockOrganization(organizationId: string) {
}
function isMockBillingPlanId(planId: string): planId is MockBillingPlanId {
return planId === "free" || planId === "team" || planId === "enterprise";
return planId === "free" || planId === "team";
}
function RootLayout() {
@ -468,6 +453,40 @@ function RootLayout() {
);
}
function AppBootstrapPending() {
return (
<div
style={{
minHeight: "100dvh",
display: "grid",
placeItems: "center",
background:
"radial-gradient(circle at top left, rgba(255, 79, 0, 0.16), transparent 28%), radial-gradient(circle at top right, rgba(24, 140, 255, 0.18), transparent 32%), #050505",
color: "#ffffff",
}}
>
<div
style={{
width: "min(520px, calc(100vw - 40px))",
padding: "32px",
borderRadius: "28px",
border: "1px solid rgba(255, 255, 255, 0.1)",
background: "linear-gradient(180deg, rgba(21, 21, 24, 0.96), rgba(10, 10, 11, 0.98))",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.36)",
}}
>
<div style={{ fontSize: "12px", fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase", color: "#a1a1aa" }}>
Restoring session
</div>
<div style={{ marginTop: "8px", fontSize: "28px", fontWeight: 800 }}>Loading Factory state</div>
<div style={{ marginTop: "12px", color: "#d4d4d8", lineHeight: 1.6 }}>
Applying the returned app session and loading your organizations before routing deeper into Factory.
</div>
</div>
</div>
);
}
function RouteContextSync() {
const location = useRouterState({
select: (state) => state.location,

File diff suppressed because it is too large Load diff

View file

@ -49,7 +49,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({
>
<div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" })}>
<LabelXSmall color={theme.colors.contentTertiary} $style={{ letterSpacing: "0.08em", textTransform: "uppercase" }}>
Handoff Events
Task Events
</LabelXSmall>
<LabelXSmall color={theme.colors.contentTertiary}>{events.length}</LabelXSmall>
</div>

View file

@ -13,7 +13,7 @@ import {
} from "lucide-react";
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { type FileTreeNode, type Handoff, diffTabId } from "./view-model";
import { type FileTreeNode, type Task, diffTabId } from "./view-model";
const StatusCard = memo(function StatusCard({
label,
@ -145,7 +145,7 @@ const FileTree = memo(function FileTree({
});
export const RightSidebar = memo(function RightSidebar({
handoff,
task,
activeTabId,
onOpenDiff,
onArchive,
@ -153,7 +153,7 @@ export const RightSidebar = memo(function RightSidebar({
onRevertFile,
onPublishPr,
}: {
handoff: Handoff;
task: Task;
activeTabId: string | null;
onOpenDiff: (path: string) => void;
onArchive: () => void;
@ -164,14 +164,14 @@ export const RightSidebar = memo(function RightSidebar({
const [css, theme] = useStyletron();
const [rightTab, setRightTab] = useState<"changes" | "files">("changes");
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 changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]);
const isTerminal = task.status === "archived";
const canPush = !isTerminal && Boolean(task.branch);
const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null;
const pullRequestStatus =
handoff.pullRequest == null
task.pullRequest == null
? "Not published"
: `#${handoff.pullRequest.number} ${handoff.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
: `#${task.pullRequest.number} ${task.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
const copyFilePath = useCallback(async (path: string) => {
try {
@ -309,7 +309,7 @@ export const RightSidebar = memo(function RightSidebar({
})}
>
Changes
{handoff.fileChanges.length > 0 ? (
{task.fileChanges.length > 0 ? (
<span
className={css({
display: "inline-flex",
@ -325,7 +325,7 @@ export const RightSidebar = memo(function RightSidebar({
borderRadius: "8px",
})}
>
{handoff.fileChanges.length}
{task.fileChanges.length}
</span>
) : null}
</button>
@ -356,17 +356,17 @@ export const RightSidebar = memo(function RightSidebar({
<ScrollBody>
<div className={css({ padding: "12px 14px 0", display: "grid", gap: "8px" })}>
<StatusCard label="Branch" value={handoff.branch ?? "Not created"} mono />
<StatusCard label="Branch" value={task.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 ? (
{task.fileChanges.length === 0 ? (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No changes yet</LabelSmall>
</div>
) : null}
{handoff.fileChanges.map((file) => {
{task.fileChanges.map((file) => {
const isActive = activeTabId === diffTabId(file.path);
const TypeIcon = file.type === "A" ? FilePlus : file.type === "D" ? FileX : FileCode;
const iconColor = file.type === "A" ? "#7ee787" : file.type === "D" ? "#ffa198" : theme.colors.contentTertiary;
@ -421,9 +421,9 @@ export const RightSidebar = memo(function RightSidebar({
</div>
) : (
<div className={css({ padding: "6px 0" })}>
{handoff.fileTree.length > 0 ? (
{task.fileTree.length > 0 ? (
<FileTree
nodes={handoff.fileTree}
nodes={task.fileTree}
depth={0}
onSelectFile={onOpenDiff}
onFileContextMenu={openFileMenu}

View file

@ -4,10 +4,10 @@ import { LabelSmall, LabelXSmall } from "baseui/typography";
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react";
import { SidebarSkeleton } from "./skeleton";
import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model";
import { formatRelativeAge, type Task, type RepoSection } from "./view-model";
import {
ContextMenuOverlay,
HandoffIndicator,
TaskIndicator,
PanelHeaderBar,
SPanel,
ScrollBody,
@ -17,7 +17,7 @@ import {
export const Sidebar = memo(function Sidebar({
workspaceId,
repoCount,
projects,
repos,
activeId,
title,
subtitle,
@ -25,12 +25,12 @@ export const Sidebar = memo(function Sidebar({
onSelect,
onCreate,
onMarkUnread,
onRenameHandoff,
onRenameTask,
onRenameBranch,
}: {
workspaceId: string;
repoCount: number;
projects: ProjectSection[];
repos: RepoSection[];
activeId: string;
title?: string;
subtitle?: string;
@ -41,12 +41,12 @@ export const Sidebar = memo(function Sidebar({
onSelect: (id: string) => void;
onCreate: () => void;
onMarkUnread: (id: string) => void;
onRenameHandoff: (id: string) => void;
onRenameTask: (id: string) => void;
onRenameBranch: (id: string) => void;
}) {
const [css, theme] = useStyletron();
const contextMenu = useContextMenu();
const [expandedProjects, setExpandedProjects] = useState<Record<string, boolean>>({});
const [expandedRepos, setExpandedRepos] = useState<Record<string, boolean>>({});
return (
<SPanel>
@ -61,7 +61,7 @@ export const Sidebar = memo(function Sidebar({
</div>
<button
onClick={onCreate}
aria-label="Create handoff"
aria-label="Create task"
className={css({
all: "unset",
width: "24px",
@ -115,16 +115,20 @@ export const Sidebar = memo(function Sidebar({
</div>
) : null}
<ScrollBody>
{projects.length === 0 ? (
{repos.length === 0 && repoCount === 0 ? (
<SidebarSkeleton />
) : repos.length === 0 ? (
<div className={css({ padding: "16px", textAlign: "center", opacity: 0.5, fontSize: "13px" })}>
No tasks yet
</div>
) : (
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
{projects.map((project) => {
const visibleCount = expandedProjects[project.id] ? project.handoffs.length : Math.min(project.handoffs.length, 5);
const hiddenCount = Math.max(0, project.handoffs.length - visibleCount);
{repos.map((repo) => {
const visibleCount = expandedRepos[repo.id] ? repo.tasks.length : Math.min(repo.tasks.length, 5);
const hiddenCount = Math.max(0, repo.tasks.length - visibleCount);
return (
<div key={project.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
<div key={repo.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
<div
className={css({
display: "flex",
@ -146,14 +150,14 @@ export const Sidebar = memo(function Sidebar({
whiteSpace: "nowrap",
}}
>
{project.label}
{repo.label}
</LabelSmall>
<LabelXSmall color={theme.colors.contentTertiary}>
{project.updatedAtMs > 0 ? formatRelativeAge(project.updatedAtMs) : "No handoffs"}
{repo.updatedAtMs > 0 ? formatRelativeAge(repo.updatedAtMs) : "No tasks"}
</LabelXSmall>
</div>
{project.handoffs.length === 0 ? (
{repo.tasks.length === 0 ? (
<div
className={css({
padding: "0 12px 10px 34px",
@ -161,29 +165,29 @@ export const Sidebar = memo(function Sidebar({
fontSize: "12px",
})}
>
No handoffs yet
No tasks yet
</div>
) : null}
{project.handoffs.slice(0, visibleCount).map((handoff) => {
const isActive = handoff.id === activeId;
const isDim = handoff.status === "archived";
const isRunning = handoff.tabs.some((tab) => tab.status === "running");
const hasUnread = handoff.tabs.some((tab) => tab.unread);
const isDraft = handoff.pullRequest == null || handoff.pullRequest.status === "draft";
const totalAdded = handoff.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = handoff.fileChanges.reduce((sum, file) => sum + file.removed, 0);
{repo.tasks.slice(0, visibleCount).map((task) => {
const isActive = task.id === activeId;
const isDim = task.status === "archived";
const isRunning = task.tabs.some((tab) => tab.status === "running");
const hasUnread = task.tabs.some((tab) => tab.unread);
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0);
const hasDiffs = totalAdded > 0 || totalRemoved > 0;
return (
<div
key={handoff.id}
onClick={() => onSelect(handoff.id)}
key={task.id}
onClick={() => onSelect(task.id)}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Rename handoff", onClick: () => onRenameHandoff(handoff.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(handoff.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) },
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
])
}
className={css({
@ -211,7 +215,7 @@ export const Sidebar = memo(function Sidebar({
flexShrink: 0,
})}
>
<HandoffIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
<TaskIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<LabelSmall
$style={{
@ -223,7 +227,7 @@ export const Sidebar = memo(function Sidebar({
}}
color={isDim ? theme.colors.contentSecondary : theme.colors.contentPrimary}
>
{handoff.title}
{task.title}
</LabelSmall>
{hasDiffs ? (
<div className={css({ display: "flex", gap: "4px", flexShrink: 0 })}>
@ -242,20 +246,20 @@ export const Sidebar = memo(function Sidebar({
flexShrink: 1,
}}
>
{handoff.repoName}
{task.repoName}
</LabelXSmall>
{handoff.pullRequest != null ? (
{task.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
#{handoff.pullRequest.number}
#{task.pullRequest.number}
</LabelXSmall>
{handoff.pullRequest.status === "draft" ? <CloudUpload size={11} color="#ff4f00" /> : null}
{task.pullRequest.status === "draft" ? <CloudUpload size={11} color="#ff4f00" /> : null}
</span>
) : (
<GitPullRequestDraft size={11} color={theme.colors.contentTertiary} />
)}
<LabelXSmall color={theme.colors.contentTertiary} $style={{ marginLeft: "auto", flexShrink: 0 }}>
{formatRelativeAge(handoff.updatedAtMs)}
{formatRelativeAge(task.updatedAtMs)}
</LabelXSmall>
</div>
</div>
@ -266,9 +270,9 @@ export const Sidebar = memo(function Sidebar({
<button
type="button"
onClick={() =>
setExpandedProjects((current) => ({
setExpandedRepos((current) => ({
...current,
[project.id]: true,
[repo.id]: true,
}))
}
className={css({

View file

@ -53,16 +53,16 @@ export const SkeletonBlock = memo(function SkeletonBlock({
return <SkeletonLine width={width} height={height} borderRadius={borderRadius} style={style} />;
});
/** Sidebar skeleton: header + list of handoff placeholders */
/** Sidebar skeleton: header + list of task placeholders */
export const SidebarSkeleton = memo(function SidebarSkeleton() {
return (
<div style={{ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
{/* Project header skeleton */}
{/* Repo header skeleton */}
<div style={{ padding: "10px 8px 4px", display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SkeletonLine width="40%" height={10} />
<SkeletonLine width={48} height={10} />
</div>
{/* Handoff item skeletons */}
{/* Task item skeletons */}
{[0, 1, 2, 3].map((i) => (
<div
key={i}

View file

@ -4,10 +4,10 @@ import { LabelXSmall } from "baseui/typography";
import { FileCode, Plus, X } from "lucide-react";
import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui";
import { diffTabId, fileName, type Handoff } from "./view-model";
import { diffTabId, fileName, type Task } from "./view-model";
export const TabStrip = memo(function TabStrip({
handoff,
task,
activeTabId,
openDiffs,
editingSessionTabId,
@ -22,7 +22,7 @@ export const TabStrip = memo(function TabStrip({
onCloseDiffTab,
onAddTab,
}: {
handoff: Handoff;
task: Task;
activeTabId: string | null;
openDiffs: string[];
editingSessionTabId: string | null;
@ -56,7 +56,7 @@ export const TabStrip = memo(function TabStrip({
"::-webkit-scrollbar": { display: "none" },
})}
>
{handoff.tabs.map((tab) => {
{task.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
@ -64,7 +64,7 @@ export const TabStrip = memo(function TabStrip({
onClick={() => onSwitchTab(tab.id)}
onDoubleClick={() => onStartRenamingTab(tab.id)}
onMouseDown={(event) => {
if (event.button === 1 && handoff.tabs.length > 1) {
if (event.button === 1 && task.tabs.length > 1) {
event.preventDefault();
onCloseTab(tab.id);
}
@ -76,7 +76,7 @@ export const TabStrip = memo(function TabStrip({
label: tab.unread ? "Mark as read" : "Mark as unread",
onClick: () => onSetTabUnread(tab.id, !tab.unread),
},
...(handoff.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
...(task.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
])
}
className={css({
@ -134,7 +134,7 @@ export const TabStrip = memo(function TabStrip({
{tab.sessionName}
</LabelXSmall>
)}
{handoff.tabs.length > 1 ? (
{task.tabs.length > 1 ? (
<X
size={11}
color={theme.colors.contentTertiary}

View file

@ -4,10 +4,10 @@ import { LabelSmall } from "baseui/typography";
import { MailOpen } from "lucide-react";
import { PanelHeaderBar } from "./ui";
import { type AgentTab, type Handoff } from "./view-model";
import { type AgentTab, type Task } from "./view-model";
export const TranscriptHeader = memo(function TranscriptHeader({
handoff,
task,
activeTab,
editingField,
editValue,
@ -17,7 +17,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
onCancelEditingField,
onSetActiveTabUnread,
}: {
handoff: Handoff;
task: Task;
activeTab: AgentTab | null | undefined;
editingField: "title" | "branch" | null;
editValue: string;
@ -59,12 +59,12 @@ export const TranscriptHeader = memo(function TranscriptHeader({
title="Rename"
color={theme.colors.contentPrimary}
$style={{ fontWeight: 600, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
onClick={() => onStartEditingField("title", handoff.title)}
onClick={() => onStartEditingField("title", task.title)}
>
{handoff.title}
{task.title}
</LabelSmall>
)}
{handoff.branch ? (
{task.branch ? (
editingField === "branch" ? (
<input
autoFocus
@ -94,7 +94,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
) : (
<span
title="Rename"
onClick={() => onStartEditingField("branch", handoff.branch ?? "")}
onClick={() => onStartEditingField("branch", task.branch ?? "")}
className={css({
padding: "2px 8px",
borderRadius: "999px",
@ -108,7 +108,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
":hover": { borderColor: "rgba(255, 255, 255, 0.3)" },
})}
>
{handoff.branch}
{task.branch}
</span>
)
) : null}

View file

@ -111,7 +111,7 @@ export const UnreadDot = memo(function UnreadDot() {
);
});
export const HandoffIndicator = memo(function HandoffIndicator({
export const TaskIndicator = memo(function TaskIndicator({
isRunning,
hasUnread,
isDraft,

View file

@ -4,18 +4,19 @@ import type {
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileChange as FileChange,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchHandoff as Handoff,
WorkbenchTask as WorkbenchTask,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchLineAttachment as LineAttachment,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchProjectSection as ProjectSection,
WorkbenchRepoSection as WorkbenchRepoSection,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/factory-shared";
import { extractEventText } from "../../features/sessions/model";
export type { ProjectSection };
export type Task = WorkbenchTask;
export type RepoSection = WorkbenchRepoSection;
export const MODEL_GROUPS: ModelGroup[] = [
{
@ -329,7 +330,6 @@ export type {
DiffLineKind,
FileChange,
FileTreeNode,
Handoff,
HistoryEvent,
LineAttachment,
ModelGroup,

View file

@ -6,13 +6,14 @@ import {
type FactoryUser,
} from "@sandbox-agent/factory-shared";
import { useNavigate } from "@tanstack/react-router";
import { ArrowLeft, BadgeCheck, Building2, CreditCard, Github, LoaderCircle, ShieldCheck, Users } from "lucide-react";
import { ArrowLeft, BadgeCheck, Building2, CreditCard, Github, ShieldCheck, Users } from "lucide-react";
import {
activeMockUser,
eligibleOrganizations,
useMockAppClient,
useMockAppSnapshot,
} from "../lib/mock-app";
import { isMockFrontendClient } from "../lib/env";
const dateFormatter = new Intl.DateTimeFormat("en-US", {
month: "short",
@ -41,12 +42,6 @@ const planCatalog: Record<
seats: "5 seats included",
summary: "GitHub org onboarding, shared billing, and seat accrual on first prompt.",
},
enterprise: {
label: "Enterprise",
price: "$1,200/mo",
seats: "25 seats included",
summary: "Enterprise controls, larger seat pools, and procurement-ready billing.",
},
};
function appSurfaceStyle(): React.CSSProperties {
@ -162,10 +157,6 @@ function billingPath(organization: FactoryOrganization): string {
return `/organizations/${organization.id}/billing`;
}
function importPath(organization: FactoryOrganization): string {
return `/organizations/${organization.id}/import`;
}
function checkoutPath(organization: FactoryOrganization, planId: FactoryBillingPlanId): string {
return `/organizations/${organization.id}/checkout/${planId}`;
}
@ -330,8 +321,9 @@ export function MockSignInPage() {
Sign in and land directly in the org onboarding funnel.
</div>
<div style={{ fontSize: "16px", lineHeight: 1.6, color: "#d4d4d8", maxWidth: "56ch" }}>
This mock screen stands in for a basic GitHub OAuth sign-in page. After sign-in, the user moves into the
separate organization selector and then the rest of the onboarding funnel.
{isMockFrontendClient
? "This mock screen stands in for a basic GitHub OAuth sign-in page. After sign-in, the user moves into the separate organization selector and then the rest of the onboarding funnel."
: "GitHub OAuth starts here. After the callback exchange completes, the app restores the signed-in session and continues into organization selection."}
</div>
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
<div style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
@ -361,16 +353,19 @@ export function MockSignInPage() {
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ fontSize: "22px", fontWeight: 800 }}>Continue to Sandbox Agent</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
This mock sign-in uses a single GitHub account so the org selection step remains the place where the
user chooses their workspace.
{isMockFrontendClient
? "This mock sign-in uses a single GitHub account so the org selection step remains the place where the user chooses their workspace."
: "This starts the live GitHub OAuth flow and restores the app session when the callback returns."}
</div>
</div>
<button
type="button"
onClick={() => {
void (async () => {
await client.signInWithGithub("user-nathan");
await navigate({ to: "/organizations" });
await client.signInWithGithub(isMockFrontendClient ? "user-nathan" : undefined);
if (isMockFrontendClient) {
await navigate({ to: "/organizations" });
}
})();
}}
style={{
@ -403,10 +398,14 @@ export function MockSignInPage() {
@{mockAccount.githubLogin} · {mockAccount.email}
</div>
</div>
<span style={badgeStyle("rgba(24, 140, 255, 0.16)", "#b9d8ff")}>{mockAccount.label}</span>
<span style={badgeStyle("rgba(24, 140, 255, 0.16)", "#b9d8ff")}>
{isMockFrontendClient ? mockAccount.label : "Live GitHub identity"}
</span>
</div>
<div style={{ color: "#a1a1aa", fontSize: "13px", lineHeight: 1.5 }}>
Sign-in always lands as this single mock user. Organization choice happens on the next screen.
{isMockFrontendClient
? "Sign-in always lands as this single mock user. Organization choice happens on the next screen."
: "In remote mode this card is replaced by the live GitHub user once the OAuth callback completes."}
</div>
</div>
</div>
@ -492,9 +491,7 @@ export function MockOrganizationSelectorPage() {
onClick={() => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({
to: organization.repoImportStatus === "ready" ? workspacePath(organization) : importPath(organization),
});
await navigate({ to: workspacePath(organization) });
})();
}}
style={primaryButtonStyle()}
@ -515,110 +512,6 @@ export function MockOrganizationSelectorPage() {
);
}
export function MockOrganizationImportPage({ organization }: { organization: FactoryOrganization }) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
useEffect(() => {
if (organization.repoImportStatus === "ready") {
void navigate({ to: workspacePath(organization), replace: true });
}
}, [navigate, organization]);
return (
<PageShell
user={user}
title={`Importing ${organization.settings.displayName}`}
eyebrow="Repository Sync"
description="This mock view stands in for the post-auth GitHub installation and repository import step. Organization onboarding blocks until repo metadata is ready so the user lands in a populated workspace."
actions={
<button type="button" onClick={() => void navigate({ to: "/organizations" })} style={secondaryButtonStyle()}>
<ArrowLeft size={15} />
Back
</button>
}
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.2fr) minmax(320px, 0.8fr)", gap: "18px" }}>
<div style={{ ...cardStyle(), padding: "28px", display: "flex", flexDirection: "column", gap: "18px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<LoaderCircle size={24} style={{ animation: "hf-spin 1s linear infinite" }} />
<div>
<div style={{ fontSize: "22px", fontWeight: 800 }}>
{organization.repoImportStatus === "ready" ? "Import complete" : "Preparing repository catalog"}
</div>
<div style={{ color: "#a1a1aa", fontSize: "14px" }}>{organization.github.lastSyncLabel}</div>
</div>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.6 }}>
The mock client now simulates the expected onboarding pause: GitHub app access is validated, repository metadata
is imported, and the resulting workspace stays blocked until ready.
</div>
<div
style={{
height: "12px",
borderRadius: "999px",
overflow: "hidden",
background: "rgba(255, 255, 255, 0.08)",
}}
>
<div
style={{
width: organization.repoImportStatus === "ready" ? "100%" : "76%",
height: "100%",
borderRadius: "999px",
background: "linear-gradient(90deg, #ff4f00, #ff9d00)",
transition: "width 200ms ease",
}}
/>
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
{organization.github.installationStatus !== "connected" ? (
<button
type="button"
onClick={() => void client.reconnectGithub(organization.id)}
style={primaryButtonStyle()}
>
Reconnect GitHub App
</button>
) : null}
<button type="button" onClick={() => void client.triggerRepoImport(organization.id)} style={secondaryButtonStyle()}>
Re-run import
</button>
<button type="button" onClick={() => void navigate({ to: settingsPath(organization) })} style={subtleButtonStyle()}>
Review org settings
</button>
</div>
</div>
<div style={{ display: "grid", gap: "14px" }}>
<StatCard
label="GitHub"
value={organization.github.connectedAccount}
caption={organization.github.installationStatus.replaceAll("_", " ")}
/>
<StatCard
label="Repositories"
value={`${organization.repoCatalog.length}`}
caption="Imported into the mock workspace catalog"
/>
<StatCard
label="Billing"
value={planCatalog[organization.billing.planId]!.label}
caption={`${organization.seatAssignments.length} seats accrued so far`}
/>
</div>
</div>
</PageShell>
);
}
export function MockOrganizationSettingsPage({ organization }: { organization: FactoryOrganization }) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
@ -631,6 +524,12 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
() => `${organization.seatAssignments.length} of ${organization.billing.seatsIncluded} seats already accrued`,
[organization.billing.seatsIncluded, organization.seatAssignments.length],
);
const openWorkspace = () => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
};
useEffect(() => {
setDisplayName(organization.settings.displayName);
@ -648,7 +547,7 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
user={user}
title={`${organization.settings.displayName} settings`}
eyebrow="Organization"
description="This mock settings surface covers the org profile, GitHub installation state, repository import controls, and the seat-accrual rule from the spec. It is intentionally product-shaped even though the real backend is not wired yet."
description="This mock settings surface covers the org profile, GitHub installation state, background repository sync controls, and the seat-accrual rule from the spec. It is intentionally product-shaped even though the real backend is not wired yet."
actions={
<>
<button type="button" onClick={() => void navigate({ to: "/organizations" })} style={secondaryButtonStyle()}>
@ -658,7 +557,7 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={subtleButtonStyle()}>
Billing
</button>
<button type="button" onClick={() => void navigate({ to: workspacePath(organization) })} style={primaryButtonStyle()}>
<button type="button" onClick={openWorkspace} style={primaryButtonStyle()}>
Open workspace
</button>
</>
@ -719,8 +618,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
>
Save settings
</button>
<button type="button" onClick={() => void client.triggerRepoImport(organization.id)} style={secondaryButtonStyle()}>
Refresh repo import
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={secondaryButtonStyle()}>
Refresh repo sync
</button>
</div>
</div>
@ -741,8 +640,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={secondaryButtonStyle()}>
Reconnect GitHub
</button>
<button type="button" onClick={() => void navigate({ to: importPath(organization) })} style={subtleButtonStyle()}>
Open import flow
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={subtleButtonStyle()}>
Retry sync
</button>
</div>
</div>
@ -784,19 +683,32 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const hasStripeCustomer = organization.billing.stripeCustomerId.trim().length > 0;
const effectivePlanId: FactoryBillingPlanId = hasStripeCustomer ? organization.billing.planId : "free";
const effectiveSeatsIncluded = hasStripeCustomer ? organization.billing.seatsIncluded : 1;
const openWorkspace = () => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
};
return (
<PageShell
user={user}
title={`${organization.settings.displayName} billing`}
eyebrow="Stripe Billing"
description="This mock page covers plan selection, hosted checkout entry, renewal controls, seat usage, and invoice history. It is the reviewable UI surface for Milestone 2 billing without wiring the real Stripe backend yet."
description={
isMockFrontendClient
? "This mock page covers plan selection, hosted checkout entry, renewal controls, seat usage, and invoice history. It is the reviewable UI surface for Milestone 2 billing without wiring the real Stripe backend yet."
: "This billing surface drives live Stripe checkout, portal management, renewal controls, seat usage, and invoice history from the persisted organization billing model."
}
actions={
<>
<button type="button" onClick={() => void navigate({ to: settingsPath(organization) })} style={secondaryButtonStyle()}>
Org settings
</button>
<button type="button" onClick={() => void navigate({ to: workspacePath(organization) })} style={primaryButtonStyle()}>
<button type="button" onClick={openWorkspace} style={primaryButtonStyle()}>
Open workspace
</button>
</>
@ -811,12 +723,12 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: "14px" }}>
<StatCard
label="Current plan"
value={planCatalog[organization.billing.planId]!.label}
value={planCatalog[effectivePlanId]!.label}
caption={organization.billing.status.replaceAll("_", " ")}
/>
<StatCard
label="Seats used"
value={`${organization.seatAssignments.length}/${organization.billing.seatsIncluded}`}
value={`${organization.seatAssignments.length}/${effectiveSeatsIncluded}`}
caption="Seat accrual happens on first prompt in the workspace."
/>
<StatCard
@ -828,7 +740,7 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: "18px" }}>
{(Object.entries(planCatalog) as Array<[FactoryBillingPlanId, (typeof planCatalog)[FactoryBillingPlanId]]>).map(([planId, plan]) => {
const isCurrent = organization.billing.planId === planId;
const isCurrent = effectivePlanId === planId;
return (
<div key={planId} style={{ ...cardStyle(), padding: "22px", display: "grid", gap: "14px" }}>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "12px" }}>
@ -863,25 +775,41 @@ export function MockOrganizationBillingPage({ organization }: { organization: Fa
<div style={{ fontSize: "20px", fontWeight: 800 }}>Subscription controls</div>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
Stripe customer {organization.billing.stripeCustomerId}. This mock screen intentionally mirrors a hosted
billing portal entry point and the in-product summary beside it.
Stripe customer {organization.billing.stripeCustomerId || "pending"}.{" "}
{isMockFrontendClient
? "This mock screen intentionally mirrors a hosted billing portal entry point and the in-product summary beside it."
: hasStripeCustomer
? "Use the portal for payment method management and invoices, while in-product controls keep renewal state visible in the app shell."
: "Complete checkout first, then use the portal and renewal controls once Stripe has created the customer and subscription."}
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
{organization.billing.status === "scheduled_cancel" ? (
<button type="button" onClick={() => void client.resumeSubscription(organization.id)} style={primaryButtonStyle()}>
Resume subscription
</button>
{hasStripeCustomer ? (
organization.billing.status === "scheduled_cancel" ? (
<button type="button" onClick={() => void client.resumeSubscription(organization.id)} style={primaryButtonStyle()}>
Resume subscription
</button>
) : (
<button type="button" onClick={() => void client.cancelScheduledRenewal(organization.id)} style={secondaryButtonStyle()}>
Cancel at period end
</button>
)
) : (
<button type="button" onClick={() => void client.cancelScheduledRenewal(organization.id)} style={secondaryButtonStyle()}>
Cancel at period end
<button type="button" onClick={() => void navigate({ to: checkoutPath(organization, "team") })} style={primaryButtonStyle()}>
Start Team checkout
</button>
)}
<button
type="button"
onClick={() => void navigate({ to: checkoutPath(organization, organization.billing.planId) })}
onClick={() =>
void (isMockFrontendClient
? navigate({ to: checkoutPath(organization, effectivePlanId) })
: hasStripeCustomer
? client.openBillingPortal(organization.id)
: navigate({ to: checkoutPath(organization, "team") }))
}
style={subtleButtonStyle()}
>
Open hosted checkout mock
{isMockFrontendClient ? "Open hosted checkout mock" : hasStripeCustomer ? "Open Stripe portal" : "Go to checkout"}
</button>
</div>
</div>
@ -951,7 +879,11 @@ export function MockHostedCheckoutPage({
user={user}
title={`Checkout ${plan.label}`}
eyebrow="Hosted Checkout"
description="This is the mock hosted Stripe step. Completing checkout updates the org billing state in the client package and returns the reviewer to the billing screen."
description={
isMockFrontendClient
? "This is the mock hosted Stripe step. Completing checkout updates the org billing state in the client package and returns the reviewer to the billing screen."
: "This hands off to a live Stripe Checkout session. After payment succeeds, the backend finalizes the session and routes back into the billing screen."
}
actions={
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={secondaryButtonStyle()}>
<ArrowLeft size={15} />
@ -975,7 +907,7 @@ export function MockHostedCheckoutPage({
<CheckoutLine label="Plan" value={plan.label} />
<CheckoutLine label="Price" value={plan.price} />
<CheckoutLine label="Included seats" value={plan.seats} />
<CheckoutLine label="Payment method" value={planId === "enterprise" ? "ACH mandate" : "Visa ending in 4242"} />
<CheckoutLine label="Payment method" value="Visa ending in 4242" />
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
@ -995,12 +927,14 @@ export function MockHostedCheckoutPage({
onClick={() => {
void (async () => {
await client.completeHostedCheckout(organization.id, planId);
await navigate({ to: billingPath(organization), replace: true });
if (isMockFrontendClient) {
await navigate({ to: billingPath(organization), replace: true });
}
})();
}}
style={primaryButtonStyle()}
>
Complete checkout
{isMockFrontendClient ? "Complete checkout" : "Continue to Stripe"}
</button>
</div>
</div>

View file

@ -1,7 +1,7 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/factory-shared";
import type { AgentType, TaskRecord, TaskSummary, 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 { groupTaskStatus } 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";
@ -15,13 +15,13 @@ import { StyledDivider } from "baseui/divider";
import { styled, useStyletron } from "baseui";
import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography";
import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal, Shuffle } from "lucide-react";
import { formatDiffStat } from "../features/handoffs/model";
import { formatDiffStat } from "../features/tasks/model";
import { buildTranscript, resolveSessionSelection } from "../features/sessions/model";
import { backendClient } from "../lib/backend";
interface WorkspaceDashboardProps {
workspaceId: string;
selectedHandoffId?: string;
selectedTaskId?: string;
selectedRepoId?: string;
}
@ -87,7 +87,7 @@ const DetailRail = styled("aside", ({ $theme }) => ({
const FILTER_OPTIONS: SelectItem[] = [
{ id: "active", label: "Active + Unmapped" },
{ id: "archived", label: "Archived Handoffs" },
{ id: "archived", label: "Archived Tasks" },
{ id: "unmapped", label: "Unmapped Only" },
{ id: "all", label: "All Branches" },
];
@ -97,8 +97,8 @@ const AGENT_OPTIONS: SelectItem[] = [
{ id: "claude", label: "claude" },
];
function statusKind(status: HandoffSummary["status"]): StatusTagKind {
const group = groupHandoffStatus(status);
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";
@ -137,21 +137,21 @@ function branchTestIdToken(value: string): string {
}
function useSessionEvents(
handoff: HandoffRecord | null,
task: TaskRecord | null,
sessionId: string | null
): ReturnType<typeof useQuery<{ items: SandboxSessionEventRecord[]; nextCursor?: string }, Error>> {
return useQuery({
queryKey: ["workspace", handoff?.workspaceId ?? "", "session", handoff?.handoffId ?? "", sessionId ?? ""],
enabled: Boolean(handoff?.activeSandboxId && sessionId),
queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""],
enabled: Boolean(task?.activeSandboxId && sessionId),
refetchInterval: 2_500,
queryFn: async () => {
if (!handoff?.activeSandboxId || !sessionId) {
if (!task?.activeSandboxId || !sessionId) {
return { items: [] };
}
return backendClient.listSandboxSessionEvents(
handoff.workspaceId,
handoff.providerId,
handoff.activeSandboxId,
task.workspaceId,
task.providerId,
task.activeSandboxId,
{
sessionId,
limit: 120,
@ -186,7 +186,7 @@ function repoSummary(overview: RepoOverview | undefined): {
let openPrs = 0;
for (const row of overview.branches) {
if (row.handoffId) {
if (row.taskId) {
mapped += 1;
}
if (row.conflictsWithMain) {
@ -225,13 +225,13 @@ function branchKind(row: RepoBranchRecord): StatusTagKind {
function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean {
if (filter === "archived") {
return branch.handoffStatus === "archived";
return branch.taskStatus === "archived";
}
if (filter === "unmapped") {
return branch.handoffId === null;
return branch.taskId === null;
}
if (filter === "active") {
return branch.handoffStatus !== "archived";
return branch.taskStatus !== "archived";
}
return true;
}
@ -364,7 +364,7 @@ function MetaRow({ label, value, mono = false }: { label: string; value: string;
);
}
export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRepoId }: WorkspaceDashboardProps) {
export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId }: WorkspaceDashboardProps) {
const [css, theme] = useStyletron();
const navigate = useNavigate();
const repoOverviewMode = typeof selectedRepoId === "string" && selectedRepoId.length > 0;
@ -377,7 +377,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const [newBranchName, setNewBranchName] = useState("");
const [createOnBranch, setCreateOnBranch] = useState<string | null>(null);
const [addRepoOpen, setAddRepoOpen] = useState(false);
const [createHandoffOpen, setCreateHandoffOpen] = useState(false);
const [createTaskOpen, setCreateTaskOpen] = useState(false);
const [addRepoRemote, setAddRepoRemote] = useState("");
const [addRepoError, setAddRepoError] = useState<string | null>(null);
const [stackActionError, setStackActionError] = useState<string | null>(null);
@ -396,21 +396,21 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
});
const [createError, setCreateError] = useState<string | null>(null);
const handoffsQuery = useQuery({
queryKey: ["workspace", workspaceId, "handoffs"],
queryFn: async () => backendClient.listHandoffs(workspaceId),
const tasksQuery = useQuery({
queryKey: ["workspace", workspaceId, "tasks"],
queryFn: async () => backendClient.listTasks(workspaceId),
refetchInterval: 2_500,
});
const handoffDetailQuery = useQuery({
queryKey: ["workspace", workspaceId, "handoff-detail", selectedHandoffId],
enabled: Boolean(selectedHandoffId && !repoOverviewMode),
const taskDetailQuery = useQuery({
queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId],
enabled: Boolean(selectedTaskId && !repoOverviewMode),
refetchInterval: 2_500,
queryFn: async () => {
if (!selectedHandoffId) {
throw new Error("No handoff");
if (!selectedTaskId) {
throw new Error("No task");
}
return backendClient.getHandoff(workspaceId, selectedHandoffId);
return backendClient.getTask(workspaceId, selectedTaskId);
},
});
@ -453,9 +453,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}
}, [newAgentType]);
const rows = handoffsQuery.data ?? [];
const rows = tasksQuery.data ?? [];
const repoGroups = useMemo(() => {
const byRepo = new Map<string, HandoffSummary[]>();
const byRepo = new Map<string, TaskSummary[]>();
for (const row of rows) {
const bucket = byRepo.get(row.repoId);
if (bucket) {
@ -467,13 +467,13 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
return repos
.map((repo) => {
const handoffs = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
const latestHandoffAt = handoffs[0]?.updatedAt ?? 0;
const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt);
const latestTaskAt = tasks[0]?.updatedAt ?? 0;
return {
repoId: repo.repoId,
repoRemote: repo.remoteUrl,
latestActivityAt: Math.max(repo.updatedAt, latestHandoffAt),
handoffs,
latestActivityAt: Math.max(repo.updatedAt, latestTaskAt),
tasks,
};
})
.sort((a, b) => {
@ -485,11 +485,11 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}, [repos, rows]);
const selectedSummary = useMemo(
() => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null,
[rows, selectedHandoffId]
() => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null,
[rows, selectedTaskId]
);
const selectedForSession = repoOverviewMode ? null : (handoffDetailQuery.data ?? null);
const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null);
const activeSandbox = useMemo(() => {
if (!selectedForSession) return null;
@ -500,23 +500,23 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}, [selectedForSession]);
useEffect(() => {
if (!repoOverviewMode && !selectedHandoffId && rows.length > 0) {
if (!repoOverviewMode && !selectedTaskId && rows.length > 0) {
void navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
handoffId: rows[0]!.handoffId,
taskId: rows[0]!.taskId,
},
search: { sessionId: undefined },
replace: true,
});
}
}, [navigate, repoOverviewMode, rows, selectedHandoffId, workspaceId]);
}, [navigate, repoOverviewMode, rows, selectedTaskId, workspaceId]);
useEffect(() => {
setActiveSessionId(null);
setDraft("");
}, [selectedForSession?.handoffId]);
}, [selectedForSession?.taskId]);
const sessionsQuery = useQuery({
queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"],
@ -537,7 +537,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
() =>
resolveSessionSelection({
explicitSessionId: activeSessionId,
handoffSessionId: selectedForSession?.activeSessionId ?? null,
taskSessionId: selectedForSession?.activeSessionId ?? null,
sessions: sessionRows,
}),
[activeSessionId, selectedForSession?.activeSessionId, sessionRows]
@ -547,9 +547,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId);
const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId);
const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => {
if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this handoff");
throw new Error("No sandbox is available for this task");
}
return backendClient.createSandboxSession({
workspaceId,
@ -562,7 +562,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
};
const createSession = useMutation({
mutationFn: async () => startSessionFromHandoff(),
mutationFn: async () => startSessionFromTask(),
onSuccess: async (session) => {
setActiveSessionId(session.id);
await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]);
@ -573,7 +573,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
if (resolvedSessionId) {
return resolvedSessionId;
}
const created = await startSessionFromHandoff();
const created = await startSessionFromTask();
setActiveSessionId(created.id);
await sessionsQuery.refetch();
return created.id;
@ -582,7 +582,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const sendPrompt = useMutation({
mutationFn: async (prompt: string) => {
if (!selectedForSession || !activeSandbox?.sandboxId) {
throw new Error("No sandbox is available for this handoff");
throw new Error("No sandbox is available for this task");
}
const sessionId = await ensureSessionForPrompt();
await backendClient.sendSandboxPrompt({
@ -600,9 +600,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
});
const transcript = buildTranscript(eventsQuery.data?.items ?? []);
const canCreateHandoff = createRepoId.trim().length > 0 && newTask.trim().length > 0;
const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0;
const createHandoff = useMutation({
const createTask = useMutation({
mutationFn: async () => {
const repoId = createRepoId.trim();
const task = newTask.trim();
@ -613,7 +613,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
const draftTitle = newTitle.trim();
const draftBranchName = newBranchName.trim();
return backendClient.createHandoff({
return backendClient.createTask({
workspaceId,
repoId,
task,
@ -623,20 +623,20 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
onBranch: createOnBranch ?? undefined,
});
},
onSuccess: async (handoff) => {
onSuccess: async (task) => {
setCreateError(null);
setNewTask("");
setNewTitle("");
setNewBranchName("");
setCreateOnBranch(null);
setCreateHandoffOpen(false);
await handoffsQuery.refetch();
setCreateTaskOpen(false);
await tasksQuery.refetch();
await repoOverviewQuery.refetch();
await navigate({
to: "/workspaces/$workspaceId/handoffs/$handoffId",
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
handoffId: handoff.handoffId,
taskId: task.taskId,
},
search: { sessionId: undefined },
});
@ -696,7 +696,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
setStackActionMessage(null);
setStackActionError(result.message);
}
await Promise.all([repoOverviewQuery.refetch(), handoffsQuery.refetch()]);
await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]);
},
onError: (error) => {
setStackActionMessage(null);
@ -712,7 +712,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
if (!newTask.trim()) {
setNewTask(`Continue work on ${branchName}`);
}
setCreateHandoffOpen(true);
setCreateTaskOpen(true);
};
const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]);
@ -858,19 +858,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
borderTop: `1px solid ${theme.colors.borderOpaque}`,
})}
>
<LabelXSmall color="contentSecondary">Handoffs</LabelXSmall>
<LabelXSmall color="contentSecondary">Tasks</LabelXSmall>
</div>
</PanelHeader>
<ScrollBody>
{handoffsQuery.isLoading ? (
{tasksQuery.isLoading ? (
<>
<Skeleton rows={3} height="72px" />
</>
) : null}
{!handoffsQuery.isLoading && repoGroups.length === 0 ? (
<EmptyState>No repos or handoffs yet. Add a repo to start a workspace.</EmptyState>
{!tasksQuery.isLoading && repoGroups.length === 0 ? (
<EmptyState>No repos or tasks yet. Add a repo to start a workspace.</EmptyState>
) : null}
{repoGroups.map((group) => (
@ -912,15 +912,15 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
gap: "0",
})}
>
{group.handoffs
.filter((handoff) => handoff.status !== "archived" || handoff.handoffId === selectedSummary?.handoffId)
.map((handoff) => {
const isActive = !repoOverviewMode && handoff.handoffId === selectedSummary?.handoffId;
{group.tasks
.filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId)
.map((task) => {
const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId;
return (
<Link
key={handoff.handoffId}
to="/workspaces/$workspaceId/handoffs/$handoffId"
params={{ workspaceId, handoffId: handoff.handoffId }}
key={task.taskId}
to="/workspaces/$workspaceId/tasks/$taskId"
params={{ workspaceId, taskId: task.taskId }}
search={{ sessionId: undefined }}
className={css({
display: "block",
@ -929,7 +929,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
borderTop: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: isActive
? "rgba(143, 180, 255, 0.08)"
: handoff.status === "archived"
: task.status === "archived"
? "rgba(255, 255, 255, 0.02)"
: "transparent",
padding: "10px 12px 10px 14px",
@ -940,7 +940,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
})}
>
<LabelSmall marginTop="0" marginBottom="0">
{handoff.title ?? "Determining title..."}
{task.title ?? "Determining title..."}
</LabelSmall>
<div
className={css({
@ -957,9 +957,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
color="contentSecondary"
overrides={{ Block: { style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } } }}
>
{handoff.branchName ?? "Determining branch..."}
{task.branchName ?? "Determining branch..."}
</ParagraphSmall>
<StatusPill kind={statusKind(handoff.status)}>{handoff.status}</StatusPill>
<StatusPill kind={statusKind(task.status)}>{task.status}</StatusPill>
</div>
</Link>
);
@ -982,11 +982,11 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
setCreateRepoId(group.repoId);
setCreateOnBranch(null);
setCreateError(null);
setCreateHandoffOpen(true);
setCreateTaskOpen(true);
}}
data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`}
data-testid={group.repoId === createRepoId ? "task-create-open" : `task-create-open-${group.repoId}`}
>
Create Handoff
Create Task
</Button>
</div>
</section>
@ -1198,8 +1198,8 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
{formatRelativeAge(branch.updatedAt)}
</ParagraphSmall>
<StatusPill kind={branch.handoffId ? "positive" : "warning"}>
{branch.handoffId ? "handoff" : "unmapped"}
<StatusPill kind={branch.taskId ? "positive" : "warning"}>
{branch.taskId ? "task" : "unmapped"}
</StatusPill>
{branch.trackedInStack ? <StatusPill kind="neutral">stack</StatusPill> : null}
</div>
@ -1282,7 +1282,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
Reparent
</Button>
{!branch.handoffId ? (
{!branch.taskId ? (
<Button
size="compact"
kind="secondary"
@ -1292,7 +1292,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
}}
data-testid={`repo-overview-create-${branchToken}`}
>
Create Handoff
Create Task
</Button>
) : null}
@ -1332,7 +1332,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
>
<Bot size={16} />
<HeadingXSmall marginTop="0" marginBottom="0">
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No handoff selected"}
{selectedForSession ? selectedForSession.title ?? "Determining title..." : "No task selected"}
</HeadingXSmall>
{selectedForSession ? (
<StatusPill kind={statusKind(selectedForSession.status)}>{selectedForSession.status}</StatusPill>
@ -1365,7 +1365,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
})}
>
{!selectedForSession ? (
<EmptyState>Select a handoff from the left sidebar.</EmptyState>
<EmptyState>Select a task from the left sidebar.</EmptyState>
) : (
<>
<div
@ -1417,7 +1417,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
setActiveSessionId(next);
}
}}
overrides={selectTestIdOverrides("handoff-session-select")}
overrides={selectTestIdOverrides("task-session-select")}
/>
</div>
) : null}
@ -1436,17 +1436,17 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
{transcript.length === 0 && !eventsQuery.isLoading ? (
<EmptyState testId="session-transcript-empty">
{groupHandoffStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
{groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage
? `Session failed: ${selectedForSession.statusMessage}`
: !activeSandbox?.sandboxId
? selectedForSession.statusMessage
? `Sandbox unavailable: ${selectedForSession.statusMessage}`
: "This handoff is still provisioning its sandbox."
: "This task is still provisioning its sandbox."
: staleSessionId
? `Session ${staleSessionId} is unavailable. Start a new session to continue.`
: resolvedSessionId
? "No transcript events yet. Send a prompt to start this session."
: "No active session for this handoff."}
: "No active session for this task."}
</EmptyState>
) : null}
@ -1519,7 +1519,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
placeholder="Send a follow-up prompt to this session"
rows={5}
disabled={!activeSandbox?.sandboxId}
overrides={textareaTestIdOverrides("handoff-session-prompt")}
overrides={textareaTestIdOverrides("task-session-prompt")}
/>
<div
className={css({
@ -1566,7 +1566,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<DetailRail>
<PanelHeader>
<HeadingSmall marginTop="0" marginBottom="0">
{repoOverviewMode ? "Repo Details" : "Handoff Details"}
{repoOverviewMode ? "Repo Details" : "Task Details"}
</HeadingSmall>
</PanelHeader>
@ -1619,8 +1619,8 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<MetaRow label="Commit" value={selectedBranchOverview.commitSha.slice(0, 10)} mono />
<MetaRow label="Diff" value={formatDiffStat(selectedBranchOverview.diffStat)} />
<MetaRow
label="Handoff"
value={selectedBranchOverview.handoffTitle ?? selectedBranchOverview.handoffId ?? "-"}
label="Task"
value={selectedBranchOverview.taskTitle ?? selectedBranchOverview.taskId ?? "-"}
/>
</div>
)}
@ -1629,7 +1629,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
)
) : !selectedForSession ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
No handoff selected.
No task selected.
</ParagraphSmall>
) : (
<>
@ -1645,7 +1645,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
gap: theme.sizing.scale300,
})}
>
<MetaRow label="Handoff" value={selectedForSession.handoffId} mono />
<MetaRow label="Task" value={selectedForSession.taskId} mono />
<MetaRow label="Sandbox" value={selectedForSession.activeSandboxId ?? "-"} mono />
<MetaRow label="Session" value={resolvedSessionId ?? "-"} mono />
</div>
@ -1689,7 +1689,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
</div>
</section>
{groupHandoffStatus(selectedForSession.status) === "error" ? (
{groupTaskStatus(selectedForSession.status) === "error" ? (
<div
className={css({
padding: "12px",
@ -1767,14 +1767,14 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
</Modal>
<Modal
isOpen={createHandoffOpen}
isOpen={createTaskOpen}
onClose={() => {
setCreateHandoffOpen(false);
setCreateTaskOpen(false);
setCreateOnBranch(null);
}}
overrides={modalOverrides}
>
<ModalHeader>Create Handoff</ModalHeader>
<ModalHeader>Create Task</ModalHeader>
<ModalBody>
<div
className={css({
@ -1784,7 +1784,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
})}
>
<ParagraphSmall marginTop="0" marginBottom="0" color="contentSecondary">
Pick a repo, describe the task, and the backend will create a handoff.
Pick a repo, describe the task, and the backend will create a task.
</ParagraphSmall>
<div>
@ -1803,7 +1803,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
setCreateRepoId(next);
}
}}
overrides={selectTestIdOverrides("handoff-create-repo")}
overrides={selectTestIdOverrides("task-create-repo")}
/>
{repos.length === 0 ? (
<div
@ -1826,7 +1826,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
size="compact"
kind="secondary"
onClick={() => {
setCreateHandoffOpen(false);
setCreateTaskOpen(false);
setAddRepoError(null);
setAddRepoOpen(true);
}}
@ -1852,7 +1852,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
setNewAgentType(next);
}
}}
overrides={selectTestIdOverrides("handoff-create-agent")}
overrides={selectTestIdOverrides("task-create-agent")}
/>
</div>
@ -1865,7 +1865,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
onChange={(event) => setNewTask(event.target.value)}
placeholder="Task"
rows={6}
overrides={textareaTestIdOverrides("handoff-create-task")}
overrides={textareaTestIdOverrides("task-create-task")}
/>
</div>
@ -1877,7 +1877,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
placeholder="Title (optional)"
value={newTitle}
onChange={(event) => setNewTitle(event.target.value)}
overrides={inputTestIdOverrides("handoff-create-title")}
overrides={inputTestIdOverrides("task-create-title")}
/>
</div>
@ -1886,19 +1886,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
Branch
</LabelXSmall>
{createOnBranch ? (
<Input value={createOnBranch} disabled overrides={inputTestIdOverrides("handoff-create-branch")} />
<Input value={createOnBranch} disabled overrides={inputTestIdOverrides("task-create-branch")} />
) : (
<Input
placeholder="Branch name (optional)"
value={newBranchName}
onChange={(event) => setNewBranchName(event.target.value)}
overrides={inputTestIdOverrides("handoff-create-branch")}
overrides={inputTestIdOverrides("task-create-branch")}
/>
)}
</div>
{createError ? (
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="handoff-create-error">
<ParagraphSmall marginTop="0" marginBottom="0" color="negative" data-testid="task-create-error">
{createError}
</ParagraphSmall>
) : null}
@ -1908,21 +1908,21 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep
<Button
kind="tertiary"
onClick={() => {
setCreateHandoffOpen(false);
setCreateTaskOpen(false);
setCreateOnBranch(null);
}}
>
Cancel
</Button>
<Button
disabled={!canCreateHandoff || createHandoff.isPending}
disabled={!canCreateTask || createTask.isPending}
onClick={() => {
setCreateError(null);
void createHandoff.mutateAsync();
void createTask.mutateAsync();
}}
data-testid="handoff-create-submit"
data-testid="task-create-submit"
>
Create Handoff
Create Task
</Button>
</ModalFooter>
</Modal>

View file

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

View file

@ -1,50 +0,0 @@
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
export interface RepoGroup {
repoId: string;
repoRemote: string;
handoffs: HandoffRecord[];
}
export function groupHandoffsByRepo(handoffs: HandoffRecord[]): RepoGroup[] {
const groups = new Map<string, RepoGroup>();
for (const handoff of handoffs) {
const group = groups.get(handoff.repoId);
if (group) {
group.handoffs.push(handoff);
continue;
}
groups.set(handoff.repoId, {
repoId: handoff.repoId,
repoRemote: handoff.repoRemote,
handoffs: [handoff],
});
}
return Array.from(groups.values())
.map((group) => ({
...group,
handoffs: [...group.handoffs].sort((a, b) => b.updatedAt - a.updatedAt),
}))
.sort((a, b) => {
const aLatest = a.handoffs[0]?.updatedAt ?? 0;
const bLatest = b.handoffs[0]?.updatedAt ?? 0;
if (aLatest !== bLatest) {
return bLatest - aLatest;
}
return a.repoRemote.localeCompare(b.repoRemote);
});
}
export function formatDiffStat(diffStat: string | null | undefined): string {
const normalized = diffStat?.trim();
if (!normalized) {
return "-";
}
if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") {
return "No changes";
}
return normalized;
}

View file

@ -92,7 +92,7 @@ describe("resolveSessionSelection", () => {
it("prefers explicit selection when present in session list", () => {
const resolved = resolveSessionSelection({
explicitSessionId: "session-2",
handoffSessionId: "session-1",
taskSessionId: "session-1",
sessions: [session("session-1"), session("session-2")]
});
@ -102,10 +102,10 @@ describe("resolveSessionSelection", () => {
});
});
it("falls back to handoff session when explicit selection is missing", () => {
it("falls back to task session when explicit selection is missing", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
handoffSessionId: "session-1",
taskSessionId: "session-1",
sessions: [session("session-1")]
});
@ -118,7 +118,7 @@ describe("resolveSessionSelection", () => {
it("falls back to the newest available session when configured session IDs are stale", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
handoffSessionId: "session-stale",
taskSessionId: "session-stale",
sessions: [session("session-fresh")]
});
@ -131,7 +131,7 @@ describe("resolveSessionSelection", () => {
it("marks stale session when no sessions are available", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
handoffSessionId: "session-stale",
taskSessionId: "session-stale",
sessions: []
});

View file

@ -107,7 +107,7 @@ export function buildTranscript(events: SandboxSessionEventRecord[]): Array<{
export function resolveSessionSelection(input: {
explicitSessionId: string | null;
handoffSessionId: string | null;
taskSessionId: string | null;
sessions: SandboxSessionRecord[];
}): {
sessionId: string | null;
@ -120,8 +120,8 @@ export function resolveSessionSelection(input: {
return { sessionId: input.explicitSessionId, staleSessionId: null };
}
if (hasSession(input.handoffSessionId)) {
return { sessionId: input.handoffSessionId, staleSessionId: null };
if (hasSession(input.taskSessionId)) {
return { sessionId: input.taskSessionId, staleSessionId: null };
}
const fallbackSessionId = input.sessions[0]?.id ?? null;
@ -133,8 +133,8 @@ export function resolveSessionSelection(input: {
return { sessionId: null, staleSessionId: input.explicitSessionId };
}
if (input.handoffSessionId) {
return { sessionId: null, staleSessionId: input.handoffSessionId };
if (input.taskSessionId) {
return { sessionId: null, staleSessionId: input.taskSessionId };
}
return { sessionId: null, staleSessionId: null };

View file

@ -1,12 +1,12 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
import { formatDiffStat, groupHandoffsByRepo } from "./model";
import type { TaskRecord } from "@sandbox-agent/factory-shared";
import { formatDiffStat, groupTasksByRepo } from "./model";
const base: HandoffRecord = {
const base: TaskRecord = {
workspaceId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
handoffId: "handoff-1",
taskId: "task-1",
branchName: "feature/one",
title: "Feature one",
task: "Ship one",
@ -41,27 +41,27 @@ const base: HandoffRecord = {
updatedAt: 10,
};
describe("groupHandoffsByRepo", () => {
describe("groupTasksByRepo", () => {
it("groups by repo and sorts by recency", () => {
const rows: HandoffRecord[] = [
{ ...base, handoffId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 },
{ ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 },
{ ...base, handoffId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 },
const rows: TaskRecord[] = [
{ ...base, taskId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 },
{ ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 },
{ ...base, taskId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 },
];
const groups = groupHandoffsByRepo(rows);
const groups = groupTasksByRepo(rows);
expect(groups).toHaveLength(2);
expect(groups[0]?.repoId).toBe("repo-a");
expect(groups[0]?.handoffs[0]?.handoffId).toBe("h2");
expect(groups[0]?.tasks[0]?.taskId).toBe("h2");
});
it("sorts repo groups by latest handoff activity first", () => {
const rows: HandoffRecord[] = [
{ ...base, handoffId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 },
{ ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 },
it("sorts repo groups by latest task activity first", () => {
const rows: TaskRecord[] = [
{ ...base, taskId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 },
{ ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 },
];
const groups = groupHandoffsByRepo(rows);
const groups = groupTasksByRepo(rows);
expect(groups[0]?.repoId).toBe("repo-z");
expect(groups[1]?.repoId).toBe("repo-a");
});

View file

@ -0,0 +1,54 @@
import type { TaskRecord } from "@sandbox-agent/factory-shared";
export interface RepoGroup {
repoId: string;
repoRemote: string;
tasks: TaskRecord[];
}
export function groupTasksByRepo(tasks: TaskRecord[]): RepoGroup[] {
const groups = new Map<string, RepoGroup>();
for (const task of tasks) {
const linkedRepoIds = task.repoIds?.length ? task.repoIds : [task.repoId];
for (const repoId of linkedRepoIds) {
const group = groups.get(repoId);
if (group) {
group.tasks.push(task);
group.tasks = group.tasks;
continue;
}
groups.set(repoId, {
repoId,
repoRemote: task.repoRemote,
tasks: [task],
});
}
}
return Array.from(groups.values())
.map((group) => ({
...group,
tasks: [...group.tasks].sort((a, b) => b.updatedAt - a.updatedAt),
}))
.sort((a, b) => {
const aLatest = a.tasks[0]?.updatedAt ?? 0;
const bLatest = b.tasks[0]?.updatedAt ?? 0;
if (aLatest !== bLatest) {
return bLatest - aLatest;
}
return a.repoRemote.localeCompare(b.repoRemote);
});
}
export function formatDiffStat(diffStat: string | null | undefined): string {
const normalized = diffStat?.trim();
if (!normalized) {
return "-";
}
if (normalized === "+0/-0" || normalized === "+0 -0" || normalized === "0 files changed") {
return "No changes";
}
return normalized;
}

View file

@ -10,6 +10,8 @@ import type { FactoryAppSnapshot, FactoryOrganization } from "@sandbox-agent/fac
import { backendClient } from "./backend";
import { frontendClientMode } from "./env";
const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-factory:remote-app-session";
const appClient: FactoryAppClient = createFactoryAppClient({
mode: frontendClientMode,
backend: frontendClientMode === "remote" ? backendClient : undefined,
@ -31,10 +33,47 @@ export const activeMockUser = currentFactoryUser;
export const activeMockOrganization = currentFactoryOrganization;
export const eligibleOrganizations = eligibleFactoryOrganizations;
// Track whether the remote client has delivered its first real snapshot.
// Before the first fetch completes the snapshot is the default empty signed_out state,
// so we show a loading screen. Once the fetch returns we know the truth.
let firstSnapshotDelivered = false;
// The remote client notifies listeners after refresh(), which sets `firstSnapshotDelivered`.
const origSubscribe = appClient.subscribe.bind(appClient);
appClient.subscribe = (listener: () => void): (() => void) => {
const wrappedListener = () => {
firstSnapshotDelivered = true;
listener();
};
return origSubscribe(wrappedListener);
};
export function isAppSnapshotBootstrapping(snapshot: FactoryAppSnapshot): boolean {
if (frontendClientMode !== "remote" || typeof window === "undefined") {
return false;
}
const hasStoredSession = window.localStorage.getItem(REMOTE_APP_SESSION_STORAGE_KEY)?.trim().length;
if (!hasStoredSession) {
return false;
}
// If the backend has already responded and we're still signed_out, the session is stale.
if (firstSnapshotDelivered) {
return false;
}
// Still waiting for the initial fetch — show the loading screen.
return (
snapshot.auth.status === "signed_out" &&
snapshot.users.length === 0 &&
snapshot.organizations.length === 0
);
}
export function getMockOrganizationById(
snapshot: FactoryAppSnapshot,
organizationId: string,
): FactoryOrganization | null {
return snapshot.organizations.find((organization) => organization.id === organizationId) ?? null;
}

View file

@ -1,8 +1,11 @@
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
import type { TaskWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
export function resolveRepoRouteHandoffId(
snapshot: HandoffWorkbenchSnapshot,
export function resolveRepoRouteTaskId(
snapshot: TaskWorkbenchSnapshot,
repoId: string,
): string | null {
return snapshot.handoffs.find((handoff) => handoff.repoId === repoId)?.id ?? null;
const tasks = (snapshot as TaskWorkbenchSnapshot & { tasks?: TaskWorkbenchSnapshot["tasks"] }).tasks ?? snapshot.tasks;
return tasks.find((task) =>
(task.repoIds?.length ? task.repoIds : [task.repoId]).includes(repoId)
)?.id ?? null;
}

View file

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

View file

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

View file

@ -1,17 +1,17 @@
import { describe, expect, it } from "vitest";
import type { HandoffWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
import { resolveRepoRouteHandoffId } from "./workbench-routing";
import type { TaskWorkbenchSnapshot } from "@sandbox-agent/factory-shared";
import { resolveRepoRouteTaskId } from "./workbench-routing";
const snapshot: HandoffWorkbenchSnapshot = {
const snapshot: TaskWorkbenchSnapshot = {
workspaceId: "default",
repos: [
{ id: "repo-a", label: "acme/repo-a" },
{ id: "repo-b", label: "acme/repo-b" },
],
projects: [],
handoffs: [
repoSections: [],
tasks: [
{
id: "handoff-a",
id: "task-a",
repoId: "repo-a",
title: "Alpha",
status: "idle",
@ -27,12 +27,12 @@ const snapshot: HandoffWorkbenchSnapshot = {
],
};
describe("resolveRepoRouteHandoffId", () => {
it("finds the active handoff for a repo route", () => {
expect(resolveRepoRouteHandoffId(snapshot, "repo-a")).toBe("handoff-a");
describe("resolveRepoRouteTaskId", () => {
it("finds the active task for a repo route", () => {
expect(resolveRepoRouteTaskId(snapshot, "repo-a")).toBe("task-a");
});
it("returns null when a repo has no handoff yet", () => {
expect(resolveRepoRouteHandoffId(snapshot, "repo-b")).toBeNull();
it("returns null when a repo has no task yet", () => {
expect(resolveRepoRouteTaskId(snapshot, "repo-b")).toBeNull();
});
});

View file

@ -1,11 +1,11 @@
import type { HandoffWorkbenchClient } from "@sandbox-agent/factory-client/workbench";
import type { TaskWorkbenchClient } from "@sandbox-agent/factory-client/workbench";
import { createWorkbenchRuntimeClient } from "@workbench-runtime";
import { frontendClientMode } from "./env";
export { resolveRepoRouteHandoffId } from "./workbench-routing";
export { resolveRepoRouteTaskId } from "./workbench-routing";
const workbenchClientCache = new Map<string, HandoffWorkbenchClient>();
const workbenchClientCache = new Map<string, TaskWorkbenchClient>();
export function getHandoffWorkbenchClient(workspaceId: string): HandoffWorkbenchClient {
export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient {
const cacheKey = `${frontendClientMode}:${workspaceId}`;
const existing = workbenchClientCache.get(cacheKey);
if (existing) {