Polish Foundry desktop UI: billing redesign, sidebar hover menu, org switching fix

- Redesign billing page with task-hours pricing model (Free: 8h, Pro: 200h/seat)
- Add bulk hour purchase packages and Stripe payment management
- Remove Usage nav section, add upgrade CTA in Members for free plan
- Fix gear icon to open menu on hover with debounced timers
- Fix org switching in workspace flyout (portal outside-click detection)
- Fix tab strip padding when sidebar is collapsed
- Update website components and Tauri config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicholas Kissel 2026-03-11 19:34:25 -07:00
parent f6656a90af
commit ed6e6f6fa5
24 changed files with 1746 additions and 1028 deletions

View file

@ -1,5 +1,5 @@
[package]
name = "foundry-desktop"
name = "foundry"
version = "0.1.0"
edition = "2021"

View file

@ -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.");

View file

@ -1,5 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
foundry_desktop::run();
foundry::run();
}

View file

@ -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
}

View file

@ -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 <MockSignInPage />;
}
function AccountRoute() {
const snapshot = useMockAppSnapshot();
if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) {
return <AppLoadingScreen label="Loading account..." />;
}
if (snapshot.auth.status === "signed_out") {
return <Navigate to="/signin" replace />;
}
return <MockAccountSettingsPage />;
}
function OrganizationsRoute() {
const snapshot = useMockAppSnapshot();
if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) {

View file

@ -72,6 +72,12 @@ const TranscriptPanel = memo(function TranscriptPanel({
onSetActiveTabId,
onSetLastAgentTabId,
onSetOpenDiffs,
sidebarCollapsed,
onToggleSidebar,
onSidebarPeekStart,
onSidebarPeekEnd,
rightSidebarCollapsed,
onToggleRightSidebar,
}: {
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
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<ModelId>("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}
/>
<div
style={{
@ -456,11 +474,10 @@ const TranscriptPanel = memo(function TranscriptPanel({
backgroundColor: "#09090b",
overflow: "hidden",
borderTopLeftRadius: "12px",
borderTopRightRadius: rightSidebarCollapsed ? "12px" : 0,
borderBottomLeftRadius: "24px",
borderLeft: "1px solid rgba(255, 255, 255, 0.10)",
borderRight: "1px solid rgba(255, 255, 255, 0.10)",
borderTop: "1px solid rgba(255, 255, 255, 0.10)",
borderBottom: "1px solid rgba(255, 255, 255, 0.10)",
borderBottomRightRadius: rightSidebarCollapsed ? "24px" : 0,
border: "1px solid rgba(255, 255, 255, 0.10)",
}}
>
<TabStrip
@ -478,6 +495,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
onCloseTab={closeTab}
onCloseDiffTab={closeDiffTab}
onAddTab={addTab}
sidebarCollapsed={sidebarCollapsed}
/>
{activeDiff ? (
<DiffContent
@ -873,6 +891,7 @@ function MockWorkspaceOrgBar() {
}
export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: MockLayoutProps) {
const [css] = useStyletron();
const navigate = useNavigate();
const taskWorkbenchClient = useMemo(() => getTaskWorkbenchClient(workspaceId), [workspaceId]);
const viewModel = useSyncExternalStore(
@ -912,6 +931,17 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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 ? (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 10000,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "24px",
background: "rgba(0, 0, 0, 0.68)",
}}
data-testid="onboarding-star-repo-modal"
>
<div
style={{
width: "min(440px, 100%)",
border: "1px solid rgba(255, 255, 255, 0.10)",
borderRadius: "12px",
background: "rgba(24, 24, 27, 0.98)",
backdropFilter: "blur(16px)",
boxShadow: "0 24px 64px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04)",
padding: "28px",
display: "flex",
flexDirection: "column",
gap: "20px",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "6px",
fontSize: "11px",
letterSpacing: "0.06em",
textTransform: "uppercase",
fontWeight: 600,
color: "rgba(255, 255, 255, 0.4)",
}}
>
<svg width="14" height="14" viewBox="0 0 130 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="1" width="126" height="126" rx="44" fill="#0F0F0F" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M88.0429 44.2658C89.3803 43.625 90.8907 44.1955 91.5731 45.3776C92.2556 46.5596 91.9945 48.1529 90.7709 48.9907L72.3923 62.885C71.8013 63.2262 71.4248 63.7062 71.1029 64.2861C70.781 64.8659 70.5554 65.3922 70.5443 66.0553L67.7403 88.9495C67.521 90.3894 66.4114 91.423 64.9867 91.4576C63.5619 91.4922 62.3731 90.3429 62.24 88.9751L59.3859 66.0642C59.3971 65.4011 59.2126 64.8489 58.8714 64.2579C58.5302 63.6669 58.1442 63.231 57.5643 62.9091L39.15 48.9819C38.032 48.1828 37.6311 46.5786 38.3734 45.362C39.1157 44.1454 40.5656 43.7013 41.9223 44.2314L63.1512 53.2502C63.731 53.5721 64.2996 53.6398 64.9627 53.651C65.6259 53.6622 66.2298 53.5761 66.8208 53.2349L88.0429 44.2658Z"
fill="white"
/>
<rect x="19.25" y="18.25" width="91.5" height="91.5" rx="25.75" stroke="#F0F0F0" strokeWidth="8.5" />
</svg>
Welcome to Foundry
</div>
<h2 style={{ margin: 0, fontSize: "18px", fontWeight: 500, lineHeight: 1.3 }}>Support Sandbox Agent</h2>
<p style={{ margin: 0, color: "rgba(255, 255, 255, 0.55)", fontSize: "13px", lineHeight: 1.6 }}>
Star the repo to help us grow and stay up to date with new releases.
</p>
</div>
{starRepoError ? (
<div
style={{
borderRadius: "8px",
border: "1px solid rgba(255, 110, 110, 0.24)",
background: "rgba(255, 110, 110, 0.06)",
padding: "10px 12px",
color: "#ff9b9b",
fontSize: "12px",
}}
data-testid="onboarding-star-repo-error"
>
{starRepoError}
</div>
) : null}
<div style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}>
<button
type="button"
onClick={dismissStarRepoPrompt}
style={{
border: "1px solid rgba(255, 255, 255, 0.10)",
borderRadius: "6px",
padding: "8px 14px",
background: "rgba(255, 255, 255, 0.05)",
color: "rgba(255, 255, 255, 0.7)",
cursor: "pointer",
fontSize: "12px",
fontWeight: 500,
transition: "all 160ms ease",
}}
>
Maybe later
</button>
<button
type="button"
onClick={starSandboxAgentRepo}
disabled={starRepoPending}
style={{
border: 0,
borderRadius: "6px",
padding: "8px 14px",
background: starRepoPending ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.12)",
color: "#ffffff",
cursor: starRepoPending ? "progress" : "pointer",
fontSize: "12px",
fontWeight: 600,
transition: "all 160ms ease",
}}
data-testid="onboarding-star-repo-submit"
>
{starRepoPending ? "Starring..." : "Star the repo"}
</button>
</div>
</div>
</div>
) : 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
</div>
) : 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
/>
</div>
</div>
{leftSidebarOpen ? null : (
<div style={{ flexShrink: 0, padding: "6px 4px 0 6px", paddingTop: isDesktop ? "38px" : "6px" }}>
<div style={collapsedToggleStyle} onClick={() => setLeftSidebarOpen(true)}>
<PanelLeft size={16} />
</div>
</div>
)}
<div style={contentFrameStyle}>
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
<SPanel $style={{ backgroundColor: "#09090b", flex: 1, minWidth: 0 }}>
{!leftSidebarOpen || !rightSidebarOpen ? (
<div style={{ display: "flex", alignItems: "center", padding: "8px 8px 0 8px" }}>
{leftSidebarOpen ? null : (
<div className={collapsedToggleClass} onClick={() => setLeftSidebarOpen(true)}>
<PanelLeft size={14} />
</div>
)}
<div style={{ flex: 1 }} />
{rightSidebarOpen ? null : (
<div className={collapsedToggleClass} onClick={() => setRightSidebarOpen(true)}>
<PanelRight size={14} />
</div>
)}
</div>
) : null}
<ScrollBody>
<div
style={{
@ -1528,13 +1420,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
</div>
</div>
</div>
{rightSidebarOpen ? null : (
<div style={{ flexShrink: 0, padding: "6px 6px 0 4px" }}>
<div style={collapsedToggleStyle} onClick={() => setRightSidebarOpen(true)}>
<PanelRight size={16} />
</div>
</div>
)}
</Shell>
</>
);
@ -1543,7 +1428,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
return (
<>
{dragRegion}
<Shell>
<Shell $style={{ position: "relative" }}>
<div
style={{
width: leftSidebarOpen ? `${leftWidth}px` : 0,
@ -1572,13 +1457,59 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
/>
</div>
</div>
{leftSidebarOpen ? null : (
<div style={{ flexShrink: 0, padding: "6px 4px 0 6px", paddingTop: isDesktop ? "38px" : "6px" }}>
<div style={collapsedToggleStyle} onClick={() => setLeftSidebarOpen(true)}>
<PanelLeft size={16} />
{!leftSidebarOpen && leftSidebarPeeking ? (
<>
<div
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.4)",
zIndex: 99,
}}
onClick={() => setLeftSidebarPeeking(false)}
onMouseEnter={endPeek}
/>
<div
style={{
position: "absolute",
top: 0,
left: 0,
bottom: 0,
width: `${leftWidth}px`,
zIndex: 100,
display: "flex",
flexDirection: "column",
boxShadow: "4px 0 24px rgba(0, 0, 0, 0.5)",
}}
onMouseEnter={startPeek}
onMouseLeave={endPeek}
>
<Sidebar
projects={projects}
newTaskRepos={viewModel.repos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
activeId={activeTask.id}
onSelect={(id) => {
selectTask(id);
setLeftSidebarPeeking(false);
}}
onCreate={createTask}
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderProjects={reorderProjects}
onToggleSidebar={() => {
setLeftSidebarPeeking(false);
setLeftSidebarOpen(true);
}}
/>
</div>
</div>
)}
</>
) : null}
<div style={contentFrameStyle}>
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
@ -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)}
/>
</div>
{rightSidebarOpen ? <PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} /> : null}
@ -1626,13 +1566,6 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
</div>
</div>
</div>
{rightSidebarOpen ? null : (
<div style={{ flexShrink: 0, padding: "6px 6px 0 4px" }}>
<div style={collapsedToggleStyle} onClick={() => setRightSidebarOpen(true)}>
<PanelRight size={16} />
</div>
</div>
)}
</Shell>
</>
);

View file

@ -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 (
<SPanel $style={{ backgroundColor: "#09090b" }}>
<PanelHeaderBar $style={{ backgroundColor: "#0f0f11", borderBottom: "none" }}>
<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;
}
<SPanel $style={{ backgroundColor: "#09090b", minWidth: 0 }}>
<PanelHeaderBar $style={{ backgroundColor: "#0f0f11", borderBottom: "none", overflow: "hidden" }}>
<div ref={headerRef} className={css({ display: "flex", alignItems: "center", flex: 1, minWidth: 0, justifyContent: "flex-end", gap: "2px" })}>
{!isTerminal ? (
<div className={css({ display: "flex", alignItems: "center", gap: "2px", flexShrink: 1, minWidth: 0 })}>
<button
onClick={() => {
if (pullRequestUrl) {
window.open(pullRequestUrl, "_blank", "noopener,noreferrer");
return;
}
onPublishPr();
onPublishPr();
}}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: compact ? "4px 6px" : "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
flexShrink: 0,
color: theme.colors.contentSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: theme.colors.contentPrimary },
})}
>
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
{!compact && <span>{pullRequestUrl ? "Open PR" : "Publish PR"}</span>}
</button>
<button
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: compact ? "4px 6px" : "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
flexShrink: 0,
color: theme.colors.contentSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: theme.colors.contentPrimary },
})}
>
<ArrowUpFromLine size={12} style={{ flexShrink: 0 }} />
{!compact && <span>Push</span>}
</button>
<button
onClick={onArchive}
className={css({
appearance: "none",
WebkitAppearance: "none",
background: "none",
border: "none",
margin: "0",
boxSizing: "border-box",
display: "inline-flex",
alignItems: "center",
gap: "5px",
padding: compact ? "4px 6px" : "4px 10px",
borderRadius: "6px",
fontSize: "11px",
fontWeight: 500,
lineHeight: 1,
whiteSpace: "nowrap",
flexShrink: 0,
color: theme.colors.contentSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: theme.colors.contentPrimary },
})}
>
<Archive size={12} style={{ flexShrink: 0 }} />
{!compact && <span>Archive</span>}
</button>
</div>
) : null}
{onToggleSidebar ? (
<div
role="button"
tabIndex={0}
onClick={onToggleSidebar}
onKeyDown={(event) => {
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)" },
})}
>
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
<span className={css({ "@media screen and (max-width: 768px)": { display: "none" } })}>{pullRequestUrl ? "Open PR" : "Publish PR"}</span>
</button>
<button
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",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<ArrowUpFromLine size={12} style={{ flexShrink: 0 }} />{" "}
<span className={css({ "@media screen and (max-width: 768px)": { display: "none" } })}>Push</span>
</button>
<button
onClick={onArchive}
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",
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: "#ffffff" },
})}
>
<Archive size={12} style={{ flexShrink: 0 }} />{" "}
<span className={css({ "@media screen and (max-width: 768px)": { display: "none" } })}>Archive</span>
</button>
</div>
) : null}
{onToggleSidebar ? (
<div
role="button"
tabIndex={0}
onClick={onToggleSidebar}
onKeyDown={(event) => {
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)" },
})}
>
<PanelRight size={14} />
</div>
) : null}
<PanelRight size={14} />
</div>
) : null}
</div>
</PanelHeaderBar>
<div

View file

@ -1,10 +1,26 @@
import { memo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import { LabelSmall, LabelXSmall } from "baseui/typography";
import { ChevronDown, ChevronUp, CloudUpload, GitPullRequestDraft, ListChecks, PanelLeft, Plus } from "lucide-react";
import {
ChevronDown,
ChevronRight,
ChevronUp,
CloudUpload,
CreditCard,
GitPullRequestDraft,
ListChecks,
LogOut,
PanelLeft,
Plus,
Settings,
User,
} from "lucide-react";
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
@ -400,7 +416,343 @@ export const Sidebar = memo(function Sidebar({
})}
</div>
</ScrollBody>
<SidebarFooter />
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);
});
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<HTMLDivElement>(null);
const flyoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const workspaceTriggerRef = useRef<HTMLDivElement>(null);
const flyoutRef = useRef<HTMLDivElement>(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: <Settings size={14} />,
label: "Settings",
onClick: () => {
setOpen(false);
void navigate({ to: "/organizations/$organizationId/settings" as never, params: { organizationId: organization.id } as never });
},
},
{
icon: <CreditCard size={14} />,
label: "Billing",
onClick: () => {
setOpen(false);
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: organization.id } as never });
},
},
);
}
menuItems.push(
{
icon: <User size={14} />,
label: "Account",
onClick: () => {
setOpen(false);
void navigate({ to: "/account" as never });
},
},
{
icon: <LogOut size={14} />,
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 (
<div
ref={containerRef}
onMouseEnter={() => {
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 ? (
<div
className={css({
position: "absolute",
bottom: "100%",
left: "8px",
right: "8px",
marginBottom: "4px",
zIndex: 9999,
})}
>
<div className={popoverStyle}>
{/* Workspace flyout trigger */}
{organization ? (
<div ref={workspaceTriggerRef} onMouseEnter={openFlyout} onMouseLeave={closeFlyout}>
<button
type="button"
onClick={() => setWorkspaceFlyoutOpen((prev) => !prev)}
className={css({
...menuButtonStyle(workspaceFlyoutOpen),
fontWeight: 500,
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)",
color: "#ffffff",
},
})}
>
<span
className={css({
width: "18px",
height: "18px",
borderRadius: "4px",
background: `linear-gradient(135deg, ${projectIconColor(organization.settings.displayName)}, ${projectIconColor(organization.settings.displayName + "x")})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "9px",
fontWeight: 700,
color: "#ffffff",
flexShrink: 0,
})}
>
{organization.settings.displayName.charAt(0).toUpperCase()}
</span>
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{organization.settings.displayName}
</span>
<ChevronRight size={12} className={css({ flexShrink: 0, color: "rgba(255, 255, 255, 0.35)" })} />
</button>
</div>
) : null}
{/* Workspace flyout portal */}
{workspaceFlyoutOpen && organization && flyoutPos
? createPortal(
<div
ref={flyoutRef}
className={css({
position: "fixed",
top: `${flyoutPos.top}px`,
left: `${flyoutPos.left}px`,
minWidth: "200px",
zIndex: 10000,
})}
onMouseEnter={() => {
openFlyout();
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
}}
onMouseLeave={() => {
closeFlyout();
hoverTimerRef.current = setTimeout(() => {
setOpen(false);
setWorkspaceFlyoutOpen(false);
}, 200);
}}
>
<div className={popoverStyle}>
{eligibleOrganizations(snapshot).map((org) => {
const isActive = organization.id === org.id;
return (
<button
key={org.id}
type="button"
onClick={() => {
if (!isActive) switchToOrg(org);
else {
setOpen(false);
setWorkspaceFlyoutOpen(false);
}
}}
className={css({
...menuButtonStyle(isActive),
fontWeight: isActive ? 600 : 400,
color: isActive ? "#ffffff" : "rgba(255, 255, 255, 0.65)",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)",
color: "#ffffff",
},
})}
>
<span
className={css({
width: "18px",
height: "18px",
borderRadius: "4px",
background: `linear-gradient(135deg, ${projectIconColor(org.settings.displayName)}, ${projectIconColor(org.settings.displayName + "x")})`,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "9px",
fontWeight: 700,
color: "#ffffff",
flexShrink: 0,
})}
>
{org.settings.displayName.charAt(0).toUpperCase()}
</span>
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{org.settings.displayName}
</span>
</button>
);
})}
</div>
</div>,
document.body,
)
: null}
{menuItems.map((item) => (
<button
key={item.label}
type="button"
onClick={item.onClick}
className={css({
...menuButtonStyle(false),
color: item.danger ? "#ffa198" : "rgba(255, 255, 255, 0.75)",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)",
color: item.danger ? "#ff6b6b" : "#ffffff",
},
})}
>
{item.icon}
{item.label}
</button>
))}
</div>
</div>
) : null}
<div className={css({ padding: "8px" })}>
<button
type="button"
onClick={() => {
if (hoverTimerRef.current) clearTimeout(hoverTimerRef.current);
setOpen((prev) => {
if (prev) setWorkspaceFlyoutOpen(false);
return !prev;
});
}}
className={css({
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "28px",
height: "28px",
borderRadius: "6px",
border: "none",
background: open ? "rgba(255, 255, 255, 0.06)" : "transparent",
color: open ? "#ffffff" : "#71717a",
cursor: "pointer",
transition: "all 160ms ease",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.06)",
color: "#a1a1aa",
},
})}
>
<Settings size={14} />
</button>
</div>
</div>
);
}

View file

@ -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",

View file

@ -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 (
<PanelHeaderBar $style={{ backgroundColor: "#0f0f11", borderBottom: "none" }}>
<PanelHeaderBar $style={{ backgroundColor: "#0f0f11", borderBottom: "none", paddingLeft: needsTrafficLightInset ? "74px" : "14px" }}>
{sidebarCollapsed && onToggleSidebar ? (
<div
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: "#71717a",
flexShrink: 0,
":hover": { color: "#a1a1aa", backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
onClick={onToggleSidebar}
onMouseEnter={onSidebarPeekStart}
onMouseLeave={onSidebarPeekEnd}
>
<PanelLeft size={14} />
</div>
) : null}
{editingField === "title" ? (
<input
autoFocus
@ -170,6 +205,25 @@ export const TranscriptHeader = memo(function TranscriptHeader({
<span className={css({ "@media screen and (max-width: 768px)": { display: "none" } })}>{activeTab.unread ? "Mark read" : "Mark unread"}</span>
</button>
) : null}
{rightSidebarCollapsed && onToggleRightSidebar ? (
<div
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: "#71717a",
flexShrink: 0,
":hover": { color: "#a1a1aa", backgroundColor: "rgba(255, 255, 255, 0.06)" },
})}
onClick={onToggleRightSidebar}
>
<PanelRight size={14} />
</div>
) : null}
</PanelHeaderBar>
);
});

File diff suppressed because it is too large Load diff