Rename Factory to Foundry

This commit is contained in:
Nathan Flurry 2026-03-10 22:01:39 -07:00
parent 0a8fda040b
commit 324de36577
256 changed files with 605 additions and 603 deletions

View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<script src="https://unpkg.com/react-scan/dist/auto.global.js" crossorigin="anonymous"></script>
<script type="module">
if (import.meta.env.DEV) {
import("react-grab");
import("@react-grab/mcp/client");
}
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sandbox Agent Foundry</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,34 @@
{
"name": "@sandbox-agent/foundry-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@sandbox-agent/foundry-client": "workspace:*",
"@sandbox-agent/foundry-frontend-errors": "workspace:*",
"@sandbox-agent/foundry-shared": "workspace:*",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-router": "^1.132.23",
"baseui": "^16.1.1",
"lucide-react": "^0.542.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"styletron-engine-atomic": "^1.6.2",
"styletron-react": "^6.1.1"
},
"devDependencies": {
"@react-grab/mcp": "^0.1.13",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.3",
"react-grab": "^0.1.13",
"tsup": "^8.5.0",
"vite": "^7.1.3"
}
}

View file

@ -0,0 +1,502 @@
import { useEffect, useSyncExternalStore } from "react";
import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client";
import { type MockBillingPlanId } from "@sandbox-agent/foundry-client";
import {
Navigate,
Outlet,
createRootRoute,
createRoute,
createRouter,
useNavigate,
useRouterState,
} from "@tanstack/react-router";
import { MockLayout } from "../components/mock-layout";
import {
MockHostedCheckoutPage,
MockOrganizationBillingPage,
MockOrganizationSelectorPage,
MockOrganizationSettingsPage,
MockSignInPage,
} from "../components/mock-onboarding";
import { defaultWorkspaceId } from "../lib/env";
import {
activeMockOrganization,
activeMockUser,
getMockOrganizationById,
isAppSnapshotBootstrapping,
eligibleOrganizations,
useMockAppClient,
useMockAppSnapshot,
} from "../lib/mock-app";
import { getTaskWorkbenchClient, resolveRepoRouteTaskId } from "../lib/workbench";
const rootRoute = createRootRoute({
component: RootLayout,
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: IndexRoute,
});
const signInRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/signin",
component: SignInRoute,
});
const organizationsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/organizations",
component: OrganizationsRoute,
});
const organizationSettingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/organizations/$organizationId/settings",
component: OrganizationSettingsRoute,
});
const organizationBillingRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/organizations/$organizationId/billing",
component: OrganizationBillingRoute,
});
const organizationCheckoutRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/organizations/$organizationId/checkout/$planId",
component: OrganizationCheckoutRoute,
});
const workspaceRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/workspaces/$workspaceId",
component: WorkspaceLayoutRoute,
});
const workspaceIndexRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "/",
component: WorkspaceRoute,
});
const taskRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "tasks/$taskId",
validateSearch: (search: Record<string, unknown>) => ({
sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined,
}),
component: TaskRoute,
});
const repoRoute = createRoute({
getParentRoute: () => workspaceRoute,
path: "repos/$repoId",
component: RepoRoute,
});
const routeTree = rootRoute.addChildren([
indexRoute,
signInRoute,
organizationsRoute,
organizationSettingsRoute,
organizationBillingRoute,
organizationCheckoutRoute,
workspaceRoute.addChildren([workspaceIndexRoute, taskRoute, repoRoute]),
]);
export const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function WorkspaceLayoutRoute() {
return <Outlet />;
}
function IndexRoute() {
const snapshot = useMockAppSnapshot();
return <NavigateToMockHome snapshot={snapshot} replace />;
}
function SignInRoute() {
const snapshot = useMockAppSnapshot();
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_in") {
return <NavigateToMockHome snapshot={snapshot} replace />;
}
return <MockSignInPage />;
}
function OrganizationsRoute() {
const snapshot = useMockAppSnapshot();
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
}
return <MockOrganizationSelectorPage />;
}
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 />;
}
if (!organization) {
return <Navigate to="/organizations" replace />;
}
return <MockOrganizationSettingsPage organization={organization} />;
}
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 />;
}
if (!organization) {
return <Navigate to="/organizations" replace />;
}
return <MockOrganizationBillingPage organization={organization} />;
}
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 />;
}
if (!organization) {
return <Navigate to="/organizations" replace />;
}
if (!isMockBillingPlanId(planId)) {
return (
<Navigate
to="/organizations/$organizationId/billing"
params={{ organizationId }}
replace
/>
);
}
return <MockHostedCheckoutPage organization={organization} planId={planId} />;
}
function WorkspaceRoute() {
const { workspaceId } = workspaceRoute.useParams();
return (
<MockWorkspaceGate workspaceId={workspaceId}>
<WorkspaceView workspaceId={workspaceId} selectedTaskId={null} selectedSessionId={null} />
</MockWorkspaceGate>
);
}
function TaskRoute() {
const { workspaceId, taskId } = taskRoute.useParams();
const { sessionId } = taskRoute.useSearch();
return (
<MockWorkspaceGate workspaceId={workspaceId}>
<WorkspaceView workspaceId={workspaceId} selectedTaskId={taskId} selectedSessionId={sessionId ?? null} />
</MockWorkspaceGate>
);
}
function RepoRoute() {
const { workspaceId, repoId } = repoRoute.useParams();
return (
<MockWorkspaceGate workspaceId={workspaceId}>
<RepoRouteInner workspaceId={workspaceId} repoId={repoId} />
</MockWorkspaceGate>
);
}
function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) {
const client = getTaskWorkbenchClient(workspaceId);
const snapshot = useSyncExternalStore(
client.subscribe.bind(client),
client.getSnapshot.bind(client),
client.getSnapshot.bind(client),
);
useEffect(() => {
setFrontendErrorContext({
workspaceId,
taskId: undefined,
repoId,
});
}, [repoId, workspaceId]);
const activeTaskId = resolveRepoRouteTaskId(snapshot, repoId);
if (!activeTaskId) {
return (
<Navigate
to="/workspaces/$workspaceId"
params={{ workspaceId }}
replace
/>
);
}
return (
<Navigate
to="/workspaces/$workspaceId/tasks/$taskId"
params={{
workspaceId,
taskId: activeTaskId,
}}
search={{ sessionId: undefined }}
replace
/>
);
}
function WorkspaceView({
workspaceId,
selectedTaskId,
selectedSessionId,
}: {
workspaceId: string;
selectedTaskId: string | null;
selectedSessionId: string | null;
}) {
const appClient = useMockAppClient();
const client = getTaskWorkbenchClient(workspaceId);
const navigate = useNavigate();
const snapshot = useMockAppSnapshot();
const organization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
useEffect(() => {
setFrontendErrorContext({
workspaceId,
taskId: selectedTaskId ?? undefined,
repoId: undefined,
});
}, [selectedTaskId, workspaceId]);
return (
<MockLayout
client={client}
workspaceId={workspaceId}
selectedTaskId={selectedTaskId}
selectedSessionId={selectedSessionId}
sidebarTitle={organization?.settings.displayName}
sidebarSubtitle={
organization
? `${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
? [
{
label: "Switch org",
onClick: () => void navigate({ to: "/organizations" }),
},
{
label: "Settings",
onClick: () =>
void navigate({
to: "/organizations/$organizationId/settings",
params: { organizationId: organization.id },
}),
},
{
label: "Billing",
onClick: () =>
void navigate({
to: "/organizations/$organizationId/billing",
params: { organizationId: organization.id },
}),
},
]
: undefined
}
/>
);
}
function MockWorkspaceGate({
workspaceId,
children,
}: {
workspaceId: string;
children: React.ReactNode;
}) {
const snapshot = useMockAppSnapshot();
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
}
const activeOrganization = activeMockOrganization(snapshot);
const workspaceOrganization = eligibleOrganizations(snapshot).find((candidate) => candidate.workspaceId === workspaceId) ?? null;
if (!workspaceOrganization) {
return <NavigateToMockHome snapshot={snapshot} replace />;
}
if (!activeOrganization || activeOrganization.id !== workspaceOrganization.id) {
return <Navigate to="/organizations" replace />;
}
return <>{children}</>;
}
function NavigateToMockHome({
snapshot,
replace = false,
}: {
snapshot: ReturnType<typeof useMockAppSnapshot>;
replace?: boolean;
}) {
if (isAppSnapshotBootstrapping(snapshot)) {
return <AppBootstrapPending />;
}
const activeOrganization = activeMockOrganization(snapshot);
const organizations = eligibleOrganizations(snapshot);
const targetOrganization =
activeOrganization ?? (organizations.length === 1 ? organizations[0] ?? null : null);
if (snapshot.auth.status === "signed_out" || !activeMockUser(snapshot)) {
return <Navigate to="/signin" replace={replace} />;
}
if (!targetOrganization) {
return snapshot.users.length === 0 ? (
<Navigate
to="/workspaces/$workspaceId"
params={{ workspaceId: defaultWorkspaceId }}
replace={replace}
/>
) : (
<Navigate to="/organizations" replace={replace} />
);
}
return (
<Navigate
to="/workspaces/$workspaceId"
params={{ workspaceId: targetOrganization.workspaceId }}
replace={replace}
/>
);
}
function useGuardedMockOrganization(organizationId: string) {
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
if (!user) {
return null;
}
const organization = getMockOrganizationById(snapshot, organizationId);
if (!organization) {
return null;
}
return user.eligibleOrganizationIds.includes(organization.id) ? organization : null;
}
function isMockBillingPlanId(planId: string): planId is MockBillingPlanId {
return planId === "free" || planId === "team";
}
function RootLayout() {
return (
<>
<RouteContextSync />
<Outlet />
</>
);
}
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 Foundry state</div>
<div style={{ marginTop: "12px", color: "#d4d4d8", lineHeight: 1.6 }}>
Applying the returned app session and loading your organizations before routing deeper into Foundry.
</div>
</div>
</div>
);
}
function RouteContextSync() {
const location = useRouterState({
select: (state) => state.location,
});
useEffect(() => {
setFrontendErrorContext({
route: `${location.pathname}${location.search}${location.hash}`,
});
}, [location.hash, location.pathname, location.search]);
return null;
}

View file

@ -0,0 +1,18 @@
import { createDarkTheme, type Theme } from "baseui";
export const appTheme: Theme = createDarkTheme({
colors: {
primary: "#e4e4e7", // zinc-200
accent: "#ff4f00", // orange accent (inspector)
backgroundPrimary: "#000000", // pure black (inspector --bg)
backgroundSecondary: "#0a0a0b", // near-black panels (inspector --bg-panel)
backgroundTertiary: "#0a0a0b", // same as panel (border provides separation)
backgroundInversePrimary: "#fafafa",
contentPrimary: "#ffffff", // white (inspector --text)
contentSecondary: "#a1a1aa", // zinc-400 (inspector --muted)
contentTertiary: "#71717a", // zinc-500
contentInversePrimary: "#000000",
borderOpaque: "rgba(255, 255, 255, 0.18)", // inspector --border
borderTransparent: "rgba(255, 255, 255, 0.14)", // inspector --border-2
},
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,77 @@
import { memo, useMemo } from "react";
import { FileCode, Plus } from "lucide-react";
import { ScrollBody } from "./ui";
import { parseDiffLines, type FileChange } from "./view-model";
export const DiffContent = memo(function DiffContent({
filePath,
file,
diff,
onAddAttachment,
}: {
filePath: string;
file?: FileChange;
diff?: string;
onAddAttachment?: (filePath: string, lineNumber: number, lineContent: string) => void;
}) {
const diffLines = useMemo(() => (diff ? parseDiffLines(diff) : []), [diff]);
return (
<>
<div className="mock-diff-header">
<FileCode size={14} color="#71717a" />
<div className="mock-diff-path">{filePath}</div>
{file ? (
<div className="mock-diff-stats">
<span className="mock-diff-added">+{file.added}</span>
<span className="mock-diff-removed">&minus;{file.removed}</span>
</div>
) : null}
</div>
<ScrollBody>
{diff ? (
<div className="mock-diff-body">
{diffLines.map((line) => {
const isHunk = line.kind === "hunk";
return (
<div
key={`${line.lineNumber}-${line.text}`}
className="mock-diff-row"
data-kind={line.kind}
style={!isHunk && onAddAttachment ? { cursor: "pointer" } : undefined}
onClick={!isHunk && onAddAttachment ? () => onAddAttachment(filePath, line.lineNumber, line.text) : undefined}
>
<div className="mock-diff-gutter">
{!isHunk && onAddAttachment ? (
<button
type="button"
aria-label={`Attach line ${line.lineNumber}`}
tabIndex={-1}
className="mock-diff-attach-button"
onClick={(event) => {
event.stopPropagation();
onAddAttachment(filePath, line.lineNumber, line.text);
}}
>
<Plus size={13} />
</button>
) : null}
<span className="mock-diff-line-number">{isHunk ? "" : line.lineNumber}</span>
</div>
<div data-selectable className="mock-diff-line-text">
{line.text}
</div>
</div>
);
})}
</div>
) : (
<div className="mock-diff-empty">
<div className="mock-diff-empty-copy">No diff data available for this file</div>
</div>
)}
</ScrollBody>
</>
);
});

View file

@ -0,0 +1,134 @@
import { memo, useEffect, useState } from "react";
import { useStyletron } from "baseui";
import { LabelXSmall } from "baseui/typography";
import { formatMessageTimestamp, type HistoryEvent } from "./view-model";
export const HistoryMinimap = memo(function HistoryMinimap({
events,
onSelect,
}: {
events: HistoryEvent[];
onSelect: (event: HistoryEvent) => void;
}) {
const [css, theme] = useStyletron();
const [open, setOpen] = useState(false);
const [activeEventId, setActiveEventId] = useState<string | null>(events[events.length - 1]?.id ?? null);
useEffect(() => {
if (!events.some((event) => event.id === activeEventId)) {
setActiveEventId(events[events.length - 1]?.id ?? null);
}
}, [activeEventId, events]);
if (events.length === 0) {
return null;
}
return (
<div
className={css({
position: "absolute",
top: "20px",
right: "16px",
zIndex: 3,
display: "flex",
alignItems: "flex-start",
gap: "12px",
})}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
{open ? (
<div
className={css({
width: "220px",
maxHeight: "320px",
overflowY: "auto",
})}
>
<div className={css({ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "6px" })}>
<LabelXSmall color={theme.colors.contentTertiary} $style={{ letterSpacing: "0.08em", textTransform: "uppercase" }}>
Task Events
</LabelXSmall>
<LabelXSmall color={theme.colors.contentTertiary}>{events.length}</LabelXSmall>
</div>
<div className={css({ display: "flex", flexDirection: "column", gap: "6px" })}>
{events.map((event) => {
const isActive = event.id === activeEventId;
return (
<button
key={event.id}
type="button"
onMouseEnter={() => setActiveEventId(event.id)}
onFocus={() => setActiveEventId(event.id)}
onClick={() => onSelect(event)}
className={css({
all: "unset",
display: "grid",
gridTemplateColumns: "1fr auto",
gap: "10px",
alignItems: "center",
padding: "9px 10px",
borderRadius: "12px",
cursor: "pointer",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.08)" : "transparent",
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "background 160ms ease, color 160ms ease",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.08)",
color: theme.colors.contentPrimary,
},
})}
>
<div className={css({ minWidth: 0, display: "flex", flexDirection: "column", gap: "4px" })}>
<div
className={css({
fontSize: "12px",
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{event.preview}
</div>
<LabelXSmall color={theme.colors.contentTertiary}>{event.sessionName}</LabelXSmall>
</div>
<LabelXSmall color={theme.colors.contentTertiary}>{formatMessageTimestamp(event.createdAtMs)}</LabelXSmall>
</button>
);
})}
</div>
</div>
) : null}
<div
className={css({
width: "18px",
padding: "4px 0",
display: "flex",
flexDirection: "column",
gap: "5px",
alignItems: "stretch",
})}
>
{events.map((event) => {
const isActive = event.id === activeEventId;
return (
<div
key={event.id}
className={css({
height: "3px",
borderRadius: "999px",
backgroundColor: isActive ? "#ff4f00" : "rgba(255, 255, 255, 0.22)",
opacity: isActive ? 1 : 0.75,
transition: "background 160ms ease, opacity 160ms ease",
})}
/>
);
})}
</div>
</div>
);
});

View file

@ -0,0 +1,217 @@
import { memo, type MutableRefObject, type Ref } from "react";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import { Copy } from "lucide-react";
import { HistoryMinimap } from "./history-minimap";
import { SkeletonBlock, SkeletonLine } from "./skeleton";
import { SpinnerDot } from "./ui";
import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model";
export const MessageList = memo(function MessageList({
tab,
scrollRef,
messageRefs,
historyEvents,
onSelectHistoryEvent,
copiedMessageId,
onCopyMessage,
thinkingTimerLabel,
}: {
tab: AgentTab | null | undefined;
scrollRef: Ref<HTMLDivElement>;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
historyEvents: HistoryEvent[];
onSelectHistoryEvent: (event: HistoryEvent) => void;
copiedMessageId: string | null;
onCopyMessage: (message: Message) => void;
thinkingTimerLabel: string | null;
}) {
const [css, theme] = useStyletron();
const messages = buildDisplayMessages(tab);
return (
<>
{historyEvents.length > 0 ? <HistoryMinimap events={historyEvents} onSelect={onSelectHistoryEvent} /> : null}
<div
ref={scrollRef}
className={css({
padding: "16px 220px 16px 44px",
display: "flex",
flexDirection: "column",
gap: "12px",
flex: 1,
minHeight: 0,
overflowY: "auto",
})}
>
{tab && messages.length === 0 ? (
tab.created && tab.status === "running" ? (
/* New tab that's loading — show message skeleton */
<div
className={css({
display: "flex",
flexDirection: "column",
gap: "12px",
flex: 1,
})}
>
<div className={css({ display: "flex", justifyContent: "flex-end" })}>
<SkeletonBlock width={200} height={44} borderRadius={16} />
</div>
<div className={css({ display: "flex", justifyContent: "flex-start" })}>
<SkeletonBlock width={280} height={64} borderRadius={16} />
</div>
</div>
) : (
<div
className={css({
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
flex: 1,
minHeight: "200px",
gap: "8px",
})}
>
<LabelSmall color={theme.colors.contentTertiary}>
{!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"}
</LabelSmall>
</div>
)
) : null}
{messages.map((message) => {
const isUser = message.sender === "client";
const isCopied = copiedMessageId === message.id;
const messageTimestamp = formatMessageTimestamp(message.createdAtMs);
const displayFooter = isUser
? messageTimestamp
: message.durationMs
? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}`
: null;
return (
<div
key={message.id}
ref={(node) => {
if (node) {
messageRefs.current.set(message.id, node);
} else {
messageRefs.current.delete(message.id);
}
}}
className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })}
>
<div
className={css({
maxWidth: "80%",
display: "flex",
flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start",
gap: "6px",
})}
>
<div
className={css({
maxWidth: "100%",
padding: "12px 16px",
borderTopLeftRadius: "16px",
borderTopRightRadius: "16px",
...(isUser
? {
backgroundColor: "#ffffff",
color: "#000000",
borderBottomLeftRadius: "16px",
borderBottomRightRadius: "4px",
}
: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
color: "#e4e4e7",
borderBottomLeftRadius: "4px",
borderBottomRightRadius: "16px",
}),
})}
>
<div
data-selectable
className={css({
fontSize: "13px",
lineHeight: "1.6",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
})}
>
{message.text}
</div>
</div>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "10px",
justifyContent: isUser ? "flex-end" : "flex-start",
minHeight: "16px",
paddingLeft: isUser ? undefined : "2px",
})}
>
{displayFooter ? (
<LabelXSmall
color={theme.colors.contentTertiary}
$style={{ fontFamily: '"IBM Plex Mono", monospace', letterSpacing: "0.01em" }}
>
{displayFooter}
</LabelXSmall>
) : null}
<button
type="button"
data-copy-action="true"
onClick={() => onCopyMessage(message)}
className={css({
all: "unset",
display: "inline-flex",
alignItems: "center",
gap: "5px",
fontSize: "11px",
cursor: "pointer",
color: isCopied ? theme.colors.contentPrimary : theme.colors.contentSecondary,
transition: "color 160ms ease",
":hover": { color: theme.colors.contentPrimary },
})}
>
<Copy size={11} />
{isCopied ? "Copied" : "Copy"}
</button>
</div>
</div>
</div>
);
})}
{tab && tab.status === "running" && messages.length > 0 ? (
<div className={css({ display: "flex", alignItems: "center", gap: "8px", padding: "4px 0" })}>
<SpinnerDot size={12} />
<LabelXSmall color="#ff4f00" $style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<span>Agent is thinking</span>
{thinkingTimerLabel ? (
<span
className={css({
padding: "2px 7px",
borderRadius: "999px",
backgroundColor: "rgba(255, 79, 0, 0.12)",
border: "1px solid rgba(255, 79, 0, 0.2)",
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "10px",
letterSpacing: "0.04em",
})}
>
{thinkingTimerLabel}
</span>
) : null}
</LabelXSmall>
</div>
) : null}
</div>
</>
);
});

View file

@ -0,0 +1,163 @@
import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { StatefulPopover, PLACEMENT } from "baseui/popover";
import { ChevronDown, Star } from "lucide-react";
import { AgentIcon } from "./ui";
import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model";
const ModelPickerContent = memo(function ModelPickerContent({
value,
defaultModel,
onChange,
onSetDefault,
close,
}: {
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
onSetDefault: (id: ModelId) => void;
close: () => void;
}) {
const [css, theme] = useStyletron();
const [hoveredId, setHoveredId] = useState<ModelId | null>(null);
return (
<div className={css({ minWidth: "200px", padding: "4px 0" })}>
{MODEL_GROUPS.map((group) => (
<div key={group.provider}>
<div
className={css({
padding: "6px 12px",
fontSize: "10px",
fontWeight: 700,
color: theme.colors.contentTertiary,
textTransform: "uppercase",
letterSpacing: "0.05em",
})}
>
{group.provider}
</div>
{group.models.map((model) => {
const isActive = model.id === value;
const isDefault = model.id === defaultModel;
const isHovered = model.id === hoveredId;
const agent = providerAgent(group.provider);
return (
<div
key={model.id}
onMouseEnter={() => setHoveredId(model.id)}
onMouseLeave={() => setHoveredId(null)}
onClick={() => {
onChange(model.id);
close();
}}
className={css({
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 12px",
cursor: "pointer",
fontSize: "12px",
fontWeight: isActive ? 600 : 400,
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
>
<AgentIcon agent={agent} size={12} />
<span className={css({ flex: 1 })}>{model.label}</span>
{isDefault ? <Star size={11} fill="#ff4f00" color="#ff4f00" /> : null}
{!isDefault && isHovered ? (
<Star
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", ":hover": { color: "#ff4f00" } })}
onClick={(event) => {
event.stopPropagation();
onSetDefault(model.id);
}}
/>
) : null}
</div>
);
})}
</div>
))}
</div>
);
});
export const ModelPicker = memo(function ModelPicker({
value,
defaultModel,
onChange,
onSetDefault,
}: {
value: ModelId;
defaultModel: ModelId;
onChange: (id: ModelId) => void;
onSetDefault: (id: ModelId) => void;
}) {
const [css, theme] = useStyletron();
return (
<StatefulPopover
placement={PLACEMENT.topLeft}
triggerType="click"
autoFocus={false}
overrides={{
Body: {
style: {
backgroundColor: "#000000",
borderTopLeftRadius: "8px",
borderTopRightRadius: "8px",
borderBottomLeftRadius: "8px",
borderBottomRightRadius: "8px",
border: `1px solid ${theme.colors.borderOpaque}`,
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)",
zIndex: 100,
},
},
Inner: {
style: {
backgroundColor: "transparent",
padding: "0",
},
},
}}
content={({ close }) => (
<ModelPickerContent
value={value}
defaultModel={defaultModel}
onChange={onChange}
onSetDefault={onSetDefault}
close={close}
/>
)}
>
<div className={css({ display: "inline-flex" })}>
<button
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "4px",
cursor: "pointer",
padding: "4px 8px",
borderRadius: "6px",
fontSize: "12px",
fontWeight: 500,
color: theme.colors.contentSecondary,
backgroundColor: theme.colors.backgroundTertiary,
border: `1px solid ${theme.colors.borderOpaque}`,
":hover": { color: theme.colors.contentPrimary },
})}
>
{modelLabel(value)}
<ChevronDown size={11} />
</button>
</div>
</StatefulPopover>
);
});

View file

@ -0,0 +1,181 @@
import { memo, type Ref } from "react";
import { useStyletron } from "baseui";
import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react";
import { ModelPicker } from "./model-picker";
import { PROMPT_TEXTAREA_MIN_HEIGHT, PROMPT_TEXTAREA_MAX_HEIGHT } from "./ui";
import { fileName, type LineAttachment, type ModelId } from "./view-model";
export const PromptComposer = memo(function PromptComposer({
draft,
textareaRef,
placeholder,
attachments,
defaultModel,
model,
isRunning,
onDraftChange,
onSend,
onStop,
onRemoveAttachment,
onChangeModel,
onSetDefaultModel,
}: {
draft: string;
textareaRef: Ref<HTMLTextAreaElement>;
placeholder: string;
attachments: LineAttachment[];
defaultModel: ModelId;
model: ModelId;
isRunning: boolean;
onDraftChange: (value: string) => void;
onSend: () => void;
onStop: () => void;
onRemoveAttachment: (id: string) => void;
onChangeModel: (model: ModelId) => void;
onSetDefaultModel: (model: ModelId) => void;
}) {
const [css, theme] = useStyletron();
return (
<div
className={css({
padding: "12px 16px",
borderTop: `1px solid ${theme.colors.borderOpaque}`,
flexShrink: 0,
display: "flex",
flexDirection: "column",
gap: "8px",
})}
>
{attachments.length > 0 ? (
<div className={css({ display: "flex", flexWrap: "wrap", gap: "4px" })}>
{attachments.map((attachment) => (
<div
key={attachment.id}
className={css({
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "4px",
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: "1px solid rgba(255, 255, 255, 0.14)",
fontSize: "11px",
fontFamily: '"IBM Plex Mono", monospace',
color: theme.colors.contentSecondary,
})}
>
<FileCode size={11} />
<span>
{fileName(attachment.filePath)}:{attachment.lineNumber}
</span>
<X
size={10}
className={css({ cursor: "pointer", opacity: 0.6, ":hover": { opacity: 1 } })}
onClick={() => onRemoveAttachment(attachment.id)}
/>
</div>
))}
</div>
) : null}
<div
className={css({
position: "relative",
backgroundColor: "rgba(255, 255, 255, 0.06)",
border: `1px solid ${theme.colors.borderOpaque}`,
borderRadius: "16px",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
transition: "border-color 200ms ease",
":focus-within": { borderColor: "rgba(255, 255, 255, 0.3)" },
})}
>
<textarea
ref={textareaRef}
value={draft}
onChange={(event) => onDraftChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder={placeholder}
rows={1}
className={css({
display: "block",
width: "100%",
minHeight: `${PROMPT_TEXTAREA_MIN_HEIGHT}px`,
padding: "12px 58px 12px 14px",
background: "transparent",
border: "none",
borderRadius: "16px",
color: theme.colors.contentPrimary,
fontSize: "13px",
fontFamily: "inherit",
resize: "none",
outline: "none",
lineHeight: "1.4",
maxHeight: `${PROMPT_TEXTAREA_MAX_HEIGHT}px`,
boxSizing: "border-box",
overflowY: "hidden",
"::placeholder": { color: theme.colors.contentSecondary },
})}
/>
{isRunning ? (
<button
onClick={onStop}
className={css({
all: "unset",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(255, 255, 255, 0.06)",
color: theme.colors.contentPrimary,
transition: "background 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.12)" },
})}
>
<Square size={16} />
</button>
) : (
<button
onClick={onSend}
className={css({
all: "unset",
width: "32px",
height: "32px",
borderRadius: "6px",
cursor: "pointer",
position: "absolute",
right: "12px",
bottom: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#ff4f00",
color: "#ffffff",
transition: "background 200ms ease",
":hover": { backgroundColor: "#ff6a00" },
})}
>
<ArrowUpFromLine size={16} />
</button>
)}
</div>
<ModelPicker
value={model}
defaultModel={defaultModel}
onChange={onChangeModel}
onSetDefault={onSetDefaultModel}
/>
</div>
);
});

View file

@ -0,0 +1,443 @@
import { memo, useCallback, useMemo, useState, type MouseEvent } from "react";
import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography";
import {
Archive,
ArrowUpFromLine,
ChevronRight,
FileCode,
FilePlus,
FileX,
FolderOpen,
GitPullRequest,
} from "lucide-react";
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { type FileTreeNode, type Task, 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,
onSelectFile,
onFileContextMenu,
changedPaths,
}: {
nodes: FileTreeNode[];
depth: number;
onSelectFile: (path: string) => void;
onFileContextMenu: (event: MouseEvent, path: string) => void;
changedPaths: Set<string>;
}) {
const [css, theme] = useStyletron();
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
return (
<>
{nodes.map((node) => {
const isCollapsed = collapsed.has(node.path);
const isChanged = changedPaths.has(node.path);
return (
<div key={node.path}>
<div
onClick={() => {
if (node.isDir) {
setCollapsed((current) => {
const next = new Set(current);
if (next.has(node.path)) {
next.delete(node.path);
} else {
next.add(node.path);
}
return next;
});
return;
}
onSelectFile(node.path);
}}
onContextMenu={node.isDir ? undefined : (event) => onFileContextMenu(event, node.path)}
className={css({
display: "flex",
alignItems: "center",
gap: "4px",
padding: "3px 10px",
paddingLeft: `${10 + depth * 16}px`,
cursor: "pointer",
fontSize: "12px",
fontFamily: '"IBM Plex Mono", monospace',
color: isChanged ? theme.colors.contentPrimary : theme.colors.contentTertiary,
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
>
{node.isDir ? (
<>
<ChevronRight
size={12}
className={css({
transform: isCollapsed ? undefined : "rotate(90deg)",
transition: "transform 0.1s",
})}
/>
<FolderOpen size={13} />
</>
) : (
<FileCode size={13} color={isChanged ? theme.colors.contentPrimary : undefined} style={{ marginLeft: "16px" }} />
)}
<span>{node.name}</span>
</div>
{node.isDir && !isCollapsed && node.children ? (
<FileTree
nodes={node.children}
depth={depth + 1}
onSelectFile={onSelectFile}
onFileContextMenu={onFileContextMenu}
changedPaths={changedPaths}
/>
) : null}
</div>
);
})}
</>
);
});
export const RightSidebar = memo(function RightSidebar({
task,
activeTabId,
onOpenDiff,
onArchive,
onPush,
onRevertFile,
onPublishPr,
}: {
task: Task;
activeTabId: string | null;
onOpenDiff: (path: string) => void;
onArchive: () => void;
onPush: () => void;
onRevertFile: (path: string) => void;
onPublishPr: () => void;
}) {
const [css, theme] = useStyletron();
const [rightTab, setRightTab] = useState<"changes" | "files">("changes");
const contextMenu = useContextMenu();
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 =
task.pullRequest == null
? "Not published"
: `#${task.pullRequest.number} ${task.pullRequest.status === "draft" ? "Draft" : "Ready"}`;
const copyFilePath = useCallback(async (path: string) => {
try {
if (!window.navigator.clipboard) {
throw new Error("Clipboard API unavailable in mock layout");
}
await window.navigator.clipboard.writeText(path);
} catch (error) {
console.error("Failed to copy file path", error);
}
}, []);
const openFileMenu = useCallback(
(event: MouseEvent, path: string) => {
const items: ContextMenuItem[] = [];
if (changedPaths.has(path)) {
items.push({ label: "Revert", onClick: () => onRevertFile(path) });
}
items.push({ label: "Copy Path", onClick: () => void copyFilePath(path) });
contextMenu.open(event, items);
},
[changedPaths, contextMenu, copyFilePath, onRevertFile],
);
return (
<SPanel>
<PanelHeaderBar>
<div className={css({ flex: 1 })} />
{!isTerminal ? (
<div className={css({ display: "flex", alignItems: "center", gap: "4px" })}>
<button
onClick={() => {
if (pullRequestUrl) {
window.open(pullRequestUrl, "_blank", "noopener,noreferrer");
return;
}
onPublishPr();
}}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<GitPullRequest size={12} />
{pullRequestUrl ? "Open PR" : "Publish PR"}
</button>
<button
onClick={canPush ? onPush : undefined}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
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" },
})}
>
<ArrowUpFromLine size={12} /> Push
</button>
<button
onClick={onArchive}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 500,
color: "#e4e4e7",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<Archive size={12} /> Archive
</button>
</div>
) : null}
</PanelHeaderBar>
<div
className={css({
display: "flex",
alignItems: "stretch",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundSecondary,
height: "41px",
minHeight: "41px",
flexShrink: 0,
})}
>
<button
onClick={() => setRightTab("changes")}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "6px",
height: "100%",
padding: "0 16px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 600,
whiteSpace: "nowrap",
color: rightTab === "changes" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
borderBottom: `2px solid ${rightTab === "changes" ? "#ff4f00" : "transparent"}`,
marginBottom: "-1px",
transitionProperty: "color, border-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7" },
})}
>
Changes
{task.fileChanges.length > 0 ? (
<span
className={css({
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
minWidth: "16px",
height: "16px",
padding: "0 5px",
background: "#3f3f46",
color: "#a1a1aa",
fontSize: "9px",
fontWeight: 700,
borderRadius: "8px",
})}
>
{task.fileChanges.length}
</span>
) : null}
</button>
<button
onClick={() => setRightTab("files")}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
height: "100%",
padding: "0 16px",
cursor: "pointer",
fontSize: "12px",
fontWeight: 600,
whiteSpace: "nowrap",
color: rightTab === "files" ? theme.colors.contentPrimary : theme.colors.contentSecondary,
borderBottom: `2px solid ${rightTab === "files" ? "#ff4f00" : "transparent"}`,
marginBottom: "-1px",
transitionProperty: "color, border-color",
transitionDuration: "200ms",
transitionTimingFunction: "ease",
":hover": { color: "#e4e4e7" },
})}
>
All Files
</button>
</div>
<ScrollBody>
<div className={css({ padding: "12px 14px 0", display: "grid", gap: "8px" })}>
<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" })}>
{task.fileChanges.length === 0 ? (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No changes yet</LabelSmall>
</div>
) : null}
{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;
return (
<div
key={file.path}
onClick={() => onOpenDiff(file.path)}
onContextMenu={(event) => openFileMenu(event, file.path)}
className={css({
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 10px",
borderRadius: "6px",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
>
<TypeIcon size={14} color={iconColor} style={{ flexShrink: 0 }} />
<div
className={css({
flex: 1,
minWidth: 0,
fontFamily: '"IBM Plex Mono", monospace',
fontSize: "12px",
color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
})}
>
{file.path}
</div>
<div
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
flexShrink: 0,
fontSize: "11px",
fontFamily: '"IBM Plex Mono", monospace',
})}
>
<span className={css({ color: "#7ee787" })}>+{file.added}</span>
<span className={css({ color: "#ffa198" })}>-{file.removed}</span>
<span className={css({ color: iconColor, fontWeight: 600, width: "10px", textAlign: "center" })}>{file.type}</span>
</div>
</div>
);
})}
</div>
) : (
<div className={css({ padding: "6px 0" })}>
{task.fileTree.length > 0 ? (
<FileTree
nodes={task.fileTree}
depth={0}
onSelectFile={onOpenDiff}
onFileContextMenu={openFileMenu}
changedPaths={changedPaths}
/>
) : (
<div className={css({ padding: "20px 0", textAlign: "center" })}>
<LabelSmall color={theme.colors.contentTertiary}>No files yet</LabelSmall>
</div>
)}
</div>
)}
</ScrollBody>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);
});

View file

@ -0,0 +1,299 @@
import { memo, useState } from "react";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import { CloudUpload, GitPullRequestDraft, Plus } from "lucide-react";
import { SidebarSkeleton } from "./skeleton";
import { formatRelativeAge, type Task, type RepoSection } from "./view-model";
import {
ContextMenuOverlay,
TaskIndicator,
PanelHeaderBar,
SPanel,
ScrollBody,
useContextMenu,
} from "./ui";
export const Sidebar = memo(function Sidebar({
workspaceId,
repoCount,
repos,
activeId,
title,
subtitle,
actions,
onSelect,
onCreate,
onMarkUnread,
onRenameTask,
onRenameBranch,
}: {
workspaceId: string;
repoCount: number;
repos: RepoSection[];
activeId: string;
title?: string;
subtitle?: string;
actions?: Array<{
label: string;
onClick: () => void;
}>;
onSelect: (id: string) => void;
onCreate: () => void;
onMarkUnread: (id: string) => void;
onRenameTask: (id: string) => void;
onRenameBranch: (id: string) => void;
}) {
const [css, theme] = useStyletron();
const contextMenu = useContextMenu();
const [expandedRepos, setExpandedRepos] = useState<Record<string, boolean>>({});
return (
<SPanel>
<PanelHeaderBar>
<div className={css({ flex: 1, minWidth: 0 })}>
<LabelSmall color={theme.colors.contentPrimary} $style={{ fontWeight: 600, fontSize: "13px" }}>
{title ?? workspaceId}
</LabelSmall>
<LabelXSmall color={theme.colors.contentTertiary}>
{subtitle ?? `${repoCount} ${repoCount === 1 ? "repo" : "repos"}`}
</LabelXSmall>
</div>
<button
onClick={onCreate}
aria-label="Create task"
className={css({
all: "unset",
width: "24px",
height: "24px",
borderRadius: "4px",
backgroundColor: "#ff4f00",
color: "#ffffff",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
":hover": { backgroundColor: "#ff6a00" },
})}
>
<Plus size={14} />
</button>
</PanelHeaderBar>
{actions && actions.length > 0 ? (
<div
className={css({
display: "flex",
gap: "8px",
flexWrap: "wrap",
padding: "10px 14px 0",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundTertiary,
})}
>
{actions.map((action) => (
<button
key={action.label}
type="button"
onClick={action.onClick}
className={css({
border: `1px solid ${theme.colors.borderOpaque}`,
borderRadius: "999px",
backgroundColor: "rgba(255, 255, 255, 0.04)",
color: theme.colors.contentPrimary,
cursor: "pointer",
padding: "6px 10px",
fontSize: "12px",
fontWeight: 600,
marginBottom: "10px",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
})}
>
{action.label}
</button>
))}
</div>
) : null}
<ScrollBody>
{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" })}>
{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={repo.id} className={css({ display: "flex", flexDirection: "column", gap: "4px" })}>
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 8px 4px",
gap: "8px",
})}
>
<LabelSmall
color={theme.colors.contentSecondary}
$style={{
fontSize: "11px",
fontWeight: 700,
letterSpacing: "0.05em",
textTransform: "uppercase",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{repo.label}
</LabelSmall>
<LabelXSmall color={theme.colors.contentTertiary}>
{repo.updatedAtMs > 0 ? formatRelativeAge(repo.updatedAtMs) : "No tasks"}
</LabelXSmall>
</div>
{repo.tasks.length === 0 ? (
<div
className={css({
padding: "0 12px 10px 34px",
color: theme.colors.contentTertiary,
fontSize: "12px",
})}
>
No tasks yet
</div>
) : null}
{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={task.id}
onClick={() => onSelect(task.id)}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
])
}
className={css({
padding: "12px",
borderRadius: "8px",
border: isActive ? "1px solid rgba(255, 255, 255, 0.2)" : "1px solid transparent",
backgroundColor: isActive ? "rgba(255, 255, 255, 0.06)" : "transparent",
cursor: "pointer",
transition: "all 200ms ease",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)",
borderColor: theme.colors.borderOpaque,
},
})}
>
<div className={css({ display: "flex", alignItems: "center", gap: "8px" })}>
<div
className={css({
width: "14px",
minWidth: "14px",
height: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<TaskIndicator isRunning={isRunning} hasUnread={hasUnread} isDraft={isDraft} />
</div>
<LabelSmall
$style={{
fontWeight: 600,
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
color={isDim ? theme.colors.contentSecondary : theme.colors.contentPrimary}
>
{task.title}
</LabelSmall>
{hasDiffs ? (
<div className={css({ display: "flex", gap: "4px", flexShrink: 0 })}>
<span className={css({ fontSize: "11px", color: "#7ee787" })}>+{totalAdded}</span>
<span className={css({ fontSize: "11px", color: "#ffa198" })}>-{totalRemoved}</span>
</div>
) : null}
</div>
<div className={css({ display: "flex", alignItems: "center", marginTop: "4px", gap: "6px" })}>
<LabelXSmall
color={theme.colors.contentTertiary}
$style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flexShrink: 1,
}}
>
{task.repoName}
</LabelXSmall>
{task.pullRequest != null ? (
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
<LabelXSmall color={theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
#{task.pullRequest.number}
</LabelXSmall>
{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(task.updatedAtMs)}
</LabelXSmall>
</div>
</div>
);
})}
{hiddenCount > 0 ? (
<button
type="button"
onClick={() =>
setExpandedRepos((current) => ({
...current,
[repo.id]: true,
}))
}
className={css({
all: "unset",
padding: "8px 12px 10px 34px",
color: theme.colors.contentSecondary,
fontSize: "12px",
cursor: "pointer",
":hover": { color: theme.colors.contentPrimary },
})}
>
Show {hiddenCount} more
</button>
) : null}
</div>
);
})}
</div>
)}
</ScrollBody>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);
});

View file

@ -0,0 +1,178 @@
import { memo } from "react";
export const SkeletonLine = memo(function SkeletonLine({
width = "100%",
height = 12,
borderRadius = 4,
style,
}: {
width?: string | number;
height?: number;
borderRadius?: number;
style?: React.CSSProperties;
}) {
return (
<div
style={{
width,
height,
borderRadius,
background: "rgba(255, 255, 255, 0.06)",
backgroundImage:
"linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.04) 50%, rgba(255,255,255,0) 100%)",
backgroundSize: "200% 100%",
animation: "hf-shimmer 1.5s ease-in-out infinite",
flexShrink: 0,
...style,
}}
/>
);
});
export const SkeletonCircle = memo(function SkeletonCircle({
size = 14,
style,
}: {
size?: number;
style?: React.CSSProperties;
}) {
return <SkeletonLine width={size} height={size} borderRadius={size} style={style} />;
});
export const SkeletonBlock = memo(function SkeletonBlock({
width = "100%",
height = 60,
borderRadius = 8,
style,
}: {
width?: string | number;
height?: number;
borderRadius?: number;
style?: React.CSSProperties;
}) {
return <SkeletonLine width={width} height={height} borderRadius={borderRadius} style={style} />;
});
/** Sidebar skeleton: header + list of task placeholders */
export const SidebarSkeleton = memo(function SidebarSkeleton() {
return (
<div style={{ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" }}>
{/* 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>
{/* Task item skeletons */}
{[0, 1, 2, 3].map((i) => (
<div
key={i}
style={{
padding: "12px",
borderRadius: "8px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<SkeletonCircle size={14} />
<SkeletonLine width={`${65 - i * 10}%`} height={13} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: "6px", paddingLeft: "22px" }}>
<SkeletonLine width="30%" height={10} />
<SkeletonLine width={32} height={10} style={{ marginLeft: "auto" }} />
</div>
</div>
))}
</div>
);
});
/** Transcript area skeleton: tab strip + message bubbles */
export const TranscriptSkeleton = memo(function TranscriptSkeleton() {
return (
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
{/* Tab strip skeleton */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
height: "41px",
minHeight: "41px",
padding: "0 14px",
borderBottom: "1px solid rgba(255, 255, 255, 0.12)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
<SkeletonCircle size={8} />
<SkeletonLine width={64} height={11} />
</div>
</div>
{/* Message skeletons */}
<div style={{ padding: "16px 220px 16px 44px", display: "flex", flexDirection: "column", gap: "12px", flex: 1 }}>
{/* User message */}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div style={{ maxWidth: "60%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-end" }}>
<SkeletonBlock width={240} height={48} borderRadius={16} />
<SkeletonLine width={60} height={9} />
</div>
</div>
{/* Agent message */}
<div style={{ display: "flex", justifyContent: "flex-start" }}>
<div style={{ maxWidth: "70%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-start" }}>
<SkeletonBlock width={320} height={72} borderRadius={16} />
<SkeletonLine width={100} height={9} />
</div>
</div>
{/* Another user message */}
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<div style={{ maxWidth: "60%", display: "flex", flexDirection: "column", gap: "6px", alignItems: "flex-end" }}>
<SkeletonBlock width={180} height={40} borderRadius={16} />
</div>
</div>
</div>
</div>
);
});
/** Right sidebar skeleton: status cards + file list */
export const RightSidebarSkeleton = memo(function RightSidebarSkeleton() {
return (
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
{/* Tab bar skeleton */}
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
height: "41px",
minHeight: "41px",
padding: "0 16px",
borderBottom: "1px solid rgba(255, 255, 255, 0.12)",
}}
>
<SkeletonLine width={56} height={11} />
<SkeletonLine width={48} height={11} />
</div>
{/* Status cards */}
<div style={{ padding: "12px 14px 0", display: "grid", gap: "8px" }}>
<SkeletonBlock height={52} />
<SkeletonBlock height={52} />
</div>
{/* File changes */}
<div style={{ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "4px" }}>
{[0, 1, 2].map((i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: "8px", padding: "6px 10px" }}>
<SkeletonCircle size={14} />
<SkeletonLine width={`${60 - i * 12}%`} height={12} />
<div style={{ display: "flex", gap: "6px", marginLeft: "auto" }}>
<SkeletonLine width={24} height={11} />
<SkeletonLine width={24} height={11} />
</div>
</div>
))}
</div>
</div>
);
});

View file

@ -0,0 +1,213 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelXSmall } from "baseui/typography";
import { FileCode, Plus, X } from "lucide-react";
import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui";
import { diffTabId, fileName, type Task } from "./view-model";
export const TabStrip = memo(function TabStrip({
task,
activeTabId,
openDiffs,
editingSessionTabId,
editingSessionName,
onEditingSessionNameChange,
onSwitchTab,
onStartRenamingTab,
onCommitSessionRename,
onCancelSessionRename,
onSetTabUnread,
onCloseTab,
onCloseDiffTab,
onAddTab,
}: {
task: Task;
activeTabId: string | null;
openDiffs: string[];
editingSessionTabId: string | null;
editingSessionName: string;
onEditingSessionNameChange: (value: string) => void;
onSwitchTab: (tabId: string) => void;
onStartRenamingTab: (tabId: string) => void;
onCommitSessionRename: () => void;
onCancelSessionRename: () => void;
onSetTabUnread: (tabId: string, unread: boolean) => void;
onCloseTab: (tabId: string) => void;
onCloseDiffTab: (path: string) => void;
onAddTab: () => void;
}) {
const [css, theme] = useStyletron();
const contextMenu = useContextMenu();
return (
<>
<div
className={css({
display: "flex",
alignItems: "stretch",
borderBottom: `1px solid ${theme.colors.borderOpaque}`,
backgroundColor: theme.colors.backgroundSecondary,
height: "41px",
minHeight: "41px",
overflowX: "auto",
scrollbarWidth: "none",
flexShrink: 0,
"::-webkit-scrollbar": { display: "none" },
})}
>
{task.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
onClick={() => onSwitchTab(tab.id)}
onDoubleClick={() => onStartRenamingTab(tab.id)}
onMouseDown={(event) => {
if (event.button === 1 && task.tabs.length > 1) {
event.preventDefault();
onCloseTab(tab.id);
}
}}
onContextMenu={(event) =>
contextMenu.open(event, [
{ label: "Rename session", onClick: () => onStartRenamingTab(tab.id) },
{
label: tab.unread ? "Mark as read" : "Mark as unread",
onClick: () => onSetTabUnread(tab.id, !tab.unread),
},
...(task.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []),
])
}
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "0 14px",
borderBottom: isActive ? "2px solid #ff4f00" : "2px solid transparent",
cursor: "pointer",
transition: "color 200ms ease, border-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7" },
})}
>
<div
className={css({
width: "14px",
minWidth: "14px",
height: "14px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
})}
>
<TabAvatar tab={tab} />
</div>
{editingSessionTabId === tab.id ? (
<input
autoFocus
value={editingSessionName}
onChange={(event) => onEditingSessionNameChange(event.target.value)}
onBlur={onCommitSessionRename}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitSessionRename();
} else if (event.key === "Escape") {
onCancelSessionRename();
}
}}
className={css({
all: "unset",
minWidth: "72px",
maxWidth: "180px",
fontSize: "11px",
fontWeight: 600,
color: theme.colors.contentPrimary,
borderBottom: "1px solid rgba(255, 255, 255, 0.3)",
})}
/>
) : (
<LabelXSmall color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary} $style={{ fontWeight: 600 }}>
{tab.sessionName}
</LabelXSmall>
)}
{task.tabs.length > 1 ? (
<X
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", opacity: 0.5, ":hover": { opacity: 1 } })}
onClick={(event) => {
event.stopPropagation();
onCloseTab(tab.id);
}}
/>
) : null}
</div>
);
})}
{openDiffs.map((path) => {
const tabId = diffTabId(path);
const isActive = tabId === activeTabId;
return (
<div
key={tabId}
onClick={() => onSwitchTab(tabId)}
onMouseDown={(event) => {
if (event.button === 1) {
event.preventDefault();
onCloseDiffTab(path);
}
}}
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "0 14px",
borderBottom: "2px solid transparent",
cursor: "pointer",
transition: "color 200ms ease, border-color 200ms ease",
flexShrink: 0,
":hover": { color: "#e4e4e7" },
})}
>
<FileCode size={12} color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary} />
<LabelXSmall
color={isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary}
$style={{ fontWeight: 600, fontFamily: '"IBM Plex Mono", monospace' }}
>
{fileName(path)}
</LabelXSmall>
<X
size={11}
color={theme.colors.contentTertiary}
className={css({ cursor: "pointer", opacity: 0.5, ":hover": { opacity: 1 } })}
onClick={(event) => {
event.stopPropagation();
onCloseDiffTab(path);
}}
/>
</div>
);
})}
<div
onClick={onAddTab}
className={css({
display: "flex",
alignItems: "center",
padding: "0 10px",
cursor: "pointer",
opacity: 0.4,
":hover": { opacity: 0.7 },
flexShrink: 0,
})}
>
<Plus size={14} color={theme.colors.contentTertiary} />
</div>
</div>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</>
);
});

View file

@ -0,0 +1,139 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelSmall } from "baseui/typography";
import { MailOpen } from "lucide-react";
import { PanelHeaderBar } from "./ui";
import { type AgentTab, type Task } from "./view-model";
export const TranscriptHeader = memo(function TranscriptHeader({
task,
activeTab,
editingField,
editValue,
onEditValueChange,
onStartEditingField,
onCommitEditingField,
onCancelEditingField,
onSetActiveTabUnread,
}: {
task: Task;
activeTab: AgentTab | null | undefined;
editingField: "title" | "branch" | null;
editValue: string;
onEditValueChange: (value: string) => void;
onStartEditingField: (field: "title" | "branch", value: string) => void;
onCommitEditingField: (field: "title" | "branch") => void;
onCancelEditingField: () => void;
onSetActiveTabUnread: (unread: boolean) => void;
}) {
const [css, theme] = useStyletron();
return (
<PanelHeaderBar>
{editingField === "title" ? (
<input
autoFocus
value={editValue}
onChange={(event) => onEditValueChange(event.target.value)}
onBlur={() => onCommitEditingField("title")}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitEditingField("title");
} else if (event.key === "Escape") {
onCancelEditingField();
}
}}
className={css({
all: "unset",
fontWeight: 600,
fontSize: "14px",
color: theme.colors.contentPrimary,
borderBottom: "1px solid rgba(255, 255, 255, 0.3)",
minWidth: "80px",
maxWidth: "300px",
})}
/>
) : (
<LabelSmall
title="Rename"
color={theme.colors.contentPrimary}
$style={{ fontWeight: 600, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }}
onClick={() => onStartEditingField("title", task.title)}
>
{task.title}
</LabelSmall>
)}
{task.branch ? (
editingField === "branch" ? (
<input
autoFocus
value={editValue}
onChange={(event) => onEditValueChange(event.target.value)}
onBlur={() => onCommitEditingField("branch")}
onKeyDown={(event) => {
if (event.key === "Enter") {
onCommitEditingField("branch");
} else if (event.key === "Escape") {
onCancelEditingField();
}
}}
className={css({
all: "unset",
padding: "2px 8px",
borderRadius: "999px",
border: "1px solid rgba(255, 255, 255, 0.3)",
backgroundColor: "rgba(255, 255, 255, 0.03)",
color: "#e4e4e7",
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
minWidth: "60px",
})}
/>
) : (
<span
title="Rename"
onClick={() => onStartEditingField("branch", task.branch ?? "")}
className={css({
padding: "2px 8px",
borderRadius: "999px",
border: "1px solid rgba(255, 255, 255, 0.14)",
backgroundColor: "rgba(255, 255, 255, 0.03)",
color: "#e4e4e7",
fontSize: "11px",
whiteSpace: "nowrap",
fontFamily: '"IBM Plex Mono", monospace',
cursor: "pointer",
":hover": { borderColor: "rgba(255, 255, 255, 0.3)" },
})}
>
{task.branch}
</span>
)
) : null}
<div className={css({ flex: 1 })} />
{activeTab ? (
<button
onClick={() => onSetActiveTabUnread(!activeTab.unread)}
className={css({
all: "unset",
display: "flex",
alignItems: "center",
gap: "5px",
padding: "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
color: theme.colors.contentSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: theme.colors.contentPrimary },
})}
>
<MailOpen size={12} /> {activeTab.unread ? "Mark read" : "Mark unread"}
</button>
) : null}
</PanelHeaderBar>
);
});

View file

@ -0,0 +1,211 @@
import { memo, useCallback, useEffect, useState, type MouseEvent } from "react";
import { styled, useStyletron } from "baseui";
import { GitPullRequest, GitPullRequestDraft } from "lucide-react";
import type { AgentKind, AgentTab } from "./view-model";
export interface ContextMenuItem {
label: string;
onClick: () => void;
}
export function useContextMenu() {
const [menu, setMenu] = useState<{ x: number; y: number; items: ContextMenuItem[] } | null>(null);
useEffect(() => {
if (!menu) {
return;
}
const close = () => setMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [menu]);
const open = useCallback((event: MouseEvent, items: ContextMenuItem[]) => {
event.preventDefault();
event.stopPropagation();
setMenu({ x: event.clientX, y: event.clientY, items });
}, []);
return { menu, open, close: useCallback(() => setMenu(null), []) };
}
export const ContextMenuOverlay = memo(function ContextMenuOverlay({
menu,
onClose,
}: {
menu: { x: number; y: number; items: ContextMenuItem[] };
onClose: () => void;
}) {
const [css] = useStyletron();
return (
<div
className={css({
position: "fixed",
zIndex: 9999,
top: `${menu.y}px`,
left: `${menu.x}px`,
backgroundColor: "#1a1a1d",
border: "1px solid rgba(255, 255, 255, 0.18)",
borderRadius: "8px",
padding: "4px 0",
minWidth: "160px",
boxShadow: "0 8px 24px rgba(0, 0, 0, 0.6)",
})}
>
{menu.items.map((item, index) => (
<div
key={index}
onClick={() => {
item.onClick();
onClose();
}}
className={css({
padding: "8px 14px",
fontSize: "12px",
color: "#e4e4e7",
cursor: "pointer",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.08)" },
})}
>
{item.label}
</div>
))}
</div>
);
});
export const SpinnerDot = memo(function SpinnerDot({ size = 10 }: { size?: number }) {
return (
<div
style={{
width: size,
height: size,
borderRadius: "50%",
border: "2px solid rgba(255, 79, 0, 0.25)",
borderTopColor: "#ff4f00",
animation: "hf-spin 0.8s linear infinite",
flexShrink: 0,
}}
/>
);
});
export const UnreadDot = memo(function UnreadDot() {
return (
<div
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: "#ff4f00",
flexShrink: 0,
}}
/>
);
});
export const TaskIndicator = memo(function TaskIndicator({
isRunning,
hasUnread,
isDraft,
}: {
isRunning: boolean;
hasUnread: boolean;
isDraft: boolean;
}) {
if (isRunning) return <SpinnerDot size={8} />;
if (hasUnread) return <UnreadDot />;
if (isDraft) return <GitPullRequestDraft size={12} color="#a1a1aa" />;
return <GitPullRequest size={12} color="#7ee787" />;
});
const ClaudeIcon = memo(function ClaudeIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 1200 1200" fill="none" style={{ flexShrink: 0 }}>
<path fill="#D97757" d="M 233.96 800.21 L 468.64 668.54 L 472.59 657.1 L 468.64 650.74 L 457.21 650.74 L 417.99 648.32 L 283.89 644.7 L 167.6 639.87 L 54.93 633.83 L 26.58 627.79 L 0 592.75 L 2.74 575.28 L 26.58 559.25 L 60.72 562.23 L 136.19 567.38 L 249.42 575.19 L 331.57 580.03 L 453.26 592.67 L 472.59 592.67 L 475.33 584.86 L 468.72 580.03 L 463.57 575.19 L 346.39 495.79 L 219.54 411.87 L 153.1 363.54 L 117.18 339.06 L 99.06 316.11 L 91.25 266.01 L 123.87 230.09 L 167.68 233.07 L 178.87 236.05 L 223.25 270.2 L 318.04 343.57 L 441.83 434.74 L 459.95 449.8 L 467.19 444.64 L 468.08 441.02 L 459.95 427.41 L 392.62 305.72 L 320.78 181.93 L 288.81 130.63 L 280.35 99.87 C 277.37 87.22 275.19 76.59 275.19 63.62 L 312.32 13.21 L 332.86 6.6 L 382.39 13.21 L 403.25 31.33 L 434.01 101.72 L 483.87 212.54 L 561.18 363.22 L 583.81 407.92 L 595.89 449.32 L 600.4 461.96 L 608.21 461.96 L 608.21 454.71 L 614.58 369.83 L 626.34 265.61 L 637.77 131.52 L 641.72 93.75 L 660.4 48.48 L 697.53 24 L 726.52 37.85 L 750.36 72 L 747.06 94.07 L 732.89 186.2 L 705.1 330.52 L 686.98 427.17 L 697.53 427.17 L 709.61 415.09 L 758.5 350.17 L 840.64 247.49 L 876.89 206.74 L 919.17 161.72 L 946.31 140.3 L 997.61 140.3 L 1035.38 196.43 L 1018.47 254.42 L 965.64 321.42 L 921.83 378.2 L 859.01 462.77 L 819.79 530.42 L 823.41 535.81 L 832.75 534.93 L 974.66 504.72 L 1051.33 490.87 L 1142.82 475.17 L 1184.21 494.5 L 1188.72 514.15 L 1172.46 554.34 L 1074.6 578.5 L 959.84 601.45 L 788.94 641.88 L 786.85 643.41 L 789.26 646.39 L 866.26 653.64 L 899.19 655.41 L 979.81 655.41 L 1129.93 666.6 L 1169.15 692.54 L 1192.67 724.27 L 1188.72 748.43 L 1128.32 779.19 L 1046.82 759.87 L 856.59 714.6 L 791.36 698.34 L 782.34 698.34 L 782.34 703.73 L 836.7 756.89 L 936.32 846.85 L 1061.07 962.82 L 1067.44 991.49 L 1051.41 1014.12 L 1034.5 1011.7 L 924.89 929.23 L 882.6 892.11 L 786.85 811.49 L 780.48 811.49 L 780.48 819.95 L 802.55 852.24 L 919.09 1027.41 L 925.13 1081.13 L 916.67 1098.6 L 886.47 1109.15 L 853.29 1103.11 L 785.07 1007.36 L 714.68 899.52 L 657.91 802.87 L 650.98 806.82 L 617.48 1167.7 L 601.77 1186.15 L 565.53 1200 L 535.33 1177.05 L 519.3 1139.92 L 535.33 1066.55 L 554.66 970.79 L 570.36 894.68 L 584.54 800.13 L 592.99 768.72 L 592.43 766.63 L 585.5 767.52 L 514.23 865.37 L 405.83 1011.87 L 320.05 1103.68 L 299.52 1111.81 L 263.92 1093.37 L 267.22 1060.43 L 287.11 1031.11 L 405.83 880.11 L 477.42 786.52 L 523.65 732.48 L 523.33 724.67 L 520.59 724.67 L 205.29 929.4 L 149.15 936.64 L 124.99 914.01 L 127.97 876.89 L 139.41 864.81 L 234.2 799.57 Z" />
</svg>
);
});
const OpenAIIcon = memo(function OpenAIIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364l2.0153-1.1639a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" fill="#ffffff" />
</svg>
);
});
const CursorIcon = memo(function CursorIcon({ size = 14 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ flexShrink: 0 }}>
<rect x="3" y="3" width="18" height="18" rx="4" stroke="#A1A1AA" strokeWidth="1.5" />
<path d="M8 12h8M12 8v8" stroke="#A1A1AA" strokeWidth="1.5" strokeLinecap="round" />
</svg>
);
});
export const AgentIcon = memo(function AgentIcon({ agent, size = 14 }: { agent: AgentKind; size?: number }) {
switch (agent) {
case "Claude":
return <ClaudeIcon size={size} />;
case "Codex":
return <OpenAIIcon size={size} />;
case "Cursor":
return <CursorIcon size={size} />;
}
});
export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) {
if (tab.status === "running") return <SpinnerDot size={8} />;
if (tab.unread) return <UnreadDot />;
return <AgentIcon agent={tab.agent} size={13} />;
});
export const Shell = styled("div", ({ $theme }) => ({
display: "grid",
gap: "1px",
height: "100dvh",
backgroundColor: $theme.colors.borderOpaque,
gridTemplateColumns: "280px minmax(0, 1fr) 380px",
overflow: "hidden",
}));
export const SPanel = styled("section", ({ $theme }) => ({
minHeight: 0,
display: "flex",
flexDirection: "column" as const,
backgroundColor: $theme.colors.backgroundSecondary,
overflow: "hidden",
}));
export const ScrollBody = styled("div", () => ({
minHeight: 0,
flex: 1,
position: "relative" as const,
overflowY: "auto" as const,
display: "flex",
flexDirection: "column" as const,
}));
export const HEADER_HEIGHT = "42px";
export const PROMPT_TEXTAREA_MIN_HEIGHT = 56;
export const PROMPT_TEXTAREA_MAX_HEIGHT = 100;
export const PanelHeaderBar = styled("div", ({ $theme }) => ({
display: "flex",
alignItems: "center",
minHeight: HEADER_HEIGHT,
maxHeight: HEADER_HEIGHT,
padding: "0 14px",
borderBottom: `1px solid ${$theme.colors.borderOpaque}`,
backgroundColor: $theme.colors.backgroundTertiary,
gap: "8px",
flexShrink: 0,
}));

View file

@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import type { WorkbenchAgentTab } from "@sandbox-agent/foundry-shared";
import { buildDisplayMessages } from "./view-model";
function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab {
return {
id: "tab-1",
sessionId: "session-1",
sessionName: "Session 1",
agent: "Codex",
model: "gpt-4o",
status: "idle",
thinkingSinceMs: null,
unread: false,
created: true,
draft: {
text: "",
attachments: [],
updatedAtMs: null,
},
transcript,
};
}
describe("buildDisplayMessages", () => {
it("collapses chunked agent output into a single display message", () => {
const messages = buildDisplayMessages(
makeTab([
{
id: "evt-setup",
eventIndex: 0,
sessionId: "session-1",
createdAt: 0,
connectionId: "conn-1",
sender: "client",
payload: {
method: "session/new",
params: {
cwd: "/repo",
},
},
},
{
id: "evt-client",
eventIndex: 1,
sessionId: "session-1",
createdAt: 1,
connectionId: "conn-1",
sender: "client",
payload: {
method: "session/prompt",
params: {
prompt: [{ type: "text", text: "hello" }],
},
},
},
{
id: "evt-config",
eventIndex: 1,
sessionId: "session-1",
createdAt: 1,
connectionId: "conn-1",
sender: "agent",
payload: {
result: {
configOptions: [],
},
},
},
{
id: "evt-chunk-1",
eventIndex: 2,
sessionId: "session-1",
createdAt: 2,
connectionId: "conn-1",
sender: "agent",
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "hel",
},
},
},
},
},
{
id: "evt-chunk-2",
eventIndex: 3,
sessionId: "session-1",
createdAt: 3,
connectionId: "conn-1",
sender: "agent",
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "lo",
},
},
},
},
},
{
id: "evt-stop",
eventIndex: 4,
sessionId: "session-1",
createdAt: 4,
connectionId: "conn-1",
sender: "agent",
payload: {
result: {
stopReason: "end_turn",
},
},
},
]),
);
expect(messages).toEqual([
expect.objectContaining({
id: "evt-client",
sender: "client",
text: "hello",
}),
expect.objectContaining({
id: "evt-chunk-1",
sender: "agent",
text: "hello",
}),
]);
});
it("hides non-message session update envelopes", () => {
const messages = buildDisplayMessages(
makeTab([
{
id: "evt-client",
eventIndex: 1,
sessionId: "session-1",
createdAt: 1,
connectionId: "conn-1",
sender: "client",
payload: {
method: "session/prompt",
params: {
prompt: [{ type: "text", text: "hello" }],
},
},
},
{
id: "evt-update",
eventIndex: 2,
sessionId: "session-1",
createdAt: 2,
connectionId: "conn-1",
sender: "agent",
payload: {
method: "session/update",
params: {
update: {
sessionUpdate: "agent_thought",
content: {
type: "text",
text: "thinking",
},
},
},
},
},
{
id: "evt-result",
eventIndex: 3,
sessionId: "session-1",
createdAt: 3,
connectionId: "conn-1",
sender: "agent",
payload: {
result: {
text: "done",
},
},
},
]),
);
expect(messages).toEqual([
expect.objectContaining({
id: "evt-client",
sender: "client",
text: "hello",
}),
expect.objectContaining({
id: "evt-result",
sender: "agent",
text: "done",
}),
]);
});
});

View file

@ -0,0 +1,339 @@
import type {
WorkbenchAgentKind as AgentKind,
WorkbenchAgentTab as AgentTab,
WorkbenchDiffLineKind as DiffLineKind,
WorkbenchFileChange as FileChange,
WorkbenchFileTreeNode as FileTreeNode,
WorkbenchTask as WorkbenchTask,
WorkbenchHistoryEvent as HistoryEvent,
WorkbenchLineAttachment as LineAttachment,
WorkbenchModelGroup as ModelGroup,
WorkbenchModelId as ModelId,
WorkbenchParsedDiffLine as ParsedDiffLine,
WorkbenchRepoSection as WorkbenchRepoSection,
WorkbenchTranscriptEvent as TranscriptEvent,
} from "@sandbox-agent/foundry-shared";
import { extractEventText } from "../../features/sessions/model";
export type Task = WorkbenchTask;
export type RepoSection = WorkbenchRepoSection;
export const MODEL_GROUPS: ModelGroup[] = [
{
provider: "Claude",
models: [
{ id: "claude-sonnet-4", label: "Sonnet 4" },
{ id: "claude-opus-4", label: "Opus 4" },
],
},
{
provider: "OpenAI",
models: [
{ id: "gpt-4o", label: "GPT-4o" },
{ id: "o3", label: "o3" },
],
},
];
export function formatRelativeAge(updatedAtMs: number, nowMs = Date.now()): string {
const deltaSeconds = Math.max(0, Math.floor((nowMs - updatedAtMs) / 1000));
if (deltaSeconds < 60) return `${deltaSeconds}s`;
const minutes = Math.floor(deltaSeconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h`;
const days = Math.floor(hours / 24);
return `${days}d`;
}
export function formatMessageTimestamp(createdAtMs: number, nowMs = Date.now()): string {
const createdAt = new Date(createdAtMs);
const now = new Date(nowMs);
const sameDay = createdAt.toDateString() === now.toDateString();
const timeLabel = createdAt.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
});
if (sameDay) {
return timeLabel;
}
const deltaDays = Math.floor((nowMs - createdAtMs) / (24 * 60 * 60 * 1000));
if (deltaDays < 7) {
const weekdayLabel = createdAt.toLocaleDateString([], { weekday: "short" });
return `${weekdayLabel} ${timeLabel}`;
}
return createdAt.toLocaleDateString([], {
month: "short",
day: "numeric",
});
}
export function formatThinkingDuration(durationMs: number): string {
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
export function formatMessageDuration(durationMs: number): string {
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
if (totalSeconds < 60) {
return `${totalSeconds}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
}
export function modelLabel(id: ModelId): string {
const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id));
const model = group?.models.find((candidate) => candidate.id === id);
return model && group ? `${group.provider} ${model.label}` : id;
}
export function providerAgent(provider: string): AgentKind {
if (provider === "Claude") return "Claude";
if (provider === "OpenAI") return "Codex";
return "Cursor";
}
const DIFF_PREFIX = "diff:";
export function isDiffTab(id: string): boolean {
return id.startsWith(DIFF_PREFIX);
}
export function diffPath(id: string): string {
return id.slice(DIFF_PREFIX.length);
}
export function diffTabId(path: string): string {
return `${DIFF_PREFIX}${path}`;
}
export function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function eventOrder(id: string): number {
const match = id.match(/\d+/);
return match ? Number(match[0]) : 0;
}
function historyPreview(event: TranscriptEvent): string {
const content = extractEventText(event.payload).trim() || "Untitled event";
return content.length > 42 ? `${content.slice(0, 39)}...` : content;
}
function historyDetail(event: TranscriptEvent): string {
const content = extractEventText(event.payload).trim();
return content || "Untitled event";
}
export function buildHistoryEvents(tabs: AgentTab[]): HistoryEvent[] {
return tabs
.flatMap((tab) =>
tab.transcript
.filter((event) => event.sender === "client")
.map((event) => ({
id: `history-${tab.id}-${event.id}`,
messageId: event.id,
preview: historyPreview(event),
sessionName: tab.sessionName,
tabId: tab.id,
createdAtMs: event.createdAt,
detail: historyDetail(event),
})),
)
.sort((left, right) => eventOrder(left.messageId) - eventOrder(right.messageId));
}
export interface Message {
id: string;
sender: "client" | "agent";
text: string;
createdAtMs: number;
durationMs?: number;
event: TranscriptEvent;
}
function isAgentChunkEvent(event: TranscriptEvent): string | null {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return null;
}
const params = (payload as { params?: unknown }).params;
if (!params || typeof params !== "object") {
return null;
}
const update = (params as { update?: unknown }).update;
if (!update || typeof update !== "object") {
return null;
}
if ((update as { sessionUpdate?: unknown }).sessionUpdate !== "agent_message_chunk") {
return null;
}
const content = (update as { content?: unknown }).content;
if (!content || typeof content !== "object") {
return null;
}
const text = (content as { text?: unknown }).text;
return typeof text === "string" ? text : null;
}
function isClientPromptEvent(event: TranscriptEvent): boolean {
const payload = event.payload;
if (!payload || typeof payload !== "object") {
return false;
}
return (payload as { method?: unknown }).method === "session/prompt";
}
function shouldDisplayEvent(event: TranscriptEvent): boolean {
const payload = event.payload;
if (event.sender === "client") {
return isClientPromptEvent(event) && Boolean(extractEventText(payload).trim());
}
if (!payload || typeof payload !== "object") {
return Boolean(extractEventText(payload).trim());
}
if ((payload as { error?: unknown }).error) {
return true;
}
if (isAgentChunkEvent(event) !== null) {
return false;
}
if ((payload as { method?: unknown }).method === "session/update") {
return false;
}
const result = (payload as { result?: unknown }).result;
if (result && typeof result === "object") {
if (typeof (result as { stopReason?: unknown }).stopReason === "string") {
return false;
}
if (typeof (result as { text?: unknown }).text !== "string") {
return false;
}
}
const params = (payload as { params?: unknown }).params;
if (params && typeof params === "object") {
const update = (params as { update?: unknown }).update;
if (update && typeof update === "object") {
const sessionUpdate = (update as { sessionUpdate?: unknown }).sessionUpdate;
if (
sessionUpdate === "usage_update" ||
sessionUpdate === "available_commands_update" ||
sessionUpdate === "config_options_update" ||
sessionUpdate === "available_modes_update" ||
sessionUpdate === "available_models_update"
) {
return false;
}
}
}
return Boolean(extractEventText(payload).trim());
}
export function buildDisplayMessages(tab: AgentTab | null | undefined): Message[] {
if (!tab) {
return [];
}
const messages: Message[] = [];
let pendingAgentMessage: Message | null = null;
const flushPendingAgentMessage = () => {
if (pendingAgentMessage && pendingAgentMessage.text.length > 0) {
messages.push(pendingAgentMessage);
}
pendingAgentMessage = null;
};
for (const event of tab.transcript) {
const chunkText = isAgentChunkEvent(event);
if (chunkText !== null) {
if (!pendingAgentMessage) {
pendingAgentMessage = {
id: event.id,
sender: "agent",
text: chunkText,
createdAtMs: event.createdAt,
event,
};
} else {
pendingAgentMessage.text += chunkText;
}
continue;
}
flushPendingAgentMessage();
if (!shouldDisplayEvent(event)) {
continue;
}
messages.push({
id: event.id,
sender: event.sender,
text: extractEventText(event.payload),
createdAtMs: event.createdAt,
durationMs:
event.payload && typeof event.payload === "object"
? typeof (event.payload as { result?: { durationMs?: unknown } }).result?.durationMs === "number"
? ((event.payload as { result?: { durationMs?: number } }).result?.durationMs ?? undefined)
: undefined
: undefined,
event,
});
}
flushPendingAgentMessage();
return messages;
}
export function parseDiffLines(diff: string): ParsedDiffLine[] {
return diff.split("\n").map((text, index) => {
if (text.startsWith("@@")) {
return { kind: "hunk", lineNumber: index + 1, text };
}
if (text.startsWith("+")) {
return { kind: "add", lineNumber: index + 1, text };
}
if (text.startsWith("-")) {
return { kind: "remove", lineNumber: index + 1, text };
}
return { kind: "context", lineNumber: index + 1, text };
});
}
export type {
AgentKind,
AgentTab,
DiffLineKind,
FileChange,
FileTreeNode,
HistoryEvent,
LineAttachment,
ModelGroup,
ModelId,
ParsedDiffLine,
TranscriptEvent,
};

View file

@ -0,0 +1,973 @@
import { useEffect, useMemo, useState } from "react";
import {
type FoundryBillingPlanId,
type FoundryOrganization,
type FoundryOrganizationMember,
type FoundryUser,
} from "@sandbox-agent/foundry-shared";
import { useNavigate } from "@tanstack/react-router";
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",
day: "numeric",
year: "numeric",
});
const planCatalog: Record<
FoundryBillingPlanId,
{
label: string;
price: string;
seats: string;
summary: string;
}
> = {
free: {
label: "Free",
price: "$0",
seats: "1 seat included",
summary: "Best for a personal workspace and quick evaluations.",
},
team: {
label: "Team",
price: "$240/mo",
seats: "5 seats included",
summary: "GitHub org onboarding, shared billing, and seat accrual on first prompt.",
},
};
function appSurfaceStyle(): React.CSSProperties {
return {
minHeight: "100dvh",
display: "flex",
flexDirection: "column",
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",
};
}
function topBarStyle(): React.CSSProperties {
return {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "18px 28px",
borderBottom: "1px solid rgba(255, 255, 255, 0.1)",
background: "rgba(0, 0, 0, 0.36)",
backdropFilter: "blur(16px)",
};
}
function contentWrapStyle(): React.CSSProperties {
return {
width: "min(1180px, calc(100vw - 40px))",
margin: "0 auto",
padding: "28px 0 40px",
display: "flex",
flexDirection: "column",
gap: "20px",
};
}
function primaryButtonStyle(): React.CSSProperties {
return {
border: 0,
borderRadius: "999px",
padding: "11px 16px",
background: "#ff4f00",
color: "#ffffff",
fontWeight: 700,
cursor: "pointer",
};
}
function secondaryButtonStyle(): React.CSSProperties {
return {
border: "1px solid rgba(255, 255, 255, 0.16)",
borderRadius: "999px",
padding: "10px 15px",
background: "rgba(255, 255, 255, 0.03)",
color: "#ffffff",
fontWeight: 600,
cursor: "pointer",
};
}
function subtleButtonStyle(): React.CSSProperties {
return {
border: 0,
borderRadius: "999px",
padding: "10px 14px",
background: "rgba(255, 255, 255, 0.05)",
color: "#ffffff",
fontWeight: 600,
cursor: "pointer",
};
}
function cardStyle(): React.CSSProperties {
return {
background: "linear-gradient(180deg, rgba(21, 21, 24, 0.96), rgba(10, 10, 11, 0.98))",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "24px",
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.36)",
};
}
function badgeStyle(background: string, color = "#f4f4f5"): React.CSSProperties {
return {
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 10px",
borderRadius: "999px",
background,
color,
fontSize: "12px",
fontWeight: 700,
letterSpacing: "0.01em",
};
}
function formatDate(value: string | null): string {
if (!value) {
return "N/A";
}
return dateFormatter.format(new Date(value));
}
function workspacePath(organization: FoundryOrganization): string {
return `/workspaces/${organization.workspaceId}`;
}
function settingsPath(organization: FoundryOrganization): string {
return `/organizations/${organization.id}/settings`;
}
function billingPath(organization: FoundryOrganization): string {
return `/organizations/${organization.id}/billing`;
}
function checkoutPath(organization: FoundryOrganization, planId: FoundryBillingPlanId): string {
return `/organizations/${organization.id}/checkout/${planId}`;
}
function statusBadge(organization: FoundryOrganization) {
if (organization.kind === "personal") {
return <span style={badgeStyle("rgba(24, 140, 255, 0.18)", "#b9d8ff")}>Personal workspace</span>;
}
return <span style={badgeStyle("rgba(255, 79, 0, 0.16)", "#ffd6c7")}>GitHub organization</span>;
}
function githubBadge(organization: FoundryOrganization) {
if (organization.github.installationStatus === "connected") {
return <span style={badgeStyle("rgba(46, 160, 67, 0.16)", "#b7f0c3")}>GitHub connected</span>;
}
if (organization.github.installationStatus === "reconnect_required") {
return <span style={badgeStyle("rgba(255, 193, 7, 0.18)", "#ffe6a6")}>Reconnect required</span>;
}
return <span style={badgeStyle("rgba(255, 255, 255, 0.08)")}>Install GitHub App</span>;
}
function PageShell({
user,
title,
eyebrow,
description,
children,
actions,
onSignOut,
}: {
user: FoundryUser | null;
title: string;
eyebrow: string;
description: string;
children: React.ReactNode;
actions?: React.ReactNode;
onSignOut?: () => void;
}) {
return (
<div style={appSurfaceStyle()}>
<div style={topBarStyle()}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
width: "42px",
height: "42px",
borderRadius: "14px",
background: "linear-gradient(135deg, #ff4f00, #ff7a00)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 800,
letterSpacing: "0.06em",
}}
>
SA
</div>
<div>
<div style={{ fontSize: "12px", fontWeight: 700, textTransform: "uppercase", color: "#a1a1aa" }}>{eyebrow}</div>
<div style={{ fontSize: "24px", fontWeight: 800 }}>{title}</div>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
{actions}
{user ? (
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: "13px", fontWeight: 700 }}>{user.name}</div>
<div style={{ fontSize: "12px", color: "#a1a1aa" }}>@{user.githubLogin}</div>
</div>
{onSignOut ? (
<button type="button" onClick={onSignOut} style={secondaryButtonStyle()}>
Sign out
</button>
) : null}
</div>
) : null}
</div>
</div>
<div style={contentWrapStyle()}>
<div style={{ maxWidth: "720px", color: "#d4d4d8", fontSize: "15px", lineHeight: 1.5 }}>{description}</div>
{children}
</div>
</div>
);
}
function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) {
return (
<div
style={{
...cardStyle(),
padding: "18px 20px",
display: "flex",
flexDirection: "column",
gap: "8px",
}}
>
<div style={{ fontSize: "12px", color: "#a1a1aa", textTransform: "uppercase", letterSpacing: "0.06em" }}>{label}</div>
<div style={{ fontSize: "26px", fontWeight: 800 }}>{value}</div>
<div style={{ fontSize: "13px", color: "#c4c4ca", lineHeight: 1.5 }}>{caption}</div>
</div>
);
}
function MemberRow({ member }: { member: FoundryOrganizationMember }) {
return (
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr) 120px",
gap: "12px",
padding: "12px 0",
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
alignItems: "center",
}}
>
<div>
<div style={{ fontWeight: 700 }}>{member.name}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>{member.email}</div>
</div>
<div style={{ color: "#d4d4d8", textTransform: "capitalize" }}>{member.role}</div>
<div>
<span
style={badgeStyle(
member.state === "active" ? "rgba(46, 160, 67, 0.16)" : "rgba(255, 193, 7, 0.18)",
member.state === "active" ? "#b7f0c3" : "#ffe6a6",
)}
>
{member.state}
</span>
</div>
</div>
);
}
export function MockSignInPage() {
const client = useMockAppClient();
const navigate = useNavigate();
const mockAccount = {
name: "Nathan",
email: "nathan@acme.dev",
githubLogin: "nathan",
label: "Mock account for review",
};
return (
<div style={appSurfaceStyle()}>
<div style={{ ...contentWrapStyle(), justifyContent: "center", minHeight: "100dvh" }}>
<div
style={{
...cardStyle(),
padding: "32px",
display: "grid",
gridTemplateColumns: "minmax(0, 1.1fr) minmax(0, 0.9fr)",
gap: "28px",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "18px", justifyContent: "center" }}>
<span style={badgeStyle("rgba(255, 79, 0, 0.18)", "#ffd6c7")}>Mock Better Auth + GitHub OAuth</span>
<div style={{ fontSize: "42px", lineHeight: 1.05, fontWeight: 900, maxWidth: "11ch" }}>
Sign in and land directly in the org onboarding funnel.
</div>
<div style={{ fontSize: "16px", lineHeight: 1.6, color: "#d4d4d8", maxWidth: "56ch" }}>
{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)")}>
<Github size={14} />
GitHub sign-in
</div>
<div style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
<Building2 size={14} />
Org selection
</div>
<div style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
<CreditCard size={14} />
Hosted billing
</div>
</div>
</div>
<div
style={{
...cardStyle(),
padding: "24px",
display: "flex",
flexDirection: "column",
gap: "18px",
justifyContent: "center",
}}
>
<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 }}>
{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(isMockFrontendClient ? "user-nathan" : undefined);
if (isMockFrontendClient) {
await navigate({ to: "/organizations" });
}
})();
}}
style={{
...primaryButtonStyle(),
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "10px",
fontSize: "16px",
padding: "14px 18px",
}}
>
<Github size={18} />
Sign in with GitHub
</button>
<div
style={{
borderRadius: "18px",
border: "1px solid rgba(255, 255, 255, 0.08)",
background: "rgba(255, 255, 255, 0.03)",
padding: "16px",
display: "grid",
gap: "8px",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px" }}>
<div>
<div style={{ fontSize: "16px", fontWeight: 800 }}>{mockAccount.name}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>
@{mockAccount.githubLogin} · {mockAccount.email}
</div>
</div>
<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 }}>
{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>
</div>
</div>
</div>
);
}
export function MockOrganizationSelectorPage() {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const organizations: FoundryOrganization[] = eligibleOrganizations(snapshot);
const navigate = useNavigate();
return (
<PageShell
user={user}
title="Choose an organization"
eyebrow="Onboarding"
description="After GitHub sign-in, choose which personal workspace or GitHub organization to onboard. Organization workspaces simulate GitHub app installation, repository import, and shared billing."
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(320px, 1fr))", gap: "18px" }}>
{organizations.map((organization) => (
<div
key={organization.id}
style={{
...cardStyle(),
padding: "22px",
display: "flex",
flexDirection: "column",
gap: "16px",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: "16px" }}>
<div>
<div style={{ fontSize: "22px", fontWeight: 800 }}>{organization.settings.displayName}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>
{organization.settings.slug} · {organization.settings.primaryDomain}
</div>
</div>
{statusBadge(organization)}
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{githubBadge(organization)}
<span style={badgeStyle("rgba(255, 255, 255, 0.06)")}>
<CreditCard size={14} />
{planCatalog[organization.billing.planId]!.label}
</span>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55, minHeight: "70px" }}>
{organization.kind === "personal"
? "Personal workspaces skip seat purchasing but still show the same onboarding and billing entry points."
: "Organization onboarding includes GitHub repo import, seat accrual on first prompt, and billing controls for the shared workspace."}
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: "10px" }}>
<StatCard
label="Members"
value={`${organization.members.length}`}
caption={`${organization.members.filter((member) => member.state === "active").length} active`}
/>
<StatCard
label="Repos"
value={`${organization.repoCatalog.length}`}
caption={organization.github.lastSyncLabel}
/>
<StatCard
label="Seats"
value={`${organization.seatAssignments.length}/${organization.billing.seatsIncluded}`}
caption="Accrue on first prompt"
/>
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button
type="button"
onClick={() => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
}}
style={primaryButtonStyle()}
>
Continue as {organization.settings.displayName}
</button>
<button type="button" onClick={() => void navigate({ to: settingsPath(organization) })} style={secondaryButtonStyle()}>
Organization settings
</button>
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={subtleButtonStyle()}>
Billing
</button>
</div>
</div>
))}
</div>
</PageShell>
);
}
export function MockOrganizationSettingsPage({ organization }: { organization: FoundryOrganization }) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const [displayName, setDisplayName] = useState(organization.settings.displayName);
const [slug, setSlug] = useState(organization.settings.slug);
const [primaryDomain, setPrimaryDomain] = useState(organization.settings.primaryDomain);
const seatCaption = useMemo(
() => `${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);
setSlug(organization.settings.slug);
setPrimaryDomain(organization.settings.primaryDomain);
}, [
organization.id,
organization.settings.displayName,
organization.settings.slug,
organization.settings.primaryDomain,
]);
return (
<PageShell
user={user}
title={`${organization.settings.displayName} settings`}
eyebrow="Organization"
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()}>
<ArrowLeft size={15} />
Orgs
</button>
<button type="button" onClick={() => void navigate({ to: billingPath(organization) })} style={subtleButtonStyle()}>
Billing
</button>
<button type="button" onClick={openWorkspace} style={primaryButtonStyle()}>
Open workspace
</button>
</>
}
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.1fr) minmax(320px, 0.9fr)", gap: "18px" }}>
<div style={{ display: "grid", gap: "18px" }}>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "16px" }}>
<div>
<div style={{ fontSize: "20px", fontWeight: 800 }}>Organization profile</div>
<div style={{ color: "#a1a1aa", fontSize: "14px" }}>
Mock Better Auth org state persisted in the client package.
</div>
</div>
{statusBadge(organization)}
</div>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Display name</span>
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
style={inputStyle()}
/>
</label>
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", gap: "14px" }}>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Slug</span>
<input value={slug} onChange={(event) => setSlug(event.target.value)} style={inputStyle()} />
</label>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Primary domain</span>
<input
value={primaryDomain}
onChange={(event) => setPrimaryDomain(event.target.value)}
style={inputStyle()}
/>
</label>
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button
type="button"
onClick={() =>
void client.updateOrganizationProfile({
organizationId: organization.id,
displayName,
slug,
primaryDomain,
})
}
style={primaryButtonStyle()}
>
Save settings
</button>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={secondaryButtonStyle()}>
Refresh repo sync
</button>
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<Github size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>GitHub access</div>
</div>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{githubBadge(organization)}
<span style={badgeStyle("rgba(255, 255, 255, 0.06)")}>{organization.github.connectedAccount}</span>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
{organization.github.importedRepoCount} repos imported. Last sync: {organization.github.lastSyncLabel}
</div>
<div style={{ display: "flex", gap: "10px", flexWrap: "wrap" }}>
<button type="button" onClick={() => void client.reconnectGithub(organization.id)} style={secondaryButtonStyle()}>
Reconnect GitHub
</button>
<button type="button" onClick={() => void client.triggerGithubSync(organization.id)} style={subtleButtonStyle()}>
Retry sync
</button>
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "8px" }}>
<Users size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>Members and roles</div>
</div>
<div style={{ color: "#a1a1aa", fontSize: "14px", marginBottom: "8px" }}>
Mock org membership feeds seat accrual and billing previews.
</div>
{organization.members.map((member) => (
<MemberRow key={member.id} member={member} />
))}
</div>
</div>
<div style={{ display: "grid", gap: "14px" }}>
<StatCard
label="Seat policy"
value="First prompt"
caption="Seats accrue when a member sends their first prompt in the workspace."
/>
<StatCard label="Seat usage" value={`${organization.seatAssignments.length}`} caption={seatCaption} />
<StatCard
label="Default model"
value={organization.settings.defaultModel}
caption="Shown here to match the expected org-level configuration surface."
/>
</div>
</div>
</PageShell>
);
}
export function MockOrganizationBillingPage({ organization }: { organization: FoundryOrganization }) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const hasStripeCustomer = organization.billing.stripeCustomerId.trim().length > 0;
const effectivePlanId: FoundryBillingPlanId = 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={
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={openWorkspace} style={primaryButtonStyle()}>
Open workspace
</button>
</>
}
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: "14px" }}>
<StatCard
label="Current plan"
value={planCatalog[effectivePlanId]!.label}
caption={organization.billing.status.replaceAll("_", " ")}
/>
<StatCard
label="Seats used"
value={`${organization.seatAssignments.length}/${effectiveSeatsIncluded}`}
caption="Seat accrual happens on first prompt in the workspace."
/>
<StatCard
label="Renewal"
value={formatDate(organization.billing.renewalAt)}
caption={`Payment method: ${organization.billing.paymentMethodLabel}`}
/>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: "18px" }}>
{(Object.entries(planCatalog) as Array<[FoundryBillingPlanId, (typeof planCatalog)[FoundryBillingPlanId]]>).map(([planId, plan]) => {
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" }}>
<div>
<div style={{ fontSize: "22px", fontWeight: 800 }}>{plan.label}</div>
<div style={{ color: "#a1a1aa", fontSize: "13px" }}>{plan.seats}</div>
</div>
{isCurrent ? <span style={badgeStyle("rgba(46, 160, 67, 0.16)", "#b7f0c3")}>Current</span> : null}
</div>
<div style={{ fontSize: "34px", fontWeight: 900 }}>{plan.price}</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55, minHeight: "70px" }}>{plan.summary}</div>
<button
type="button"
onClick={() =>
isCurrent
? void navigate({ to: billingPath(organization) })
: void navigate({ to: checkoutPath(organization, planId) })
}
style={isCurrent ? secondaryButtonStyle() : primaryButtonStyle()}
>
{isCurrent ? "Current plan" : `Choose ${plan.label}`}
</button>
</div>
);
})}
</div>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 0.9fr) minmax(320px, 1.1fr)", gap: "18px" }}>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "14px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<ShieldCheck size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>Subscription controls</div>
</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
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" }}>
{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 navigate({ to: checkoutPath(organization, "team") })} style={primaryButtonStyle()}>
Start Team checkout
</button>
)}
<button
type="button"
onClick={() =>
void (isMockFrontendClient
? navigate({ to: checkoutPath(organization, effectivePlanId) })
: hasStripeCustomer
? client.openBillingPortal(organization.id)
: navigate({ to: checkoutPath(organization, "team") }))
}
style={subtleButtonStyle()}
>
{isMockFrontendClient ? "Open hosted checkout mock" : hasStripeCustomer ? "Open Stripe portal" : "Go to checkout"}
</button>
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px", marginBottom: "8px" }}>
<BadgeCheck size={18} />
<div style={{ fontSize: "20px", fontWeight: 800 }}>Invoices</div>
</div>
<div style={{ color: "#a1a1aa", fontSize: "14px", marginBottom: "8px" }}>
Recent hosted billing activity for review.
</div>
{organization.billing.invoices.length === 0 ? (
<div style={{ color: "#d4d4d8" }}>No invoices yet.</div>
) : (
organization.billing.invoices.map((invoice) => (
<div
key={invoice.id}
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) 120px 90px",
gap: "12px",
alignItems: "center",
padding: "12px 0",
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<div>
<div style={{ fontWeight: 700 }}>{invoice.label}</div>
<div style={{ fontSize: "13px", color: "#a1a1aa" }}>{invoice.issuedAt}</div>
</div>
<div style={{ fontWeight: 700 }}>${invoice.amountUsd}</div>
<div>
<span
style={badgeStyle(
invoice.status === "paid" ? "rgba(46, 160, 67, 0.16)" : "rgba(255, 193, 7, 0.18)",
invoice.status === "paid" ? "#b7f0c3" : "#ffe6a6",
)}
>
{invoice.status}
</span>
</div>
</div>
))
)}
</div>
</div>
</PageShell>
);
}
export function MockHostedCheckoutPage({
organization,
planId,
}: {
organization: FoundryOrganization;
planId: FoundryBillingPlanId;
}) {
const client = useMockAppClient();
const snapshot = useMockAppSnapshot();
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const plan = planCatalog[planId]!;
return (
<PageShell
user={user}
title={`Checkout ${plan.label}`}
eyebrow="Hosted Checkout"
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} />
Back to billing
</button>
}
onSignOut={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
>
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 0.95fr) minmax(320px, 1.05fr)", gap: "18px" }}>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "14px" }}>
<div style={{ fontSize: "20px", fontWeight: 800 }}>Order summary</div>
<div style={{ color: "#d4d4d8", lineHeight: 1.55 }}>
{organization.settings.displayName} is checking out on the {plan.label} plan.
</div>
<div style={{ display: "grid", gap: "10px" }}>
<CheckoutLine label="Plan" value={plan.label} />
<CheckoutLine label="Price" value={plan.price} />
<CheckoutLine label="Included seats" value={plan.seats} />
<CheckoutLine label="Payment method" value="Visa ending in 4242" />
</div>
</div>
<div style={{ ...cardStyle(), padding: "24px", display: "grid", gap: "16px" }}>
<div style={{ fontSize: "20px", fontWeight: 800 }}>Mock card details</div>
<div style={{ display: "grid", gap: "12px" }}>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Cardholder</span>
<input value={organization.settings.displayName} readOnly style={inputStyle()} />
</label>
<label style={{ display: "grid", gap: "8px" }}>
<span style={{ fontSize: "13px", fontWeight: 700 }}>Card number</span>
<input value="4242 4242 4242 4242" readOnly style={inputStyle()} />
</label>
</div>
<button
type="button"
onClick={() => {
void (async () => {
await client.completeHostedCheckout(organization.id, planId);
if (isMockFrontendClient) {
await navigate({ to: billingPath(organization), replace: true });
}
})();
}}
style={primaryButtonStyle()}
>
{isMockFrontendClient ? "Complete checkout" : "Continue to Stripe"}
</button>
</div>
</div>
</PageShell>
);
}
function CheckoutLine({ label, value }: { label: string; value: string }) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
padding: "12px 0",
borderTop: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<div style={{ color: "#a1a1aa" }}>{label}</div>
<div style={{ fontWeight: 700 }}>{value}</div>
</div>
);
}
function inputStyle(): React.CSSProperties {
return {
width: "100%",
borderRadius: "14px",
border: "1px solid rgba(255, 255, 255, 0.12)",
background: "rgba(255, 255, 255, 0.04)",
color: "#ffffff",
padding: "12px 14px",
outline: "none",
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
import { describe, expect, it } from "vitest";
import type { SandboxSessionRecord } from "@sandbox-agent/foundry-client";
import { buildTranscript, extractEventText, resolveSessionSelection } from "./model";
describe("extractEventText", () => {
it("extracts prompt text arrays", () => {
expect(
extractEventText({ params: { prompt: [{ type: "text", text: "hello" }] } })
).toBe("hello");
});
it("falls back to method name", () => {
expect(extractEventText({ method: "session/started" })).toBe("session/started");
});
it("extracts agent result text when present", () => {
expect(
extractEventText({
result: {
text: "agent output"
}
})
).toBe("agent output");
});
it("extracts text from chunked session updates", () => {
expect(
extractEventText({
params: {
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "chunk"
}
}
}
})
).toBe("chunk");
});
});
describe("buildTranscript", () => {
it("maps sender/text/timestamp for UI transcript rendering", () => {
const rows = buildTranscript([
{
id: "evt-1",
eventIndex: 1,
sessionId: "sess-1",
createdAt: 1000,
connectionId: "conn-1",
sender: "client",
payload: { params: { prompt: [{ type: "text", text: "hello" }] } }
},
{
id: "evt-2",
eventIndex: 2,
sessionId: "sess-1",
createdAt: 2000,
connectionId: "conn-1",
sender: "agent",
payload: { params: { text: "world" } }
}
]);
expect(rows).toEqual([
{
id: "evt-1",
sender: "client",
text: "hello",
createdAt: 1000
},
{
id: "evt-2",
sender: "agent",
text: "world",
createdAt: 2000
}
]);
});
});
describe("resolveSessionSelection", () => {
const session = (id: string, status: "running" | "idle" | "error" = "running"): SandboxSessionRecord => ({
id,
agentSessionId: `agent-${id}`,
lastConnectionId: `conn-${id}`,
createdAt: 1,
status
} as SandboxSessionRecord);
it("prefers explicit selection when present in session list", () => {
const resolved = resolveSessionSelection({
explicitSessionId: "session-2",
taskSessionId: "session-1",
sessions: [session("session-1"), session("session-2")]
});
expect(resolved).toEqual({
sessionId: "session-2",
staleSessionId: null
});
});
it("falls back to task session when explicit selection is missing", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
taskSessionId: "session-1",
sessions: [session("session-1")]
});
expect(resolved).toEqual({
sessionId: "session-1",
staleSessionId: null
});
});
it("falls back to the newest available session when configured session IDs are stale", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
taskSessionId: "session-stale",
sessions: [session("session-fresh")]
});
expect(resolved).toEqual({
sessionId: "session-fresh",
staleSessionId: null
});
});
it("marks stale session when no sessions are available", () => {
const resolved = resolveSessionSelection({
explicitSessionId: null,
taskSessionId: "session-stale",
sessions: []
});
expect(resolved).toEqual({
sessionId: null,
staleSessionId: "session-stale"
});
});
});

View file

@ -0,0 +1,141 @@
import type { SandboxSessionEventRecord } from "@sandbox-agent/foundry-client";
import type { SandboxSessionRecord } from "@sandbox-agent/foundry-client";
function fromPromptArray(value: unknown): string | null {
if (!Array.isArray(value)) {
return null;
}
const parts: string[] = [];
for (const item of value) {
if (!item || typeof item !== "object") {
continue;
}
const text = (item as { text?: unknown }).text;
if (typeof text === "string" && text.trim().length > 0) {
parts.push(text.trim());
}
}
return parts.length > 0 ? parts.join("\n") : null;
}
function fromSessionUpdate(value: unknown): string | null {
if (!value || typeof value !== "object") {
return null;
}
const update = value as {
content?: unknown;
sessionUpdate?: unknown;
};
if (update.sessionUpdate !== "agent_message_chunk") {
return null;
}
const content = update.content;
if (!content || typeof content !== "object") {
return null;
}
const text = (content as { text?: unknown }).text;
return typeof text === "string" ? text : null;
}
export function extractEventText(payload: unknown): string {
if (!payload || typeof payload !== "object") {
return String(payload ?? "");
}
const envelope = payload as {
method?: unknown;
params?: unknown;
result?: unknown;
error?: unknown;
};
const params = envelope.params;
if (params && typeof params === "object") {
const updateText = fromSessionUpdate((params as { update?: unknown }).update);
if (typeof updateText === "string") {
return updateText;
}
const text = (params as { text?: unknown }).text;
if (typeof text === "string" && text.trim().length > 0) {
return text.trim();
}
const prompt = fromPromptArray((params as { prompt?: unknown }).prompt);
if (prompt) {
return prompt;
}
}
const result = envelope.result;
if (result && typeof result === "object") {
const text = (result as { text?: unknown }).text;
if (typeof text === "string" && text.trim().length > 0) {
return text.trim();
}
}
if (envelope.error) {
return JSON.stringify(envelope.error, null, 2);
}
if (typeof envelope.method === "string") {
return envelope.method;
}
return JSON.stringify(payload, null, 2);
}
export function buildTranscript(events: SandboxSessionEventRecord[]): Array<{
id: string;
sender: "client" | "agent";
text: string;
createdAt: number;
}> {
return events.map((event) => ({
id: event.id,
sender: event.sender,
text: extractEventText(event.payload),
createdAt: event.createdAt,
}));
}
export function resolveSessionSelection(input: {
explicitSessionId: string | null;
taskSessionId: string | null;
sessions: SandboxSessionRecord[];
}): {
sessionId: string | null;
staleSessionId: string | null;
} {
const sessionIds = new Set(input.sessions.map((session) => session.id));
const hasSession = (id: string | null): id is string => Boolean(id && sessionIds.has(id));
if (hasSession(input.explicitSessionId)) {
return { sessionId: input.explicitSessionId, staleSessionId: null };
}
if (hasSession(input.taskSessionId)) {
return { sessionId: input.taskSessionId, staleSessionId: null };
}
const fallbackSessionId = input.sessions[0]?.id ?? null;
if (fallbackSessionId) {
return { sessionId: fallbackSessionId, staleSessionId: null };
}
if (input.explicitSessionId) {
return { sessionId: null, staleSessionId: input.explicitSessionId };
}
if (input.taskSessionId) {
return { sessionId: null, staleSessionId: input.taskSessionId };
}
return { sessionId: null, staleSessionId: null };
}

View file

@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
import { formatDiffStat, groupTasksByRepo } from "./model";
const base: TaskRecord = {
workspaceId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
taskId: "task-1",
branchName: "feature/one",
title: "Feature one",
task: "Ship one",
providerId: "daytona",
status: "running",
statusMessage: null,
activeSandboxId: "sandbox-1",
activeSessionId: "session-1",
sandboxes: [
{
sandboxId: "sandbox-1",
providerId: "daytona",
sandboxActorId: null,
switchTarget: "daytona://sandbox-1",
cwd: null,
createdAt: 10,
updatedAt: 10,
}
],
agentType: null,
prSubmitted: false,
diffStat: null,
prUrl: null,
prAuthor: null,
ciStatus: null,
reviewStatus: null,
reviewer: null,
conflictsWithMain: null,
hasUnpushed: null,
parentBranch: null,
createdAt: 10,
updatedAt: 10,
};
describe("groupTasksByRepo", () => {
it("groups by repo and sorts by recency", () => {
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 = groupTasksByRepo(rows);
expect(groups).toHaveLength(2);
expect(groups[0]?.repoId).toBe("repo-a");
expect(groups[0]?.tasks[0]?.taskId).toBe("h2");
});
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 = groupTasksByRepo(rows);
expect(groups[0]?.repoId).toBe("repo-z");
expect(groups[1]?.repoId).toBe("repo-a");
});
});
describe("formatDiffStat", () => {
it("returns No changes for zero-diff values", () => {
expect(formatDiffStat("+0/-0")).toBe("No changes");
expect(formatDiffStat("+0 -0")).toBe("No changes");
});
it("returns dash for empty values", () => {
expect(formatDiffStat(null)).toBe("-");
expect(formatDiffStat("")).toBe("-");
});
it("keeps non-empty non-zero diff stats", () => {
expect(formatDiffStat("+12/-4")).toBe("+12/-4");
});
});

View file

@ -0,0 +1,54 @@
import type { TaskRecord } from "@sandbox-agent/foundry-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

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

View file

@ -0,0 +1,7 @@
import { createBackendClient } from "@sandbox-agent/foundry-client/backend";
import { backendEndpoint, defaultWorkspaceId } from "./env";
export const backendClient = createBackendClient({
endpoint: backendEndpoint,
defaultWorkspaceId,
});

View file

@ -0,0 +1,33 @@
function resolveDefaultBackendEndpoint(): string {
if (typeof window !== "undefined" && window.location?.origin) {
return `${window.location.origin}/api/rivet`;
}
return "http://127.0.0.1:7741/api/rivet";
}
type FrontendImportMetaEnv = ImportMetaEnv & {
FOUNDRY_FRONTEND_CLIENT_MODE?: string;
};
const frontendEnv = import.meta.env as FrontendImportMetaEnv;
export const backendEndpoint =
import.meta.env.VITE_HF_BACKEND_ENDPOINT?.trim() || resolveDefaultBackendEndpoint();
export const defaultWorkspaceId = import.meta.env.VITE_HF_WORKSPACE?.trim() || "default";
function resolveFrontendClientMode(): "mock" | "remote" {
const raw = frontendEnv.FOUNDRY_FRONTEND_CLIENT_MODE?.trim().toLowerCase();
if (raw === "mock") {
return "mock";
}
if (raw === "remote" || raw === "" || raw === undefined) {
return "remote";
}
throw new Error(
`Unsupported FOUNDRY_FRONTEND_CLIENT_MODE value "${frontendEnv.FOUNDRY_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`,
);
}
export const frontendClientMode = resolveFrontendClientMode();
export const isMockFrontendClient = frontendClientMode === "mock";

View file

@ -0,0 +1,79 @@
import { useSyncExternalStore } from "react";
import {
createFoundryAppClient,
currentFoundryOrganization,
currentFoundryUser,
eligibleFoundryOrganizations,
type FoundryAppClient,
} from "@sandbox-agent/foundry-client";
import type { FoundryAppSnapshot, FoundryOrganization } from "@sandbox-agent/foundry-shared";
import { backendClient } from "./backend";
import { frontendClientMode } from "./env";
const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-foundry:remote-app-session";
const appClient: FoundryAppClient = createFoundryAppClient({
mode: frontendClientMode,
backend: frontendClientMode === "remote" ? backendClient : undefined,
});
export function useMockAppSnapshot(): FoundryAppSnapshot {
return useSyncExternalStore(
appClient.subscribe.bind(appClient),
appClient.getSnapshot.bind(appClient),
appClient.getSnapshot.bind(appClient),
);
}
export function useMockAppClient(): FoundryAppClient {
return appClient;
}
export const activeMockUser = currentFoundryUser;
export const activeMockOrganization = currentFoundryOrganization;
export const eligibleOrganizations = eligibleFoundryOrganizations;
// 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: FoundryAppSnapshot): 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: FoundryAppSnapshot,
organizationId: string,
): FoundryOrganization | null {
return snapshot.organizations.find((organization) => organization.id === organizationId) ?? null;
}

View file

@ -0,0 +1,11 @@
import type { TaskWorkbenchSnapshot } from "@sandbox-agent/foundry-shared";
export function resolveRepoRouteTaskId(
snapshot: TaskWorkbenchSnapshot,
repoId: string,
): string | 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

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

View file

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

View file

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { TaskWorkbenchSnapshot } from "@sandbox-agent/foundry-shared";
import { resolveRepoRouteTaskId } from "./workbench-routing";
const snapshot: TaskWorkbenchSnapshot = {
workspaceId: "default",
repos: [
{ id: "repo-a", label: "acme/repo-a" },
{ id: "repo-b", label: "acme/repo-b" },
],
repoSections: [],
tasks: [
{
id: "task-a",
repoId: "repo-a",
title: "Alpha",
status: "idle",
repoName: "acme/repo-a",
updatedAtMs: 20,
branch: "feature/alpha",
pullRequest: null,
tabs: [],
fileChanges: [],
diffs: {},
fileTree: [],
},
],
};
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 task yet", () => {
expect(resolveRepoRouteTaskId(snapshot, "repo-b")).toBeNull();
});
});

View file

@ -0,0 +1,18 @@
import type { TaskWorkbenchClient } from "@sandbox-agent/foundry-client/workbench";
import { createWorkbenchRuntimeClient } from "@workbench-runtime";
import { frontendClientMode } from "./env";
export { resolveRepoRouteTaskId } from "./workbench-routing";
const workbenchClientCache = new Map<string, TaskWorkbenchClient>();
export function getTaskWorkbenchClient(workspaceId: string): TaskWorkbenchClient {
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

@ -0,0 +1,33 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BaseProvider } from "baseui";
import { RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Client as Styletron } from "styletron-engine-atomic";
import { Provider as StyletronProvider } from "styletron-react";
import { router } from "./app/router";
import { appTheme } from "./app/theme";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: true,
},
},
});
const styletronEngine = new Styletron();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<StyletronProvider value={styletronEngine}>
<BaseProvider theme={appTheme}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</BaseProvider>
</StyletronProvider>
</StrictMode>
);

View file

@ -0,0 +1,207 @@
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap");
:root {
color-scheme: dark;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: #000000;
color: #ffffff;
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: #000000;
color: #ffffff;
overflow: hidden;
}
* {
box-sizing: border-box;
user-select: none;
}
/* Opt-in text selection for content areas */
input,
textarea,
pre,
code,
[data-selectable] {
user-select: text;
}
a {
color: inherit;
}
@keyframes hf-spin {
to { transform: rotate(360deg); }
}
@keyframes hf-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
button,
input,
textarea,
select {
font: inherit;
}
code,
pre {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
}
.mock-diff-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
background: #111111;
}
.mock-diff-path {
color: #fafafa;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 13px;
font-weight: 600;
}
.mock-diff-stats {
display: flex;
gap: 8px;
margin-left: 4px;
font-size: 12px;
}
.mock-diff-added {
color: #7ee787;
}
.mock-diff-removed {
color: #ffa198;
}
.mock-diff-body {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 12px;
line-height: 20px;
}
.mock-diff-row {
display: flex;
align-items: stretch;
min-height: 20px;
}
.mock-diff-row[data-kind="add"] {
background: rgba(46, 160, 67, 0.12);
}
.mock-diff-row[data-kind="remove"] {
background: rgba(248, 81, 73, 0.10);
}
.mock-diff-row[data-kind="hunk"] {
background: rgba(255, 255, 255, 0.04);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.mock-diff-row[data-kind="hunk"]:not(:first-child) {
border-top: 1px solid rgba(255, 255, 255, 0.12);
}
.mock-diff-gutter {
position: relative;
width: 36px;
flex-shrink: 0;
padding: 0 8px 0 0;
font-size: 11px;
line-height: 20px;
text-align: right;
user-select: none;
}
.mock-diff-line-number {
display: block;
color: #71717a;
opacity: 0.5;
}
.mock-diff-line-text {
flex: 1;
padding: 0 10px;
overflow: hidden;
color: #a1a1aa;
font-size: 12px;
font-weight: 400;
line-height: 20px;
text-overflow: ellipsis;
white-space: pre;
}
.mock-diff-row[data-kind="add"] .mock-diff-line-text {
color: #7ee787;
}
.mock-diff-row[data-kind="remove"] .mock-diff-line-text {
color: #ffa198;
}
.mock-diff-row[data-kind="hunk"] .mock-diff-line-text {
color: #71717a;
}
.mock-diff-row[data-kind="hunk"] .mock-diff-line-text {
font-size: 11px;
font-weight: 600;
}
.mock-diff-attach-button {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: #ff4f00;
cursor: pointer;
opacity: 0;
pointer-events: none;
z-index: 1;
}
.mock-diff-row:not([data-kind="hunk"]):hover {
background: rgba(255, 79, 0, 0.06);
}
.mock-diff-row:not([data-kind="hunk"]):hover .mock-diff-attach-button {
opacity: 1;
pointer-events: auto;
background: rgba(255, 79, 0, 0.1);
}
.mock-diff-row:not([data-kind="hunk"]):hover .mock-diff-line-number {
opacity: 0;
}
.mock-diff-empty {
padding: 40px;
text-align: center;
}
.mock-diff-empty-copy {
color: #71717a;
font-size: 14px;
line-height: 1.4;
}

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2023",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"declaration": false,
"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

@ -0,0 +1,51 @@
import { realpathSync } from "node:fs";
import { dirname } from "node:path";
import { fileURLToPath, URL } from "node:url";
import { defineConfig, searchForWorkspaceRoot } from "vite";
import react from "@vitejs/plugin-react";
import { frontendErrorCollectorVitePlugin } from "@sandbox-agent/foundry-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.FOUNDRY_FRONTEND_CLIENT_MODE?.trim() || "remote";
const rivetkitClientEntry = realpathSync(
fileURLToPath(new URL("../client/node_modules/rivetkit/dist/browser/client.js", import.meta.url)),
);
const rivetkitPackageRoot = dirname(dirname(dirname(rivetkitClientEntry)));
export default defineConfig({
define: {
"import.meta.env.FOUNDRY_FRONTEND_CLIENT_MODE": JSON.stringify(
frontendClientMode,
),
},
plugins: [react(), frontendErrorCollectorVitePlugin()],
cacheDir,
resolve: {
alias: {
"rivetkit/client": rivetkitClientEntry,
"@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,
fs: {
allow: [searchForWorkspaceRoot(process.cwd()), rivetkitPackageRoot],
},
proxy: {
"/api/rivet": {
target: backendProxyTarget,
changeOrigin: true,
},
},
},
preview: {
port: 4173,
},
});

View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});