mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 02:04:13 +00:00
Rename Factory to Foundry
This commit is contained in:
parent
0a8fda040b
commit
324de36577
256 changed files with 605 additions and 603 deletions
19
foundry/packages/frontend/index.html
Normal file
19
foundry/packages/frontend/index.html
Normal 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>
|
||||
34
foundry/packages/frontend/package.json
Normal file
34
foundry/packages/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
502
foundry/packages/frontend/src/app/router.tsx
Normal file
502
foundry/packages/frontend/src/app/router.tsx
Normal 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;
|
||||
}
|
||||
18
foundry/packages/frontend/src/app/theme.ts
Normal file
18
foundry/packages/frontend/src/app/theme.ts
Normal 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
|
||||
},
|
||||
});
|
||||
1133
foundry/packages/frontend/src/components/mock-layout.tsx
Normal file
1133
foundry/packages/frontend/src/components/mock-layout.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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">−{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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
299
foundry/packages/frontend/src/components/mock-layout/sidebar.tsx
Normal file
299
foundry/packages/frontend/src/components/mock-layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
211
foundry/packages/frontend/src/components/mock-layout/ui.tsx
Normal file
211
foundry/packages/frontend/src/components/mock-layout/ui.tsx
Normal 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,
|
||||
}));
|
||||
|
|
@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
973
foundry/packages/frontend/src/components/mock-onboarding.tsx
Normal file
973
foundry/packages/frontend/src/components/mock-onboarding.tsx
Normal 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",
|
||||
};
|
||||
}
|
||||
1976
foundry/packages/frontend/src/components/workspace-dashboard.tsx
Normal file
1976
foundry/packages/frontend/src/components/workspace-dashboard.tsx
Normal file
File diff suppressed because it is too large
Load diff
143
foundry/packages/frontend/src/features/sessions/model.test.ts
Normal file
143
foundry/packages/frontend/src/features/sessions/model.test.ts
Normal 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"
|
||||
});
|
||||
});
|
||||
});
|
||||
141
foundry/packages/frontend/src/features/sessions/model.ts
Normal file
141
foundry/packages/frontend/src/features/sessions/model.ts
Normal 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 };
|
||||
}
|
||||
84
foundry/packages/frontend/src/features/tasks/model.test.ts
Normal file
84
foundry/packages/frontend/src/features/tasks/model.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
54
foundry/packages/frontend/src/features/tasks/model.ts
Normal file
54
foundry/packages/frontend/src/features/tasks/model.ts
Normal 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;
|
||||
}
|
||||
7
foundry/packages/frontend/src/foundry-client-view-model.d.ts
vendored
Normal file
7
foundry/packages/frontend/src/foundry-client-view-model.d.ts
vendored
Normal 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";
|
||||
}
|
||||
7
foundry/packages/frontend/src/lib/backend.ts
Normal file
7
foundry/packages/frontend/src/lib/backend.ts
Normal 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,
|
||||
});
|
||||
33
foundry/packages/frontend/src/lib/env.ts
Normal file
33
foundry/packages/frontend/src/lib/env.ts
Normal 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";
|
||||
79
foundry/packages/frontend/src/lib/mock-app.ts
Normal file
79
foundry/packages/frontend/src/lib/mock-app.ts
Normal 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;
|
||||
}
|
||||
11
foundry/packages/frontend/src/lib/workbench-routing.ts
Normal file
11
foundry/packages/frontend/src/lib/workbench-routing.ts
Normal 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;
|
||||
}
|
||||
11
foundry/packages/frontend/src/lib/workbench-runtime.mock.ts
Normal file
11
foundry/packages/frontend/src/lib/workbench-runtime.mock.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
38
foundry/packages/frontend/src/lib/workbench.test.ts
Normal file
38
foundry/packages/frontend/src/lib/workbench.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
18
foundry/packages/frontend/src/lib/workbench.ts
Normal file
18
foundry/packages/frontend/src/lib/workbench.ts
Normal 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;
|
||||
}
|
||||
33
foundry/packages/frontend/src/main.tsx
Normal file
33
foundry/packages/frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
207
foundry/packages/frontend/src/styles.css
Normal file
207
foundry/packages/frontend/src/styles.css
Normal 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;
|
||||
}
|
||||
16
foundry/packages/frontend/tsconfig.json
Normal file
16
foundry/packages/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
51
foundry/packages/frontend/vite.config.ts
Normal file
51
foundry/packages/frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
8
foundry/packages/frontend/vitest.config.ts
Normal file
8
foundry/packages/frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue