diff --git a/foundry/packages/desktop/src-tauri/Cargo.toml b/foundry/packages/desktop/src-tauri/Cargo.toml index ad8298d..bf86188 100644 --- a/foundry/packages/desktop/src-tauri/Cargo.toml +++ b/foundry/packages/desktop/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "foundry-desktop" +name = "foundry" version = "0.1.0" edition = "2021" diff --git a/foundry/packages/desktop/src-tauri/src/lib.rs b/foundry/packages/desktop/src-tauri/src/lib.rs index 8e75db1..f8117bc 100644 --- a/foundry/packages/desktop/src-tauri/src/lib.rs +++ b/foundry/packages/desktop/src-tauri/src/lib.rs @@ -1,5 +1,5 @@ use std::sync::Mutex; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_shell::process::CommandChild; use tauri_plugin_shell::ShellExt; @@ -95,6 +95,29 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![get_backend_url, backend_health]) .setup(|app| { + // Create main window programmatically so we can set traffic light position + let url = if cfg!(debug_assertions) { + WebviewUrl::External("http://localhost:4173".parse().unwrap()) + } else { + WebviewUrl::default() + }; + + let mut builder = WebviewWindowBuilder::new(app, "main", url) + .title("Foundry") + .inner_size(1280.0, 800.0) + .min_inner_size(900.0, 600.0) + .resizable(true) + .theme(Some(tauri::Theme::Dark)) + .title_bar_style(tauri::TitleBarStyle::Overlay) + .hidden_title(true); + + #[cfg(target_os = "macos")] + { + builder = builder.traffic_light_position(LogicalPosition::new(14.0, 14.0)); + } + + builder.build()?; + // In debug mode, assume the developer is running the backend externally if cfg!(debug_assertions) { eprintln!("[foundry-desktop] Dev mode: skipping sidecar spawn. Run the backend separately."); diff --git a/foundry/packages/desktop/src-tauri/src/main.rs b/foundry/packages/desktop/src-tauri/src/main.rs index 1ce1b8b..bc7e592 100644 --- a/foundry/packages/desktop/src-tauri/src/main.rs +++ b/foundry/packages/desktop/src-tauri/src/main.rs @@ -1,5 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - foundry_desktop::run(); + foundry::run(); } diff --git a/foundry/packages/desktop/src-tauri/tauri.conf.json b/foundry/packages/desktop/src-tauri/tauri.conf.json index 7f36ca0..53221cb 100644 --- a/foundry/packages/desktop/src-tauri/tauri.conf.json +++ b/foundry/packages/desktop/src-tauri/tauri.conf.json @@ -4,24 +4,12 @@ "version": "0.1.0", "identifier": "dev.sandboxagent.foundry", "build": { - "beforeDevCommand": "VITE_DESKTOP=1 pnpm --filter @sandbox-agent/foundry-frontend dev", + "beforeDevCommand": "FOUNDRY_FRONTEND_CLIENT_MODE=mock VITE_DESKTOP=1 pnpm --filter @sandbox-agent/foundry-frontend dev", "devUrl": "http://localhost:4173", "frontendDist": "../frontend-dist" }, "app": { - "windows": [ - { - "title": "Foundry", - "width": 1280, - "height": 800, - "minWidth": 900, - "minHeight": 600, - "resizable": true, - "theme": "Dark", - "titleBarStyle": "Overlay", - "hiddenTitle": true - } - ], + "windows": [], "security": { "csp": null } diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx index 834ec71..cc33ba7 100644 --- a/foundry/packages/frontend/src/app/router.tsx +++ b/foundry/packages/frontend/src/app/router.tsx @@ -4,6 +4,7 @@ import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router"; import { MockLayout } from "../components/mock-layout"; import { + MockAccountSettingsPage, MockHostedCheckoutPage, MockOrganizationBillingPage, MockOrganizationSelectorPage, @@ -30,6 +31,12 @@ const signInRoute = createRoute({ component: SignInRoute, }); +const accountRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/account", + component: AccountRoute, +}); + const organizationsRoute = createRoute({ getParentRoute: () => rootRoute, path: "/organizations", @@ -84,6 +91,7 @@ const repoRoute = createRoute({ const routeTree = rootRoute.addChildren([ indexRoute, signInRoute, + accountRoute, organizationsRoute, organizationSettingsRoute, organizationBillingRoute, @@ -152,6 +160,18 @@ function SignInRoute() { return ; } +function AccountRoute() { + const snapshot = useMockAppSnapshot(); + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + if (snapshot.auth.status === "signed_out") { + return ; + } + + return ; +} + function OrganizationsRoute() { const snapshot = useMockAppSnapshot(); if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index f2f728c..a8dec5d 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -72,6 +72,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetActiveTabId, onSetLastAgentTabId, onSetOpenDiffs, + sidebarCollapsed, + onToggleSidebar, + onSidebarPeekStart, + onSidebarPeekEnd, + rightSidebarCollapsed, + onToggleRightSidebar, }: { taskWorkbenchClient: ReturnType; task: Task; @@ -82,6 +88,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetActiveTabId: (tabId: string | null) => void; onSetLastAgentTabId: (tabId: string | null) => void; onSetOpenDiffs: (paths: string[]) => void; + sidebarCollapsed?: boolean; + onToggleSidebar?: () => void; + onSidebarPeekStart?: () => void; + onSidebarPeekEnd?: () => void; + rightSidebarCollapsed?: boolean; + onToggleRightSidebar?: () => void; }) { const [defaultModel, setDefaultModel] = useState("claude-sonnet-4"); const [editingField, setEditingField] = useState<"title" | "branch" | null>(null); @@ -446,6 +458,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ setTabUnread(activeAgentTab.id, unread); } }} + sidebarCollapsed={sidebarCollapsed} + onToggleSidebar={onToggleSidebar} + onSidebarPeekStart={onSidebarPeekStart} + onSidebarPeekEnd={onSidebarPeekEnd} + rightSidebarCollapsed={rightSidebarCollapsed} + onToggleRightSidebar={onToggleRightSidebar} />
{activeDiff ? ( getTaskWorkbenchClient(workspaceId), [workspaceId]); const viewModel = useSyncExternalStore( @@ -912,6 +931,17 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M const autoCreatingSessionForTaskRef = useRef>(new Set()); const [leftSidebarOpen, setLeftSidebarOpen] = useState(true); const [rightSidebarOpen, setRightSidebarOpen] = useState(true); + const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false); + const peekTimeoutRef = useRef | null>(null); + + const startPeek = useCallback(() => { + if (peekTimeoutRef.current) clearTimeout(peekTimeoutRef.current); + setLeftSidebarPeeking(true); + }, []); + + const endPeek = useCallback(() => { + peekTimeoutRef.current = setTimeout(() => setLeftSidebarPeeking(false), 200); + }, []); useEffect(() => { leftWidthRef.current = leftWidth; @@ -1212,154 +1242,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M [activeTask, lastAgentTabIdByTask], ); - const dismissStarRepoPrompt = useCallback(() => { - setStarRepoError(null); - try { - globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "dismissed"); - } catch { - // ignore storage failures - } - setStarRepoPromptOpen(false); - }, []); - - const starSandboxAgentRepo = useCallback(() => { - setStarRepoPending(true); - setStarRepoError(null); - void backendClient - .starSandboxAgentRepo(workspaceId) - .then(() => { - try { - globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "completed"); - } catch { - // ignore storage failures - } - setStarRepoPromptOpen(false); - }) - .catch((error) => { - setStarRepoError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => { - setStarRepoPending(false); - }); - }, [workspaceId]); - - const starRepoPrompt = starRepoPromptOpen ? ( -
-
-
-
- - - - - - Welcome to Foundry -
-

Support Sandbox Agent

-

- Star the repo to help us grow and stay up to date with new releases. -

-
- - {starRepoError ? ( -
- {starRepoError} -
- ) : null} - -
- - -
-
-
- ) : null; - const isDesktop = !!import.meta.env.VITE_DESKTOP; const onDragMouseDown = useCallback((event: ReactPointerEvent) => { if (event.button !== 0) return; @@ -1397,7 +1279,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
) : null; - const collapsedToggleStyle: React.CSSProperties = { + const collapsedToggleClass = css({ width: "26px", height: "26px", borderRadius: "6px", @@ -1409,7 +1291,8 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M position: "relative", zIndex: 9999, flexShrink: 0, - }; + ":hover": { color: "#a1a1aa", backgroundColor: "rgba(255, 255, 255, 0.06)" }, + }); const sidebarTransition = "width 200ms ease"; const contentFrameStyle: React.CSSProperties = { @@ -1420,6 +1303,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M overflow: "hidden", marginBottom: "8px", marginRight: "8px", + marginLeft: leftSidebarOpen ? 0 : "8px", }; if (!activeTask) { @@ -1455,16 +1339,24 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M /> - {leftSidebarOpen ? null : ( -
-
setLeftSidebarOpen(true)}> - -
-
- )}
{leftSidebarOpen ? : null} + {!leftSidebarOpen || !rightSidebarOpen ? ( +
+ {leftSidebarOpen ? null : ( +
setLeftSidebarOpen(true)}> + +
+ )} +
+ {rightSidebarOpen ? null : ( +
setRightSidebarOpen(true)}> + +
+ )} +
+ ) : null}
- {rightSidebarOpen ? null : ( -
-
setRightSidebarOpen(true)}> - -
-
- )} ); @@ -1543,7 +1428,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M return ( <> {dragRegion} - +
- {leftSidebarOpen ? null : ( -
-
setLeftSidebarOpen(true)}> - + {!leftSidebarOpen && leftSidebarPeeking ? ( + <> +
setLeftSidebarPeeking(false)} + onMouseEnter={endPeek} + /> +
+ { + selectTask(id); + setLeftSidebarPeeking(false); + }} + onCreate={createTask} + onSelectNewTaskRepo={setSelectedNewTaskRepoId} + onMarkUnread={markTaskUnread} + onRenameTask={renameTask} + onRenameBranch={renameBranch} + onReorderProjects={reorderProjects} + onToggleSidebar={() => { + setLeftSidebarPeeking(false); + setLeftSidebarOpen(true); + }} + />
-
- )} + + ) : null}
{leftSidebarOpen ? : null}
@@ -1598,6 +1529,15 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M onSetOpenDiffs={(paths) => { setOpenDiffsByTask((current) => ({ ...current, [activeTask.id]: paths })); }} + sidebarCollapsed={!leftSidebarOpen} + onToggleSidebar={() => { + setLeftSidebarPeeking(false); + setLeftSidebarOpen(true); + }} + onSidebarPeekStart={startPeek} + onSidebarPeekEnd={endPeek} + rightSidebarCollapsed={!rightSidebarOpen} + onToggleRightSidebar={() => setRightSidebarOpen(true)} />
{rightSidebarOpen ? : null} @@ -1626,13 +1566,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
- {rightSidebarOpen ? null : ( -
-
setRightSidebarOpen(true)}> - -
-
- )} ); diff --git a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx index 0d60c91..65b057b 100644 --- a/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -108,6 +108,16 @@ export const RightSidebar = memo(function RightSidebar({ const contextMenu = useContextMenu(); const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]); const isTerminal = task.status === "archived"; + const [compact, setCompact] = useState(false); + const headerRef = useCallback((node: HTMLDivElement | null) => { + if (!node) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setCompact(entry.contentRect.width < 400); + } + }); + observer.observe(node); + }, []); const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null; const copyFilePath = useCallback(async (path: string) => { @@ -137,121 +147,128 @@ export const RightSidebar = memo(function RightSidebar({ ); return ( - - -
- {!isTerminal ? ( -
- + + +
+ ) : null} + {onToggleSidebar ? ( +
{ + if (event.key === "Enter" || event.key === " ") onToggleSidebar(); }} className={css({ - appearance: "none", - WebkitAppearance: "none", - background: "none", - border: "none", - margin: "0", - boxSizing: "border-box", - display: "inline-flex", - alignItems: "center", - gap: "6px", - padding: "6px 12px", - borderRadius: "8px", - fontSize: "12px", - fontWeight: 500, - lineHeight: 1, - color: "#e4e4e7", + width: "26px", + height: "26px", + borderRadius: "6px", + color: "#71717a", cursor: "pointer", - transition: "all 200ms ease", - ":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" }, + display: "flex", + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + ":hover": { color: "#a1a1aa", backgroundColor: "rgba(255, 255, 255, 0.06)" }, })} > - - {pullRequestUrl ? "Open PR" : "Publish PR"} - - - -
- ) : null} - {onToggleSidebar ? ( -
{ - if (event.key === "Enter" || event.key === " ") onToggleSidebar(); - }} - className={css({ - width: "26px", - height: "26px", - borderRadius: "6px", - color: "#71717a", - cursor: "pointer", - display: "flex", - alignItems: "center", - justifyContent: "center", - flexShrink: 0, - ":hover": { color: "#a1a1aa", backgroundColor: "rgba(255, 255, 255, 0.06)" }, - })} - > - -
- ) : null} + +
+ ) : null} +
+ {contextMenu.menu ? : null} ); }); + +const menuButtonStyle = (highlight: boolean) => + ({ + display: "flex", + alignItems: "center", + gap: "10px", + width: "100%", + padding: "8px 12px", + borderRadius: "6px", + border: "none", + background: highlight ? "rgba(255, 255, 255, 0.06)" : "transparent", + color: "rgba(255, 255, 255, 0.75)", + cursor: "pointer", + fontSize: "13px", + fontWeight: 400 as const, + textAlign: "left" as const, + transition: "background 120ms ease, color 120ms ease", + }) satisfies React.CSSProperties; + +function SidebarFooter() { + const [css] = useStyletron(); + const navigate = useNavigate(); + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const organization = activeMockOrganization(snapshot); + const [open, setOpen] = useState(false); + const [workspaceFlyoutOpen, setWorkspaceFlyoutOpen] = useState(false); + const containerRef = useRef(null); + const flyoutTimerRef = useRef | null>(null); + const hoverTimerRef = useRef | null>(null); + const workspaceTriggerRef = useRef(null); + const flyoutRef = useRef(null); + const [flyoutPos, setFlyoutPos] = useState<{ top: number; left: number } | null>(null); + + useLayoutEffect(() => { + if (workspaceFlyoutOpen && workspaceTriggerRef.current) { + const rect = workspaceTriggerRef.current.getBoundingClientRect(); + setFlyoutPos({ top: rect.top, left: rect.right + 4 }); + } + }, [workspaceFlyoutOpen]); + + useEffect(() => { + if (!open) return; + function handleClick(event: MouseEvent) { + const target = event.target as Node; + const inContainer = containerRef.current?.contains(target); + const inFlyout = flyoutRef.current?.contains(target); + if (!inContainer && !inFlyout) { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + setOpen(false); + setWorkspaceFlyoutOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [open]); + + const switchToOrg = useCallback( + (org: (typeof snapshot.organizations)[number]) => { + setOpen(false); + setWorkspaceFlyoutOpen(false); + void (async () => { + await client.selectOrganization(org.id); + await navigate({ to: `/workspaces/${org.workspaceId}` as never }); + })(); + }, + [client, navigate], + ); + + const openFlyout = useCallback(() => { + if (flyoutTimerRef.current) clearTimeout(flyoutTimerRef.current); + setWorkspaceFlyoutOpen(true); + }, []); + + const closeFlyout = useCallback(() => { + flyoutTimerRef.current = setTimeout(() => setWorkspaceFlyoutOpen(false), 150); + }, []); + + const menuItems: Array<{ icon: React.ReactNode; label: string; danger?: boolean; onClick: () => void }> = []; + + if (organization) { + menuItems.push( + { + icon: , + label: "Settings", + onClick: () => { + setOpen(false); + void navigate({ to: "/organizations/$organizationId/settings" as never, params: { organizationId: organization.id } as never }); + }, + }, + { + icon: , + label: "Billing", + onClick: () => { + setOpen(false); + void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: organization.id } as never }); + }, + }, + ); + } + + menuItems.push( + { + icon: , + label: "Account", + onClick: () => { + setOpen(false); + void navigate({ to: "/account" as never }); + }, + }, + { + icon: , + label: "Sign Out", + danger: true, + onClick: () => { + setOpen(false); + void (async () => { + await client.signOut(); + await navigate({ to: "/signin" }); + })(); + }, + }, + ); + + const popoverStyle = css({ + borderRadius: "10px", + border: "1px solid rgba(255, 255, 255, 0.10)", + backgroundColor: "#18181b", + boxShadow: "0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04)", + padding: "4px", + display: "flex", + flexDirection: "column", + gap: "2px", + }); + + return ( +
{ + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = setTimeout(() => setOpen(true), 300); + }} + onMouseLeave={() => { + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + setWorkspaceFlyoutOpen(false); + }, 200); + }} + className={css({ position: "relative", flexShrink: 0 })} + > + {open ? ( +
+
+ {/* Workspace flyout trigger */} + {organization ? ( +
+ +
+ ) : null} + + {/* Workspace flyout portal */} + {workspaceFlyoutOpen && organization && flyoutPos + ? createPortal( +
{ + openFlyout(); + if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current); + }} + onMouseLeave={() => { + closeFlyout(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + setWorkspaceFlyoutOpen(false); + }, 200); + }} + > +
+ {eligibleOrganizations(snapshot).map((org) => { + const isActive = organization.id === org.id; + return ( + + ); + })} +
+
, + document.body, + ) + : null} + + {menuItems.map((item) => ( + + ))} +
+
+ ) : null} +
+ +
+
+ ); +} diff --git a/foundry/packages/frontend/src/components/mock-layout/tab-strip.tsx b/foundry/packages/frontend/src/components/mock-layout/tab-strip.tsx index 7c6a764..8bc7582 100644 --- a/foundry/packages/frontend/src/components/mock-layout/tab-strip.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/tab-strip.tsx @@ -21,6 +21,7 @@ export const TabStrip = memo(function TabStrip({ onCloseTab, onCloseDiffTab, onAddTab, + sidebarCollapsed, }: { task: Task; activeTabId: string | null; @@ -36,8 +37,10 @@ export const TabStrip = memo(function TabStrip({ onCloseTab: (tabId: string) => void; onCloseDiffTab: (path: string) => void; onAddTab: () => void; + sidebarCollapsed?: boolean; }) { const [css, theme] = useStyletron(); + const isDesktop = !!import.meta.env.VITE_DESKTOP; const contextMenu = useContextMenu(); return ( @@ -53,7 +56,7 @@ export const TabStrip = memo(function TabStrip({ borderBottom: `1px solid ${theme.colors.borderOpaque}`, gap: "4px", backgroundColor: "#09090b", - paddingLeft: "6px", + paddingLeft: sidebarCollapsed ? "14px" : "6px", height: "41px", minHeight: "41px", overflowX: "auto", diff --git a/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx b/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx index d566ab1..0364b9d 100644 --- a/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx @@ -1,7 +1,7 @@ import { memo } from "react"; import { useStyletron } from "baseui"; import { LabelSmall } from "baseui/typography"; -import { Clock, MailOpen } from "lucide-react"; +import { Clock, MailOpen, PanelLeft, PanelRight } from "lucide-react"; import { PanelHeaderBar } from "./ui"; import { type AgentTab, type Task } from "./view-model"; @@ -16,6 +16,12 @@ export const TranscriptHeader = memo(function TranscriptHeader({ onCommitEditingField, onCancelEditingField, onSetActiveTabUnread, + sidebarCollapsed, + onToggleSidebar, + onSidebarPeekStart, + onSidebarPeekEnd, + rightSidebarCollapsed, + onToggleRightSidebar, }: { task: Task; activeTab: AgentTab | null | undefined; @@ -26,11 +32,40 @@ export const TranscriptHeader = memo(function TranscriptHeader({ onCommitEditingField: (field: "title" | "branch") => void; onCancelEditingField: () => void; onSetActiveTabUnread: (unread: boolean) => void; + sidebarCollapsed?: boolean; + onToggleSidebar?: () => void; + onSidebarPeekStart?: () => void; + onSidebarPeekEnd?: () => void; + rightSidebarCollapsed?: boolean; + onToggleRightSidebar?: () => void; }) { const [css, theme] = useStyletron(); + const isDesktop = !!import.meta.env.VITE_DESKTOP; + const needsTrafficLightInset = isDesktop && sidebarCollapsed; return ( - + + {sidebarCollapsed && onToggleSidebar ? ( +
+ +
+ ) : null} {editingField === "title" ? ( {activeTab.unread ? "Mark read" : "Mark unread"} ) : null} + {rightSidebarCollapsed && onToggleRightSidebar ? ( +
+ +
+ ) : null}
); }); diff --git a/foundry/packages/frontend/src/components/mock-onboarding.tsx b/foundry/packages/frontend/src/components/mock-onboarding.tsx index 1ba96f8..5a44c9d 100644 --- a/foundry/packages/frontend/src/components/mock-onboarding.tsx +++ b/foundry/packages/frontend/src/components/mock-onboarding.tsx @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, 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, Star, Users } from "lucide-react"; +import { ArrowLeft, Clock, CreditCard, FileText, Github, LogOut, Settings, Users } from "lucide-react"; import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; import { isMockFrontendClient } from "../lib/env"; @@ -16,115 +16,156 @@ const planCatalog: Record< { label: string; price: string; + pricePerMonth: number; seats: string; + taskHours: number; summary: string; } > = { free: { label: "Free", price: "$0", + pricePerMonth: 0, seats: "1 seat included", - summary: "Best for a personal workspace and quick evaluations.", + taskHours: 8, + summary: "Get started with up to 8 task hours per month.", }, team: { - label: "Team", - price: "$240/mo", - seats: "5 seats included", - summary: "GitHub org onboarding, shared billing, and seat accrual on first prompt.", + label: "Pro", + price: "$25/mo", + pricePerMonth: 25, + seats: "per seat", + taskHours: 200, + summary: "200 task hours per seat, with the ability to purchase additional hours.", }, }; +const taskHourPackages = [ + { hours: 50, price: 6 }, + { hours: 100, price: 12 }, + { hours: 200, price: 24 }, + { hours: 400, price: 48 }, + { hours: 600, price: 72 }, + { hours: 1000, price: 120 }, +]; + 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", + background: "#09090b", color: "#ffffff", + fontFamily: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif", }; } -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 DesktopDragRegion() { + const isDesktop = !!import.meta.env.VITE_DESKTOP; + const onDragMouseDown = useCallback((event: React.PointerEvent) => { + if (event.button !== 0) return; + const ipc = (window as Record).__TAURI_INTERNALS__ as { invoke: (cmd: string, args?: unknown) => Promise } | undefined; + if (ipc?.invoke) { + ipc.invoke("plugin:window|start_dragging").catch(() => {}); + } + }, []); -function contentWrapStyle(): React.CSSProperties { - return { - width: "min(1180px, calc(100vw - 40px))", - margin: "0 auto", - padding: "28px 0 40px", - display: "flex", - flexDirection: "column", - gap: "20px", - }; + if (!isDesktop) return null; + + return ( +
+
+
+ ); } function primaryButtonStyle(): React.CSSProperties { return { border: 0, - borderRadius: "999px", - padding: "11px 16px", - background: "#ff4f00", - color: "#ffffff", - fontWeight: 700, + borderRadius: "6px", + padding: "6px 12px", + background: "#ffffff", + color: "#09090b", + fontWeight: 500, + fontSize: "12px", cursor: "pointer", + fontFamily: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif", + lineHeight: 1.4, }; } function secondaryButtonStyle(): React.CSSProperties { return { - border: "1px solid rgba(255, 255, 255, 0.16)", - borderRadius: "999px", - padding: "10px 15px", + border: "1px solid rgba(255, 255, 255, 0.10)", + borderRadius: "6px", + padding: "5px 11px", background: "rgba(255, 255, 255, 0.03)", - color: "#ffffff", - fontWeight: 600, + color: "#d4d4d8", + fontWeight: 500, + fontSize: "12px", cursor: "pointer", + fontFamily: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif", + lineHeight: 1.4, }; } function subtleButtonStyle(): React.CSSProperties { return { border: 0, - borderRadius: "999px", - padding: "10px 14px", + borderRadius: "6px", + padding: "6px 10px", background: "rgba(255, 255, 255, 0.05)", - color: "#ffffff", - fontWeight: 600, + color: "#a1a1aa", + fontWeight: 500, + fontSize: "12px", cursor: "pointer", + fontFamily: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif", + lineHeight: 1.4, }; } 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)", + background: "#0f0f11", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "8px", }; } -function badgeStyle(background: string, color = "#f4f4f5"): React.CSSProperties { +function badgeStyle(background: string, color = "#a1a1aa"): React.CSSProperties { return { display: "inline-flex", alignItems: "center", - gap: "6px", - padding: "6px 10px", - borderRadius: "999px", + gap: "4px", + padding: "2px 6px", + borderRadius: "4px", background, color, - fontSize: "12px", - fontWeight: 700, + fontSize: "10px", + fontWeight: 500, letterSpacing: "0.01em", + lineHeight: 1.4, }; } @@ -168,86 +209,20 @@ function githubBadge(organization: FoundryOrganization) { return Install GitHub App; } -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 ( -
-
-
-
- SA -
-
-
{eyebrow}
-
{title}
-
-
-
- {actions} - {user ? ( -
-
-
{user.name}
-
@{user.githubLogin}
-
- {onSignOut ? ( - - ) : null} -
- ) : null} -
-
-
-
{description}
- {children} -
-
- ); -} - function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) { return (
-
{label}
-
{value}
-
{caption}
+
{label}
+
{value}
+
{caption}
); } @@ -257,18 +232,18 @@ function MemberRow({ member }: { member: FoundryOrganizationMember }) {
-
{member.name}
-
{member.email}
+
{member.name}
+
{member.email}
-
{member.role}
+
{member.role}
-
-
+ +
+ {/* Foundry icon */} + + + + + +

-
- Mock Better Auth + GitHub OAuth -
Sign in and land directly in the org onboarding funnel.
-
- {isMockFrontendClient - ? "This mock screen stands in for a basic GitHub OAuth sign-in page. After sign-in, the user moves into the separate organization selector and then the rest of the onboarding funnel." - : "GitHub OAuth starts here. After the callback exchange completes, the app restores the signed-in session and continues into organization selection."} -
-
-
- - GitHub sign-in -
-
- - Org selection -
-
- - Hosted billing -
-
-
-
-
-
Continue to Sandbox Agent
-
- {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."} -
-
- -
-
-
-
{mockAccount.name}
-
- @{mockAccount.githubLogin} · {mockAccount.email} -
-
- {isMockFrontendClient ? mockAccount.label : "Live GitHub identity"} -
-
- {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."} -
-
-
-

+ Sign in to Sandbox Agent Foundry + + +

+ Connect your GitHub account to get started. +

+ + {/* GitHub sign-in button */} + + + {/* Footer */} + + Learn more +
); @@ -404,159 +377,338 @@ export function MockSignInPage() { export function MockOrganizationSelectorPage() { const client = useMockAppClient(); const snapshot = useMockAppSnapshot(); - const user = activeMockUser(snapshot); const organizations: FoundryOrganization[] = eligibleOrganizations(snapshot); const navigate = useNavigate(); - const starterRepo = snapshot.onboarding.starterRepo; - const starterRepoTarget = organizations.find((organization) => organization.kind === "organization") ?? organizations[0] ?? null; return ( - { - void (async () => { - await client.signOut(); - await navigate({ to: "/signin" }); - })(); +
+
-
-
-
- -
Starter repo
-
-
- Star {starterRepo.repoFullName} before entering the main app, or skip it and continue onboarding. This keeps the starter-repo ask - inside the funnel instead of interrupting the workspace later. -
-
- {starterRepo.status === "starred" ? ( - Starred - ) : starterRepo.status === "skipped" ? ( - Skipped for now - ) : ( - Optional - )} + {/* Header */} +
+ + + + +

Select a workspace

+

Choose where you want to work.

-
+ + {/* Workspace list */} +
+ {organizations.map((organization, index) => ( + + ))} +
+ + {/* Footer */} +
-
-
- {organizations.map((organization) => ( -
+ ); +} + +type SettingsSection = "settings" | "members" | "billing" | "docs"; + +function SettingsNavItem({ icon, label, active, onClick }: { icon: React.ReactNode; label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} + +function SettingsContentSection({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {description ?

{description}

: null} +
{children}
+
+ ); +} + +function SettingsRow({ label, description, action }: { label: string; description?: string; action?: React.ReactNode }) { + return ( +
+
+
{label}
+ {description ?
{description}
: null} +
+ {action ?? null} +
+ ); +} + +function SettingsLayout({ + organization, + activeSection, + onSectionChange, + children, +}: { + organization: FoundryOrganization; + activeSection: SettingsSection; + onSectionChange?: (section: SettingsSection) => void; + children: React.ReactNode; +}) { + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const user = activeMockUser(snapshot); + const navigate = useNavigate(); + + const navSections: Array<{ section: SettingsSection; icon: React.ReactNode; label: string }> = [ + { section: "settings", icon: , label: "Settings" }, + { section: "members", icon: , label: "Members" }, + { section: "billing", icon: , label: "Billing & Invoices" }, + { section: "docs", icon: , label: "Docs" }, + ]; + + return ( +
+ +
+ {/* Left nav */} +
+ {/* Back to workspace */} + - - -
+ + Back to workspace + + + {/* User header */} +
+ {user?.name ?? "User"} + + {planCatalog[organization.billing.planId]?.label ?? "Free"} Plan · {user?.email ?? ""} +
- ))} + + {navSections.map((item) => ( + { + if (item.section === "billing") { + void navigate({ to: billingPath(organization) }); + } else if (onSectionChange) { + onSectionChange(item.section); + } else { + void navigate({ to: settingsPath(organization) }); + } + }} + /> + ))} +
+ + {/* Content */} +
+
{children}
+
- +
); } export function MockOrganizationSettingsPage({ organization }: { organization: FoundryOrganization }) { const client = useMockAppClient(); - const snapshot = useMockAppSnapshot(); - const user = activeMockUser(snapshot); const navigate = useNavigate(); + const [section, setSection] = useState("settings"); 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); @@ -565,63 +717,29 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F }, [organization.id, organization.settings.displayName, organization.settings.slug, organization.settings.primaryDomain]); return ( - - - - - - } - onSignOut={() => { - void (async () => { - await client.signOut(); - await navigate({ to: "/signin" }); - })(); - }} - > -
-
-
-
-
-
Organization profile
-
- {isMockFrontendClient ? "Mock org state persisted in the client package." : "Organization profile persisted in the app-shell backend."} -
-
- {statusBadge(organization)} -
-