From e03484848ee2089c00c66d9261ca082410c06ae5 Mon Sep 17 00:00:00 2001 From: Nicholas Kissel Date: Wed, 11 Mar 2026 02:59:58 -0700 Subject: [PATCH] Foundry UI polish: favicon, icon alignment, and border refinements (#236) Add Foundry favicon, fix icon centering across sidebar/composer/header buttons, restore center panel top-left border curve, and position right sidebar border between header actions and tab strip. Co-authored-by: Claude Opus 4.6 --- factory/packages/frontend/index.html | 1 + factory/packages/frontend/public/favicon.svg | 5 + .../frontend/src/components/mock-layout.tsx | 434 +++++++++++------- .../mock-layout/prompt-composer.tsx | 23 +- .../components/mock-layout/right-sidebar.tsx | 310 +++++++------ .../src/components/mock-layout/sidebar.tsx | 183 ++++---- .../src/components/mock-layout/tab-strip.tsx | 1 + .../mock-layout/transcript-header.tsx | 11 +- .../src/components/mock-layout/ui.tsx | 5 +- 9 files changed, 558 insertions(+), 415 deletions(-) create mode 100644 factory/packages/frontend/public/favicon.svg diff --git a/factory/packages/frontend/index.html b/factory/packages/frontend/index.html index 9319e9d..5b71cce 100644 --- a/factory/packages/frontend/index.html +++ b/factory/packages/frontend/index.html @@ -9,6 +9,7 @@ } + Foundry diff --git a/factory/packages/frontend/public/favicon.svg b/factory/packages/frontend/public/favicon.svg new file mode 100644 index 0000000..ec605d6 --- /dev/null +++ b/factory/packages/frontend/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/factory/packages/frontend/src/components/mock-layout.tsx b/factory/packages/frontend/src/components/mock-layout.tsx index 277c49d..e06683b 100644 --- a/factory/packages/frontend/src/components/mock-layout.tsx +++ b/factory/packages/frontend/src/components/mock-layout.tsx @@ -1,14 +1,4 @@ -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, - type PointerEvent as ReactPointerEvent, -} from "react"; +import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent } from "react"; import { useNavigate } from "@tanstack/react-router"; import { useStyletron } from "baseui"; @@ -455,111 +445,189 @@ const TranscriptPanel = memo(function TranscriptPanel({ } }} /> -
- - {activeDiff ? ( - file.path === activeDiff)} - diff={handoff.diffs[activeDiff]} - onAddAttachment={addAttachment} +
+ - ) : handoff.tabs.length === 0 ? ( - -
+ {activeDiff ? ( + file.path === activeDiff)} + diff={handoff.diffs[activeDiff]} + onAddAttachment={addAttachment} + /> + ) : handoff.tabs.length === 0 ? ( +
-

Create the first session

-

- Sessions are where you chat with the agent. Start one now to send the first prompt on this task. -

- +

Create the first session

+

Sessions are where you chat with the agent. Start one now to send the first prompt on this task.

+ +
-
- - ) : ( - - { - void copyMessage(message); - }} - thinkingTimerLabel={thinkingTimerLabel} + + ) : ( + + { + void copyMessage(message); + }} + thinkingTimerLabel={thinkingTimerLabel} + /> + + )} + {!isTerminal && promptTab ? ( + updateDraft(value, attachments)} + onSend={sendMessage} + onStop={stopAgent} + onRemoveAttachment={removeAttachment} + onChangeModel={changeModel} + onSetDefaultModel={setDefaultModel} /> - - )} - {!isTerminal && promptTab ? ( - updateDraft(value, attachments)} - onSend={sendMessage} - onStop={stopAgent} - onRemoveAttachment={removeAttachment} - onChangeModel={changeModel} - onSetDefaultModel={setDefaultModel} - /> - ) : null} + ) : null}
); }); +const LEFT_SIDEBAR_DEFAULT_WIDTH = 340; +const RIGHT_SIDEBAR_DEFAULT_WIDTH = 380; +const SIDEBAR_MIN_WIDTH = 220; +const SIDEBAR_MAX_WIDTH = 600; +const RESIZE_HANDLE_WIDTH = 1; +const LEFT_WIDTH_STORAGE_KEY = "openhandoff:foundry-left-sidebar-width"; +const RIGHT_WIDTH_STORAGE_KEY = "openhandoff:foundry-right-sidebar-width"; + +function readStoredWidth(key: string, fallback: number): number { + if (typeof window === "undefined") return fallback; + const stored = window.localStorage.getItem(key); + const parsed = stored ? Number.parseInt(stored, 10) : Number.NaN; + return Number.isFinite(parsed) ? Math.min(Math.max(parsed, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH) : fallback; +} + +const PanelResizeHandle = memo(function PanelResizeHandle({ onResizeStart, onResize }: { onResizeStart: () => void; onResize: (deltaX: number) => void }) { + const handlePointerDown = useCallback( + (event: ReactPointerEvent) => { + event.preventDefault(); + const startX = event.clientX; + onResizeStart(); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + + const handlePointerMove = (moveEvent: PointerEvent) => { + onResize(moveEvent.clientX - startX); + }; + + const stopResize = () => { + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopResize); + }; + + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopResize, { once: true }); + }, + [onResize, onResizeStart], + ); + + return ( +
+
+
+ ); +}); + const RIGHT_RAIL_MIN_SECTION_HEIGHT = 180; const RIGHT_RAIL_SPLITTER_HEIGHT = 10; const DEFAULT_TERMINAL_HEIGHT = 320; @@ -596,10 +664,7 @@ const RightRail = memo(function RightRail({ const clampTerminalHeight = useCallback((nextHeight: number) => { const railHeight = railRef.current?.getBoundingClientRect().height ?? 0; - const maxHeight = Math.max( - RIGHT_RAIL_MIN_SECTION_HEIGHT, - railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT, - ); + const maxHeight = Math.max(RIGHT_RAIL_MIN_SECTION_HEIGHT, railHeight - RIGHT_RAIL_MIN_SECTION_HEIGHT - RIGHT_RAIL_SPLITTER_HEIGHT); return Math.min(Math.max(nextHeight, RIGHT_RAIL_MIN_SECTION_HEIGHT), maxHeight); }, []); @@ -652,6 +717,7 @@ const RightRail = memo(function RightRail({ ref={railRef} className={css({ minHeight: 0, + flex: 1, display: "flex", flexDirection: "column", backgroundColor: "#090607", @@ -736,18 +802,54 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } } return ordered; }, [rawProjects, projectOrder]); - const reorderProjects = useCallback((fromIndex: number, toIndex: number) => { - const ids = projects.map((p) => p.id); - const [moved] = ids.splice(fromIndex, 1); - ids.splice(toIndex, 0, moved!); - setProjectOrder(ids); - }, [projects]); + const reorderProjects = useCallback( + (fromIndex: number, toIndex: number) => { + const ids = projects.map((p) => p.id); + const [moved] = ids.splice(fromIndex, 1); + ids.splice(toIndex, 0, moved!); + setProjectOrder(ids); + }, + [projects], + ); const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState>({}); const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState>({}); const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState>({}); const [starRepoPromptOpen, setStarRepoPromptOpen] = useState(false); const [starRepoPending, setStarRepoPending] = useState(false); const [starRepoError, setStarRepoError] = useState(null); + const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH)); + const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH)); + const leftWidthRef = useRef(leftWidth); + const rightWidthRef = useRef(rightWidth); + + useEffect(() => { + leftWidthRef.current = leftWidth; + window.localStorage.setItem(LEFT_WIDTH_STORAGE_KEY, String(leftWidth)); + }, [leftWidth]); + + useEffect(() => { + rightWidthRef.current = rightWidth; + window.localStorage.setItem(RIGHT_WIDTH_STORAGE_KEY, String(rightWidth)); + }, [rightWidth]); + + const startLeftRef = useRef(leftWidth); + const startRightRef = useRef(rightWidth); + + const onLeftResize = useCallback((deltaX: number) => { + setLeftWidth(Math.min(Math.max(startLeftRef.current + deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH)); + }, []); + + const onLeftResizeStart = useCallback(() => { + startLeftRef.current = leftWidthRef.current; + }, []); + + const onRightResize = useCallback((deltaX: number) => { + setRightWidth(Math.min(Math.max(startRightRef.current - deltaX, SIDEBAR_MIN_WIDTH), SIDEBAR_MAX_WIDTH)); + }, []); + + const onRightResizeStart = useCallback(() => { + startRightRef.current = rightWidthRef.current; + }, []); const activeHandoff = useMemo(() => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, [handoffs, selectedHandoffId]); @@ -1058,7 +1160,9 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } }} >
-
Welcome to Foundry
+
+ Welcome to Foundry +

Support Sandbox Agent

Star the repo to help us grow and stay up to date with new releases. @@ -1127,17 +1231,20 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } return ( <> - - +

+ +
+ +
- + +
+ +
{starRepoPrompt} @@ -1194,41 +1304,49 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } return ( <> - - { - setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetLastAgentTabId={(tabId) => { - setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetOpenDiffs={(paths) => { - setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); - }} - /> - +
+ +
+ +
+ { + setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + }} + onSetLastAgentTabId={(tabId) => { + setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + }} + onSetOpenDiffs={(paths) => { + setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); + }} + /> +
+ +
+ +
{starRepoPrompt} diff --git a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx b/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx index 970a94a..98d5ad0 100644 --- a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx +++ b/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx @@ -69,9 +69,14 @@ export const PromptComposer = memo(function PromptComposer({ "::placeholder": { color: theme.colors.contentSecondary }, }), submit: css({ - all: "unset", + appearance: "none", + WebkitAppearance: "none", + boxSizing: "border-box", width: "32px", height: "32px", + padding: "0", + margin: "0", + border: "none", borderRadius: "6px", cursor: "pointer", position: "absolute", @@ -80,6 +85,8 @@ export const PromptComposer = memo(function PromptComposer({ display: "flex", alignItems: "center", justifyContent: "center", + lineHeight: 0, + fontSize: 0, color: theme.colors.contentPrimary, transition: "background 200ms ease", backgroundColor: isRunning ? "rgba(255, 255, 255, 0.06)" : "rgba(255, 255, 255, 0.12)", @@ -92,9 +99,12 @@ export const PromptComposer = memo(function PromptComposer({ }, }), submitContent: css({ - display: "inline-flex", + display: "flex", alignItems: "center", justifyContent: "center", + width: "100%", + height: "100%", + lineHeight: 0, color: isRunning ? theme.colors.contentPrimary : "#ffffff", }), }; @@ -157,15 +167,10 @@ export const PromptComposer = memo(function PromptComposer({ allowEmptySubmit={isRunning} submitLabel={isRunning ? "Stop" : "Send"} classNames={composerClassNames} - renderSubmitContent={() => (isRunning ? : )} + renderSubmitContent={() => (isRunning ? : )} renderFooter={() => (
- +
)} /> diff --git a/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx index 69d8717..1ac1092 100644 --- a/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -151,220 +151,230 @@ export const RightSidebar = memo(function RightSidebar({ }} className={css({ all: "unset", - display: "flex", + 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" }, })} > - + {pullRequestUrl ? "Open PR" : "Publish PR"}
) : null} -
-
- - -
- - - {rightTab === "changes" ? ( -
- {handoff.fileChanges.length === 0 ? ( -
- No changes yet -
+ + +
+ + + {rightTab === "changes" ? ( +
+ {handoff.fileChanges.length === 0 ? ( +
+ No changes yet +
+ ) : null} + {handoff.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 (
onOpenDiff(file.path)} + onContextMenu={(event) => openFileMenu(event, file.path)} className={css({ display: "flex", alignItems: "center", - gap: "6px", - flexShrink: 0, - fontSize: "11px", - fontFamily: '"IBM Plex Mono", monospace', + 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)" }, })} > - +{file.added} - -{file.removed} - {file.type} + +
+ {file.path} +
+
+ +{file.added} + -{file.removed} + {file.type} +
+ ); + })} +
+ ) : ( +
+ {handoff.fileTree.length > 0 ? ( + + ) : ( +
+ No files yet
- ); - })} -
- ) : ( -
- {handoff.fileTree.length > 0 ? ( - - ) : ( -
- No files yet -
- )} -
- )} -
+ )} +
+ )} +
{contextMenu.menu ? : null} diff --git a/factory/packages/frontend/src/components/mock-layout/sidebar.tsx b/factory/packages/frontend/src/components/mock-layout/sidebar.tsx index 4aa76a8..4cac6a3 100644 --- a/factory/packages/frontend/src/components/mock-layout/sidebar.tsx +++ b/factory/packages/frontend/src/components/mock-layout/sidebar.tsx @@ -60,15 +60,19 @@ export const Sidebar = memo(function Sidebar({ Tasks - + +
@@ -191,97 +196,93 @@ export const Sidebar = memo(function Sidebar({ {project.label}
- {isCollapsed ? ( - - {formatRelativeAge(project.updatedAtMs)} - - ) : null} + {isCollapsed ? {formatRelativeAge(project.updatedAtMs)} : null} - {!isCollapsed && project.handoffs.map((handoff) => { - const isActive = handoff.id === activeId; - const isDim = handoff.status === "archived"; - const isRunning = handoff.tabs.some((tab) => tab.status === "running"); - const hasUnread = handoff.tabs.some((tab) => tab.unread); - const isDraft = handoff.pullRequest == null || handoff.pullRequest.status === "draft"; - const totalAdded = handoff.fileChanges.reduce((sum, file) => sum + file.added, 0); - const totalRemoved = handoff.fileChanges.reduce((sum, file) => sum + file.removed, 0); - const hasDiffs = totalAdded > 0 || totalRemoved > 0; + {!isCollapsed && + project.handoffs.map((handoff) => { + const isActive = handoff.id === activeId; + const isDim = handoff.status === "archived"; + const isRunning = handoff.tabs.some((tab) => tab.status === "running"); + const hasUnread = handoff.tabs.some((tab) => tab.unread); + const isDraft = handoff.pullRequest == null || handoff.pullRequest.status === "draft"; + const totalAdded = handoff.fileChanges.reduce((sum, file) => sum + file.added, 0); + const totalRemoved = handoff.fileChanges.reduce((sum, file) => sum + file.removed, 0); + const hasDiffs = totalAdded > 0 || totalRemoved > 0; - return ( -
onSelect(handoff.id)} - onContextMenu={(event) => - contextMenu.open(event, [ - { label: "Rename task", onClick: () => onRenameHandoff(handoff.id) }, - { label: "Rename branch", onClick: () => onRenameBranch(handoff.id) }, - { label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) }, - ]) - } - className={css({ - padding: "8px 12px", - borderRadius: "8px", - border: "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)", - }, - })} - > -
-
- -
- - {handoff.title} - - {handoff.pullRequest != null ? ( - - - #{handoff.pullRequest.number} - - {handoff.pullRequest.status === "draft" ? : null} - - ) : ( - - )} - {hasDiffs ? ( -
- +{totalAdded} - -{totalRemoved} + return ( +
onSelect(handoff.id)} + onContextMenu={(event) => + contextMenu.open(event, [ + { label: "Rename task", onClick: () => onRenameHandoff(handoff.id) }, + { label: "Rename branch", onClick: () => onRenameBranch(handoff.id) }, + { label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) }, + ]) + } + className={css({ + padding: "8px 12px", + borderRadius: "8px", + border: "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)", + }, + })} + > +
+
+
- ) : null} - - {formatRelativeAge(handoff.updatedAtMs)} - + + {handoff.title} + + {handoff.pullRequest != null ? ( + + + #{handoff.pullRequest.number} + + {handoff.pullRequest.status === "draft" ? : null} + + ) : ( + + )} + {hasDiffs ? ( +
+ +{totalAdded} + -{totalRemoved} +
+ ) : null} + + {formatRelativeAge(handoff.updatedAtMs)} + +
-
- ); - })} - + ); + })}
); })} diff --git a/factory/packages/frontend/src/components/mock-layout/tab-strip.tsx b/factory/packages/frontend/src/components/mock-layout/tab-strip.tsx index 9f0cd50..03f05ed 100644 --- a/factory/packages/frontend/src/components/mock-layout/tab-strip.tsx +++ b/factory/packages/frontend/src/components/mock-layout/tab-strip.tsx @@ -216,6 +216,7 @@ export const TabStrip = memo(function TabStrip({ padding: "0 10px", cursor: "pointer", opacity: 0.4, + lineHeight: 0, ":hover": { opacity: 0.7 }, flexShrink: 0, })} diff --git a/factory/packages/frontend/src/components/mock-layout/transcript-header.tsx b/factory/packages/frontend/src/components/mock-layout/transcript-header.tsx index 56a9c76..1107324 100644 --- a/factory/packages/frontend/src/components/mock-layout/transcript-header.tsx +++ b/factory/packages/frontend/src/components/mock-layout/transcript-header.tsx @@ -115,7 +115,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({
- + 847 min used
{activeTab ? ( @@ -136,20 +137,22 @@ export const TranscriptHeader = memo(function TranscriptHeader({ onClick={() => onSetActiveTabUnread(!activeTab.unread)} className={css({ all: "unset", - display: "flex", + boxSizing: "border-box", + display: "inline-flex", alignItems: "center", gap: "5px", padding: "4px 10px", borderRadius: "6px", fontSize: "11px", fontWeight: 500, + lineHeight: 1, color: theme.colors.contentSecondary, cursor: "pointer", transition: "all 200ms ease", ":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)", color: theme.colors.contentPrimary }, })} > - {activeTab.unread ? "Mark read" : "Mark unread"} + {activeTab.unread ? "Mark read" : "Mark unread"} ) : null} diff --git a/factory/packages/frontend/src/components/mock-layout/ui.tsx b/factory/packages/frontend/src/components/mock-layout/ui.tsx index 90d205c..3ebb00e 100644 --- a/factory/packages/frontend/src/components/mock-layout/ui.tsx +++ b/factory/packages/frontend/src/components/mock-layout/ui.tsx @@ -175,16 +175,15 @@ export const TabAvatar = memo(function TabAvatar({ tab }: { tab: AgentTab }) { }); export const Shell = styled("div", ({ $theme }) => ({ - display: "grid", - gap: "1px", + display: "flex", height: "100dvh", backgroundColor: $theme.colors.backgroundSecondary, - gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1.5fr) 380px", overflow: "hidden", })); export const SPanel = styled("section", ({ $theme }) => ({ minHeight: 0, + flex: 1, display: "flex", flexDirection: "column" as const, backgroundColor: $theme.colors.backgroundSecondary,