Add Foundry mobile layout with Tauri iOS/Android support

- Add responsive mobile layout with bottom pill tab bar, swipe navigation, and task list as home screen
- Add platform detection (useIsMobile hook) with viewport breakpoint and VITE_MOBILE build flag
- Mobile-optimize settings/billing/account pages (single-column layout with horizontal tabs)
- Add iOS safe area inset handling with 47px minimum padding
- Scaffold Tauri v2 mobile targets (iOS/Android) with platform-gated sidecar and capabilities
- Add notification sound support and mobile build script

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nicholas Kissel 2026-03-12 22:35:54 -07:00
parent 436eb4a3a3
commit f464fa96ad
68 changed files with 8006 additions and 631 deletions

View file

@ -13,7 +13,7 @@
<script>if(window.__TAURI_INTERNALS__)document.documentElement.dataset.tauri="1"</script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Foundry</title>
</head>
<body>

View file

@ -2,8 +2,10 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
import { useNavigate } from "@tanstack/react-router";
import { useStyletron } from "baseui";
import type { WorkbenchPresence } from "@sandbox-agent/foundry-shared";
import { PanelLeft, PanelRight } from "lucide-react";
import { useFoundryTokens } from "../app/theme";
import { useAgentDoneNotification } from "../lib/notification-sound";
import { DiffContent } from "./mock-layout/diff-content";
import { MessageList } from "./mock-layout/message-list";
@ -11,15 +13,17 @@ import { PromptComposer } from "./mock-layout/prompt-composer";
import { RightSidebar } from "./mock-layout/right-sidebar";
import { Sidebar } from "./mock-layout/sidebar";
import { TabStrip } from "./mock-layout/tab-strip";
import { TerminalPane } from "./mock-layout/terminal-pane";
import { TerminalPane, type ProcessTab } from "./mock-layout/terminal-pane";
import { TranscriptHeader } from "./mock-layout/transcript-header";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui";
import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell, Tooltip } from "./mock-layout/ui";
import {
buildDisplayMessages,
diffPath,
diffTabId,
formatThinkingDuration,
isDiffTab,
isTerminalTab,
terminalTabId,
buildHistoryEvents,
type Task,
type HistoryEvent,
@ -27,8 +31,10 @@ import {
type Message,
type ModelId,
} from "./mock-layout/view-model";
import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app";
import { activeMockOrganization, activeMockUser, useMockAppSnapshot } from "../lib/mock-app";
import { useIsMobile } from "../lib/platform";
import { getTaskWorkbenchClient } from "../lib/workbench";
import { MobileLayout } from "./mock-layout/mobile-layout";
function firstAgentTabId(task: Task): string | null {
return task.tabs[0]?.id ?? null;
@ -58,12 +64,127 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
if (isDiffTab(tabId) && openDiffs.includes(diffPath(tabId))) {
return tabId;
}
if (isTerminalTab(tabId)) {
return tabId;
}
}
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
}
function TypingIndicator({ presence, currentUserId }: { presence: WorkbenchPresence[]; currentUserId: string | null }) {
const [css] = useStyletron();
const t = useFoundryTokens();
const typingMembers = presence.filter((member) => member.typing && member.memberId !== currentUserId);
const isTyping = typingMembers.length > 0;
const [animState, setAnimState] = useState<"in" | "out" | "hidden">(isTyping ? "in" : "hidden");
const lastMembersRef = useRef(typingMembers);
if (isTyping) {
lastMembersRef.current = typingMembers;
}
useEffect(() => {
if (isTyping) {
setAnimState("in");
} else if (lastMembersRef.current.length > 0) {
setAnimState("out");
}
}, [isTyping]);
if (animState === "hidden") return null;
const members = lastMembersRef.current;
if (members.length === 0) return null;
const label =
members.length === 1
? `${members[0]!.name} is typing`
: members.length === 2
? `${members[0]!.name} & ${members[1]!.name} are typing`
: `${members[0]!.name} & ${members.length - 1} others are typing`;
return (
<div
className={css({
display: "flex",
alignItems: "center",
gap: "8px",
padding: "6px 20px",
flexShrink: 0,
overflow: "hidden",
animationName: animState === "in" ? "hf-typing-in" : "hf-typing-out",
animationDuration: "0.2s",
animationTimingFunction: "ease-out",
animationFillMode: "forwards",
})}
onAnimationEnd={() => {
if (animState === "out") setAnimState("hidden");
}}
>
<div className={css({ display: "flex", alignItems: "center" })}>
{members.slice(0, 3).map((member) =>
member.avatarUrl ? (
<img
key={member.memberId}
src={member.avatarUrl}
alt=""
className={css({
width: "16px",
height: "16px",
borderRadius: "50%",
objectFit: "cover",
border: `1.5px solid ${t.surfacePrimary}`,
marginLeft: "-4px",
":first-child": { marginLeft: 0 },
})}
/>
) : (
<div
key={member.memberId}
className={css({
width: "16px",
height: "16px",
borderRadius: "50%",
backgroundColor: t.borderMedium,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "9px",
fontWeight: 600,
color: t.textSecondary,
border: `1.5px solid ${t.surfacePrimary}`,
marginLeft: "-4px",
":first-child": { marginLeft: 0 },
})}
>
{member.name.charAt(0).toUpperCase()}
</div>
),
)}
</div>
<span className={css({ fontSize: "12px", color: t.textTertiary })}>
{label}
{[0, 1, 2].map((i) => (
<span
key={i}
className={css({
animationName: "hf-dot-fade",
animationDuration: "1.4s",
animationIterationCount: "infinite",
animationFillMode: "both",
animationDelay: `${i * 0.2}s`,
})}
>
.
</span>
))}
</span>
</div>
);
}
const TranscriptPanel = memo(function TranscriptPanel({
workspaceId,
taskWorkbenchClient,
task,
activeTabId,
@ -80,7 +201,18 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed,
onToggleRightSidebar,
onNavigateToUsage,
terminalTabOpen,
onOpenTerminalTab,
onCloseTerminalTab,
terminalProcessTabs,
onTerminalProcessTabsChange,
terminalActiveTabId,
onTerminalActiveTabIdChange,
terminalCustomNames,
onTerminalCustomNamesChange,
mobile,
}: {
workspaceId: string;
taskWorkbenchClient: ReturnType<typeof getTaskWorkbenchClient>;
task: Task;
activeTabId: string | null;
@ -97,8 +229,20 @@ const TranscriptPanel = memo(function TranscriptPanel({
rightSidebarCollapsed?: boolean;
onToggleRightSidebar?: () => void;
onNavigateToUsage?: () => void;
terminalTabOpen?: boolean;
onOpenTerminalTab?: () => void;
onCloseTerminalTab?: () => void;
terminalProcessTabs?: ProcessTab[];
onTerminalProcessTabsChange?: (tabs: ProcessTab[]) => void;
terminalActiveTabId?: string | null;
onTerminalActiveTabIdChange?: (id: string | null) => void;
terminalCustomNames?: Record<string, string>;
onTerminalCustomNamesChange?: (names: Record<string, string>) => void;
mobile?: boolean;
}) {
const t = useFoundryTokens();
const transcriptAppSnapshot = useMockAppSnapshot();
const currentUser = activeMockUser(transcriptAppSnapshot);
const [defaultModel, setDefaultModel] = useState<ModelId>("claude-sonnet-4");
const [editingField, setEditingField] = useState<"title" | "branch" | null>(null);
const [editValue, setEditValue] = useState("");
@ -111,9 +255,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
const textareaRef = useRef<HTMLTextAreaElement>(null);
const messageRefs = useRef(new Map<string, HTMLDivElement>());
const activeDiff = activeTabId && isDiffTab(activeTabId) ? diffPath(activeTabId) : null;
const activeAgentTab = activeDiff ? null : (task.tabs.find((candidate) => candidate.id === activeTabId) ?? task.tabs[0] ?? null);
const activeTerminal = activeTabId && isTerminalTab(activeTabId) ? true : false;
const activeAgentTab = activeDiff || activeTerminal ? null : (task.tabs.find((candidate) => candidate.id === activeTabId) ?? task.tabs[0] ?? null);
const promptTab = task.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? task.tabs[0] ?? null;
const isTerminal = task.status === "archived";
useAgentDoneNotification(promptTab?.status);
const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]);
const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]);
const draft = promptTab?.draft.text ?? "";
@ -271,7 +417,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
(tabId: string) => {
onSetActiveTabId(tabId);
if (!isDiffTab(tabId)) {
if (!isDiffTab(tabId) && !isTerminalTab(tabId)) {
onSetLastAgentTabId(tabId);
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (tab?.unread) {
@ -448,28 +594,30 @@ const TranscriptPanel = memo(function TranscriptPanel({
return (
<SPanel>
<TranscriptHeader
task={task}
activeTab={activeAgentTab}
editingField={editingField}
editValue={editValue}
onEditValueChange={setEditValue}
onStartEditingField={startEditingField}
onCommitEditingField={commitEditingField}
onCancelEditingField={cancelEditingField}
onSetActiveTabUnread={(unread) => {
if (activeAgentTab) {
setTabUnread(activeAgentTab.id, unread);
}
}}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={onToggleSidebar}
onSidebarPeekStart={onSidebarPeekStart}
onSidebarPeekEnd={onSidebarPeekEnd}
rightSidebarCollapsed={rightSidebarCollapsed}
onToggleRightSidebar={onToggleRightSidebar}
onNavigateToUsage={onNavigateToUsage}
/>
{!mobile && (
<TranscriptHeader
task={task}
activeTab={activeAgentTab}
editingField={editingField}
editValue={editValue}
onEditValueChange={setEditValue}
onStartEditingField={startEditingField}
onCommitEditingField={commitEditingField}
onCancelEditingField={cancelEditingField}
onSetActiveTabUnread={(unread) => {
if (activeAgentTab) {
setTabUnread(activeAgentTab.id, unread);
}
}}
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={onToggleSidebar}
onSidebarPeekStart={onSidebarPeekStart}
onSidebarPeekEnd={onSidebarPeekEnd}
rightSidebarCollapsed={rightSidebarCollapsed}
onToggleRightSidebar={onToggleRightSidebar}
onNavigateToUsage={onNavigateToUsage}
/>
)}
<div
style={{
flex: 1,
@ -478,11 +626,15 @@ const TranscriptPanel = memo(function TranscriptPanel({
flexDirection: "column",
backgroundColor: t.surfacePrimary,
overflow: "hidden",
borderTopLeftRadius: "12px",
borderTopRightRadius: rightSidebarCollapsed ? "12px" : 0,
borderBottomLeftRadius: "24px",
borderBottomRightRadius: rightSidebarCollapsed ? "24px" : 0,
border: `1px solid ${t.borderDefault}`,
...(mobile
? {}
: {
borderTopLeftRadius: "12px",
borderTopRightRadius: rightSidebarCollapsed ? "12px" : 0,
borderBottomLeftRadius: "24px",
borderBottomRightRadius: rightSidebarCollapsed ? "24px" : 0,
border: `1px solid ${t.borderDefault}`,
}),
}}
>
<TabStrip
@ -500,9 +652,26 @@ const TranscriptPanel = memo(function TranscriptPanel({
onCloseTab={closeTab}
onCloseDiffTab={closeDiffTab}
onAddTab={addTab}
terminalTabOpen={terminalTabOpen}
onCloseTerminalTab={onCloseTerminalTab}
sidebarCollapsed={sidebarCollapsed}
/>
{activeDiff ? (
{activeTerminal ? (
<div style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<TerminalPane
workspaceId={workspaceId}
taskId={task.id}
isExpanded
hideHeader
processTabs={terminalProcessTabs}
onProcessTabsChange={onTerminalProcessTabsChange}
activeProcessTabId={terminalActiveTabId}
onActiveProcessTabIdChange={onTerminalActiveTabIdChange}
customTabNames={terminalCustomNames}
onCustomTabNamesChange={onTerminalCustomNamesChange}
/>
</div>
) : activeDiff ? (
<DiffContent
filePath={activeDiff}
file={task.fileChanges.find((file) => file.path === activeDiff)}
@ -563,25 +732,30 @@ const TranscriptPanel = memo(function TranscriptPanel({
void copyMessage(message);
}}
thinkingTimerLabel={thinkingTimerLabel}
userName={currentUser?.name ?? null}
userAvatarUrl={currentUser?.avatarUrl ?? null}
/>
</ScrollBody>
)}
{!isTerminal && promptTab ? (
<PromptComposer
draft={draft}
textareaRef={textareaRef}
placeholder={!promptTab.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
defaultModel={defaultModel}
model={promptTab.model}
isRunning={promptTab.status === "running"}
onDraftChange={(value) => updateDraft(value, attachments)}
onSend={sendMessage}
onStop={stopAgent}
onRemoveAttachment={removeAttachment}
onChangeModel={changeModel}
onSetDefaultModel={setDefaultModel}
/>
{!isTerminal && !activeTerminal && promptTab ? (
<>
<TypingIndicator presence={task.presence} currentUserId={currentUser?.id ?? null} />
<PromptComposer
draft={draft}
textareaRef={textareaRef}
placeholder={!promptTab.created ? "Describe your task..." : "Send a message..."}
attachments={attachments}
defaultModel={defaultModel}
model={promptTab.model}
isRunning={promptTab.status === "running"}
onDraftChange={(value) => updateDraft(value, attachments)}
onSend={sendMessage}
onStop={stopAgent}
onRemoveAttachment={removeAttachment}
onChangeModel={changeModel}
onSetDefaultModel={setDefaultModel}
/>
</>
) : null}
</div>
</SPanel>
@ -670,6 +844,14 @@ const RightRail = memo(function RightRail({
onRevertFile,
onPublishPr,
onToggleSidebar,
onOpenTerminalTab,
terminalTabOpen,
terminalProcessTabs,
onTerminalProcessTabsChange,
terminalActiveTabId,
onTerminalActiveTabIdChange,
terminalCustomNames,
onTerminalCustomNamesChange,
}: {
workspaceId: string;
task: Task;
@ -679,6 +861,14 @@ const RightRail = memo(function RightRail({
onRevertFile: (path: string) => void;
onPublishPr: () => void;
onToggleSidebar?: () => void;
onOpenTerminalTab?: () => void;
terminalTabOpen?: boolean;
terminalProcessTabs?: ProcessTab[];
onTerminalProcessTabsChange?: (tabs: ProcessTab[]) => void;
terminalActiveTabId?: string | null;
onTerminalActiveTabIdChange?: (id: string | null) => void;
terminalCustomNames?: Record<string, string>;
onTerminalCustomNamesChange?: (names: Record<string, string>) => void;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
@ -761,6 +951,13 @@ const RightRail = memo(function RightRail({
minWidth: 0,
display: "flex",
flexDirection: "column",
...(terminalTabOpen
? {
borderBottomRightRadius: "12px",
borderBottom: `1px solid ${t.borderDefault}`,
overflow: "hidden",
}
: {}),
})}
>
<RightSidebar
@ -775,14 +972,14 @@ const RightRail = memo(function RightRail({
</div>
<div
className={css({
height: `${terminalHeight}px`,
minHeight: "43px",
height: terminalTabOpen ? 0 : `${terminalHeight}px`,
minHeight: terminalTabOpen ? 0 : "43px",
backgroundColor: t.surfacePrimary,
overflow: "hidden",
borderBottomRightRadius: "12px",
borderRight: `1px solid ${t.borderDefault}`,
borderBottom: `1px solid ${t.borderDefault}`,
display: "flex",
borderRight: terminalTabOpen ? "none" : `1px solid ${t.borderDefault}`,
borderBottom: terminalTabOpen ? "none" : `1px solid ${t.borderDefault}`,
display: terminalTabOpen ? "none" : "flex",
flexDirection: "column",
})}
>
@ -802,6 +999,13 @@ const RightRail = memo(function RightRail({
onCollapse={() => {
setTerminalHeight(43);
}}
onOpenTerminalTab={onOpenTerminalTab}
processTabs={terminalProcessTabs}
onProcessTabsChange={onTerminalProcessTabsChange}
activeProcessTabId={terminalActiveTabId}
onActiveProcessTabIdChange={onTerminalActiveTabIdChange}
customTabNames={terminalCustomNames}
onCustomTabNamesChange={onTerminalCustomNamesChange}
/>
</div>
</div>
@ -909,6 +1113,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
void navigate({ to: "/organizations/$organizationId/billing" as never, params: { organizationId: activeOrg.id } });
}
}, [activeOrg, navigate]);
const navigateToSettings = useCallback(() => {
if (activeOrg) {
void navigate({ to: "/organizations/$organizationId/settings" as never, params: { organizationId: activeOrg.id } as never });
}
}, [activeOrg, navigate]);
const [projectOrder, setProjectOrder] = useState<string[] | null>(null);
const projects = useMemo(() => {
if (!projectOrder) return rawProjects;
@ -922,6 +1131,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const [activeTabIdByTask, setActiveTabIdByTask] = useState<Record<string, string | null>>({});
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState<Record<string, string | null>>({});
const [openDiffsByTask, setOpenDiffsByTask] = useState<Record<string, string[]>>({});
const [terminalTabOpenByTask, setTerminalTabOpenByTask] = useState<Record<string, boolean>>({});
const [terminalProcessTabsByTask, setTerminalProcessTabsByTask] = useState<Record<string, ProcessTab[]>>({});
const [terminalActiveTabIdByTask, setTerminalActiveTabIdByTask] = useState<Record<string, string | null>>({});
const [terminalCustomNamesByTask, setTerminalCustomNamesByTask] = useState<Record<string, Record<string, string>>>({});
const [selectedNewTaskRepoId, setSelectedNewTaskRepoId] = useState("");
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));
@ -1021,6 +1234,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
}, [activeTask, tasks, navigate, workspaceId]);
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
const terminalTabOpen = activeTask ? (terminalTabOpenByTask[activeTask.id] ?? false) : false;
const terminalProcessTabs = activeTask ? (terminalProcessTabsByTask[activeTask.id] ?? []) : [];
const terminalActiveTabId = activeTask ? (terminalActiveTabIdByTask[activeTask.id] ?? null) : null;
const terminalCustomNames = activeTask ? (terminalCustomNamesByTask[activeTask.id] ?? {}) : {};
const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null;
const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null;
@ -1115,29 +1332,32 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
})();
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
const createTask = useCallback(() => {
void (async () => {
const repoId = selectedNewTaskRepoId;
if (!repoId) {
throw new Error("Cannot create a task without an available repo");
}
const createTask = useCallback(
(overrideRepoId?: string) => {
void (async () => {
const repoId = overrideRepoId || selectedNewTaskRepoId;
if (!repoId) {
throw new Error("Cannot create a task without an available repo");
}
const { taskId, tabId } = await taskWorkbenchClient.createTask({
repoId,
task: "New task",
model: "gpt-4o",
title: "New task",
});
await navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
taskId,
},
search: { sessionId: tabId ?? undefined },
});
})();
}, [navigate, selectedNewTaskRepoId, workspaceId]);
const { taskId, tabId } = await taskWorkbenchClient.createTask({
repoId,
task: "New task",
model: "gpt-4o",
title: "New task",
});
await navigate({
to: "/workspaces/$workspaceId/tasks/$taskId",
params: {
workspaceId,
taskId,
},
search: { sessionId: tabId ?? undefined },
});
})();
},
[navigate, selectedNewTaskRepoId, workspaceId],
);
const openDiffTab = useCallback(
(path: string) => {
@ -1163,6 +1383,46 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
[activeTask],
);
const openTerminalTab = useCallback(() => {
if (!activeTask) return;
setTerminalTabOpenByTask((current) => ({ ...current, [activeTask.id]: true }));
setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: terminalTabId() }));
}, [activeTask]);
const closeTerminalTab = useCallback(() => {
if (!activeTask) return;
setTerminalTabOpenByTask((current) => ({ ...current, [activeTask.id]: false }));
const currentActive = activeTabIdByTask[activeTask.id];
if (currentActive && isTerminalTab(currentActive)) {
const fallback = lastAgentTabIdByTask[activeTask.id] ?? activeTask.tabs[0]?.id ?? null;
setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: fallback }));
}
}, [activeTask, activeTabIdByTask, lastAgentTabIdByTask]);
const setTerminalProcessTabs = useCallback(
(tabs: ProcessTab[]) => {
if (!activeTask) return;
setTerminalProcessTabsByTask((current) => ({ ...current, [activeTask.id]: tabs }));
},
[activeTask],
);
const setTerminalActiveTabId = useCallback(
(id: string | null) => {
if (!activeTask) return;
setTerminalActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: id }));
},
[activeTask],
);
const setTerminalCustomNames = useCallback(
(names: Record<string, string>) => {
if (!activeTask) return;
setTerminalCustomNamesByTask((current) => ({ ...current, [activeTask.id]: names }));
},
[activeTask],
);
const selectTask = useCallback(
(id: string) => {
const task = tasks.find((candidate) => candidate.id === id) ?? null;
@ -1265,6 +1525,8 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
[activeTask, lastAgentTabIdByTask],
);
const isMobile = useIsMobile();
const isDesktop = !!import.meta.env.VITE_DESKTOP;
const onDragMouseDown = useCallback((event: ReactPointerEvent) => {
if (event.button !== 0) return;
@ -1274,6 +1536,58 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
ipc.invoke("plugin:window|start_dragging").catch(() => {});
}
}, []);
// Mobile layout: single-panel stack navigation with bottom tab bar
if (isMobile && activeTask) {
return (
<MobileLayout
workspaceId={workspaceId}
task={activeTask}
tasks={tasks}
projects={projects}
repos={viewModel.repos}
selectedNewTaskRepoId={selectedNewTaskRepoId}
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
onSelectTask={selectTask}
onCreateTask={createTask}
onMarkUnread={markTaskUnread}
onRenameTask={renameTask}
onRenameBranch={renameBranch}
onReorderProjects={reorderProjects}
taskOrderByProject={taskOrderByProject}
onReorderTasks={reorderTasks}
activeTabId={activeTabId}
transcriptPanel={
<TranscriptPanel
workspaceId={workspaceId}
taskWorkbenchClient={taskWorkbenchClient}
task={activeTask}
activeTabId={activeTabId}
lastAgentTabId={lastAgentTabId}
openDiffs={openDiffs}
onSyncRouteSession={syncRouteSession}
onSetActiveTabId={(tabId) => setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId }))}
onSetLastAgentTabId={(tabId) => setLastAgentTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId }))}
onSetOpenDiffs={(paths) => setOpenDiffsByTask((current) => ({ ...current, [activeTask.id]: paths }))}
onNavigateToUsage={navigateToUsage}
mobile
/>
}
onOpenDiff={openDiffTab}
onArchive={archiveTask}
onRevertFile={revertFile}
onPublishPr={publishPr}
terminalProcessTabs={terminalProcessTabs}
onTerminalProcessTabsChange={setTerminalProcessTabs}
terminalActiveTabId={terminalActiveTabId}
onTerminalActiveTabIdChange={setTerminalActiveTabId}
terminalCustomNames={terminalCustomNames}
onTerminalCustomNamesChange={setTerminalCustomNames}
onOpenSettings={navigateToSettings}
/>
);
}
const dragRegion = isDesktop ? (
<div
style={{
@ -1310,11 +1624,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: t.textTertiary,
color: t.textPrimary,
position: "relative",
zIndex: 9999,
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
":hover": { color: t.textPrimary, backgroundColor: t.interactiveHover },
});
const sidebarTransition = "width 200ms ease";
@ -1370,15 +1684,19 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
{!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>
<Tooltip label="Toggle sidebar" placement="bottom">
<div className={collapsedToggleClass} onClick={() => setLeftSidebarOpen(true)}>
<PanelLeft size={14} />
</div>
</Tooltip>
)}
<div style={{ flex: 1 }} />
{rightSidebarOpen ? null : (
<div className={collapsedToggleClass} onClick={() => setRightSidebarOpen(true)}>
<PanelRight size={14} />
</div>
<Tooltip label="Toggle changes" placement="bottom">
<div className={collapsedToggleClass} onClick={() => setRightSidebarOpen(true)}>
<PanelRight size={14} />
</div>
</Tooltip>
)}
</div>
) : null}
@ -1409,7 +1727,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
</p>
<button
type="button"
onClick={createTask}
onClick={() => createTask()}
disabled={viewModel.repos.length === 0}
style={{
alignSelf: "center",
@ -1543,6 +1861,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
{leftSidebarOpen ? <PanelResizeHandle onResizeStart={onLeftResizeStart} onResize={onLeftResize} /> : null}
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
<TranscriptPanel
workspaceId={workspaceId}
taskWorkbenchClient={taskWorkbenchClient}
task={activeTask}
activeTabId={activeTabId}
@ -1568,6 +1887,15 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
rightSidebarCollapsed={!rightSidebarOpen}
onToggleRightSidebar={() => setRightSidebarOpen(true)}
onNavigateToUsage={navigateToUsage}
terminalTabOpen={terminalTabOpen}
onOpenTerminalTab={openTerminalTab}
onCloseTerminalTab={closeTerminalTab}
terminalProcessTabs={terminalProcessTabs}
onTerminalProcessTabsChange={setTerminalProcessTabs}
terminalActiveTabId={terminalActiveTabId}
onTerminalActiveTabIdChange={setTerminalActiveTabId}
terminalCustomNames={terminalCustomNames}
onTerminalCustomNamesChange={setTerminalCustomNames}
/>
</div>
{rightSidebarOpen ? <PanelResizeHandle onResizeStart={onRightResizeStart} onResize={onRightResize} /> : null}
@ -1592,6 +1920,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
onRevertFile={revertFile}
onPublishPr={publishPr}
onToggleSidebar={() => setRightSidebarOpen(false)}
onOpenTerminalTab={openTerminalTab}
terminalTabOpen={terminalTabOpen}
terminalProcessTabs={terminalProcessTabs}
onTerminalProcessTabsChange={setTerminalProcessTabs}
terminalActiveTabId={terminalActiveTabId}
onTerminalActiveTabIdChange={setTerminalActiveTabId}
terminalCustomNames={terminalCustomNames}
onTerminalCustomNamesChange={setTerminalCustomNames}
/>
</div>
</div>

View file

@ -4,6 +4,7 @@ import { LabelXSmall } from "baseui/typography";
import { History } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import { Tooltip } from "./ui";
import { formatMessageTimestamp, type HistoryEvent } from "./view-model";
export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }: { events: HistoryEvent[]; onSelect: (event: HistoryEvent) => void }) {
@ -41,29 +42,31 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
gap: "6px",
})}
>
<div
role="button"
tabIndex={0}
onClick={() => setOpen((prev) => !prev)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setOpen((prev) => !prev);
}}
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: open ? t.textSecondary : t.textTertiary,
backgroundColor: open ? t.interactiveHover : "transparent",
transition: "background 200ms ease, color 200ms ease",
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
<History size={14} />
</div>
<Tooltip label="History" placement="left">
<div
role="button"
tabIndex={0}
onClick={() => setOpen((prev) => !prev)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") setOpen((prev) => !prev);
}}
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: open ? t.textSecondary : t.textTertiary,
backgroundColor: open ? t.interactiveHover : "transparent",
transition: "background 200ms ease, color 200ms ease",
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
<History size={14} />
</div>
</Tooltip>
{open ? (
<div

View file

@ -14,11 +14,15 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
messageRefs,
copiedMessageId,
onCopyMessage,
userName,
userAvatarUrl,
}: {
message: Message;
messageRefs: MutableRefObject<Map<string, HTMLDivElement>>;
copiedMessageId: string | null;
onCopyMessage: (message: Message) => void;
userName?: string | null;
userAvatarUrl?: string | null;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
@ -81,12 +85,52 @@ const TranscriptMessageBody = memo(function TranscriptMessageBody({
className={css({
display: "flex",
alignItems: "center",
gap: "10px",
gap: "6px",
justifyContent: isUser ? "flex-end" : "flex-start",
minHeight: "16px",
paddingLeft: isUser ? undefined : "2px",
})}
>
{isUser && (userAvatarUrl || userName) ? (
<>
{userAvatarUrl ? (
<img
src={userAvatarUrl}
alt=""
className={css({
width: "18px",
height: "18px",
borderRadius: "50%",
objectFit: "cover",
flexShrink: 0,
})}
/>
) : userName ? (
<div
className={css({
width: "18px",
height: "18px",
borderRadius: "50%",
backgroundColor: t.borderMedium,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
fontWeight: 600,
color: t.textSecondary,
flexShrink: 0,
})}
>
{userName.charAt(0).toUpperCase()}
</div>
) : null}
{userName ? (
<LabelXSmall color={t.textTertiary} $style={{ fontWeight: 500 }}>
{userName}
</LabelXSmall>
) : null}
</>
) : null}
{displayFooter ? (
<LabelXSmall color={t.textTertiary} $style={{ fontFamily: '"IBM Plex Mono", monospace', letterSpacing: "0.01em" }}>
{displayFooter}
@ -130,6 +174,8 @@ export const MessageList = memo(function MessageList({
copiedMessageId,
onCopyMessage,
thinkingTimerLabel,
userName,
userAvatarUrl,
}: {
tab: AgentTab | null | undefined;
scrollRef: Ref<HTMLDivElement>;
@ -139,6 +185,8 @@ export const MessageList = memo(function MessageList({
copiedMessageId: string | null;
onCopyMessage: (message: Message) => void;
thinkingTimerLabel: string | null;
userName?: string | null;
userAvatarUrl?: string | null;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
@ -238,7 +286,16 @@ export const MessageList = memo(function MessageList({
return null;
}
return <TranscriptMessageBody message={message} messageRefs={messageRefs} copiedMessageId={copiedMessageId} onCopyMessage={onCopyMessage} />;
return (
<TranscriptMessageBody
message={message}
messageRefs={messageRefs}
copiedMessageId={copiedMessageId}
onCopyMessage={onCopyMessage}
userName={userName}
userAvatarUrl={userAvatarUrl}
/>
);
}}
isThinking={Boolean(tab && tab.status === "running" && transcriptEntries.length > 0)}
renderThinkingState={() => (

View file

@ -0,0 +1,338 @@
import { memo, useCallback, useRef, useState } from "react";
import { useStyletron } from "baseui";
import { FileText, List, MessageSquare, Settings, Terminal as TerminalIcon } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import type { WorkbenchProjectSection, WorkbenchRepo } from "@sandbox-agent/foundry-shared";
import { RightSidebar } from "./right-sidebar";
import { Sidebar } from "./sidebar";
import { TerminalPane, type ProcessTab } from "./terminal-pane";
import type { Task } from "./view-model";
type MobileView = "tasks" | "chat" | "changes" | "terminal";
const VIEW_ORDER: MobileView[] = ["tasks", "chat", "changes", "terminal"];
const SWIPE_THRESHOLD = 50;
const SWIPE_MAX_VERTICAL = 80;
interface MobileLayoutProps {
workspaceId: string;
task: Task;
tasks: Task[];
projects: WorkbenchProjectSection[];
repos: WorkbenchRepo[];
selectedNewTaskRepoId: string;
onSelectNewTaskRepo: (id: string) => void;
onSelectTask: (id: string) => void;
onCreateTask: (repoId?: string) => void;
onMarkUnread: (id: string) => void;
onRenameTask: (id: string) => void;
onRenameBranch: (id: string) => void;
onReorderProjects: (from: number, to: number) => void;
taskOrderByProject: Record<string, string[]>;
onReorderTasks: (projectId: string, from: number, to: number) => void;
// Transcript panel (rendered by parent)
transcriptPanel: React.ReactNode;
// Diff/file actions
onOpenDiff: (path: string) => void;
onArchive: () => void;
onRevertFile: (path: string) => void;
onPublishPr: () => void;
// Tab state
activeTabId: string | null;
// Terminal state
terminalProcessTabs: ProcessTab[];
onTerminalProcessTabsChange: (tabs: ProcessTab[]) => void;
terminalActiveTabId: string | null;
onTerminalActiveTabIdChange: (id: string | null) => void;
terminalCustomNames: Record<string, string>;
onTerminalCustomNamesChange: (names: Record<string, string>) => void;
onOpenSettings?: () => void;
}
export const MobileLayout = memo(function MobileLayout(props: MobileLayoutProps) {
const [css] = useStyletron();
const t = useFoundryTokens();
const [activeView, setActiveView] = useState<MobileView>("tasks");
// Swipe gesture tracking
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) {
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
}
}, []);
const handleTouchEnd = useCallback(
(e: React.TouchEvent) => {
const start = touchStartRef.current;
const touch = e.changedTouches[0];
if (!start || !touch) return;
touchStartRef.current = null;
const dx = touch.clientX - start.x;
const dy = touch.clientY - start.y;
if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dy) > SWIPE_MAX_VERTICAL) return;
const currentIndex = VIEW_ORDER.indexOf(activeView);
if (dx > 0 && currentIndex > 0) {
// Swipe right -> go back
setActiveView(VIEW_ORDER[currentIndex - 1]!);
} else if (dx < 0 && currentIndex < VIEW_ORDER.length - 1) {
// Swipe left -> go forward
setActiveView(VIEW_ORDER[currentIndex + 1]!);
}
},
[activeView],
);
const handleSelectTask = useCallback(
(id: string) => {
props.onSelectTask(id);
setActiveView("chat");
},
[props.onSelectTask],
);
return (
<div
className={css({
display: "flex",
flexDirection: "column",
height: "100dvh",
backgroundColor: t.surfacePrimary,
paddingTop: "max(var(--safe-area-top), 47px)",
overflow: "hidden",
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
})}
>
{/* Header - show task info when not on tasks view */}
{activeView !== "tasks" && <MobileHeader task={props.task} />}
{/* Content area */}
<div
className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", overflow: "hidden" })}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{activeView === "tasks" ? (
<div className={css({ flex: 1, minHeight: 0, overflow: "auto" })}>
<Sidebar
projects={props.projects}
newTaskRepos={props.repos}
selectedNewTaskRepoId={props.selectedNewTaskRepoId}
activeId={props.task.id}
onSelect={handleSelectTask}
onCreate={props.onCreateTask}
onSelectNewTaskRepo={props.onSelectNewTaskRepo}
onMarkUnread={props.onMarkUnread}
onRenameTask={props.onRenameTask}
onRenameBranch={props.onRenameBranch}
onReorderProjects={props.onReorderProjects}
taskOrderByProject={props.taskOrderByProject}
onReorderTasks={props.onReorderTasks}
onToggleSidebar={undefined}
hideSettings
panelStyle={{ backgroundColor: t.surfacePrimary }}
/>
</div>
) : activeView === "chat" ? (
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" })}>{props.transcriptPanel}</div>
) : activeView === "changes" ? (
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" })}>
<RightSidebar
task={props.task}
activeTabId={props.activeTabId}
onOpenDiff={props.onOpenDiff}
onArchive={props.onArchive}
onRevertFile={props.onRevertFile}
onPublishPr={props.onPublishPr}
mobile
/>
</div>
) : (
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" })}>
<TerminalPane
workspaceId={props.workspaceId}
taskId={props.task.id}
hideHeader
processTabs={props.terminalProcessTabs}
onProcessTabsChange={props.onTerminalProcessTabsChange}
activeProcessTabId={props.terminalActiveTabId}
onActiveProcessTabIdChange={props.onTerminalActiveTabIdChange}
customTabNames={props.terminalCustomNames}
onCustomTabNamesChange={props.onTerminalCustomNamesChange}
/>
</div>
)}
</div>
{/* Bottom tab bar - always fixed at bottom */}
<MobileTabBar
activeView={activeView}
onViewChange={setActiveView}
changesCount={Object.keys(props.task.diffs).length}
onOpenSettings={props.onOpenSettings}
/>
</div>
);
});
function MobileHeader({ task }: { task: Task }) {
const [css] = useStyletron();
const t = useFoundryTokens();
return (
<div
className={css({
display: "flex",
alignItems: "center",
padding: "6px 12px",
gap: "8px",
flexShrink: 0,
borderBottom: `1px solid ${t.borderDefault}`,
minHeight: "40px",
})}
>
<div className={css({ flex: 1, minWidth: 0, overflow: "hidden" })}>
<div
className={css({
fontSize: "14px",
fontWeight: 600,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
})}
>
{task.title}
</div>
<div
className={css({
fontSize: "11px",
color: t.textTertiary,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
})}
>
{task.repoName}
</div>
</div>
</div>
);
}
function MobileTabBar({
activeView,
onViewChange,
changesCount,
onOpenSettings,
}: {
activeView: MobileView;
onViewChange: (view: MobileView) => void;
changesCount: number;
onOpenSettings?: () => void;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
const tabs: { id: MobileView; icon: React.ReactNode; badge?: number }[] = [
{ id: "tasks", icon: <List size={20} /> },
{ id: "chat", icon: <MessageSquare size={20} /> },
{ id: "changes", icon: <FileText size={20} />, badge: changesCount > 0 ? changesCount : undefined },
{ id: "terminal", icon: <TerminalIcon size={20} /> },
];
const iconButtonClass = css({
border: "none",
background: "transparent",
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "42px",
height: "42px",
borderRadius: "12px",
cursor: "pointer",
position: "relative",
transition: "background 150ms ease, color 150ms ease",
});
return (
<div
className={css({
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "8px 16px",
paddingBottom: "calc(8px + var(--safe-area-bottom))",
flexShrink: 0,
})}
>
{/* Pill container */}
<div
className={css({
display: "flex",
alignItems: "center",
gap: "4px",
backgroundColor: t.surfaceElevated,
borderRadius: "16px",
padding: "4px",
})}
>
{tabs.map((tab) => {
const isActive = activeView === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onViewChange(tab.id)}
className={iconButtonClass}
style={{
background: isActive ? t.interactiveHover : "transparent",
color: isActive ? t.textPrimary : t.textTertiary,
}}
>
<div className={css({ position: "relative", display: "flex" })}>
{tab.icon}
{tab.badge ? (
<div
className={css({
position: "absolute",
top: "-4px",
right: "-8px",
backgroundColor: t.accent,
color: "#fff",
fontSize: "10px",
fontWeight: 700,
borderRadius: "8px",
minWidth: "16px",
height: "16px",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "0 4px",
})}
>
{tab.badge}
</div>
) : null}
</div>
</button>
);
})}
{onOpenSettings && (
<button type="button" onClick={onOpenSettings} className={iconButtonClass} style={{ color: t.textTertiary }}>
<Settings size={20} />
</button>
)}
</div>
</div>
);
}

View file

@ -4,7 +4,7 @@ import { LabelSmall } from "baseui/typography";
import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, Tooltip, useContextMenu } from "./ui";
import { type FileTreeNode, type Task, diffTabId } from "./view-model";
const FileTree = memo(function FileTree({
@ -96,6 +96,7 @@ export const RightSidebar = memo(function RightSidebar({
onRevertFile,
onPublishPr,
onToggleSidebar,
mobile,
}: {
task: Task;
activeTabId: string | null;
@ -104,6 +105,7 @@ export const RightSidebar = memo(function RightSidebar({
onRevertFile: (path: string) => void;
onPublishPr: () => void;
onToggleSidebar?: () => void;
mobile?: boolean;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
@ -151,128 +153,138 @@ export const RightSidebar = memo(function RightSidebar({
return (
<SPanel $style={{ backgroundColor: t.surfacePrimary, minWidth: 0 }}>
<PanelHeaderBar $style={{ backgroundColor: t.surfaceSecondary, 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;
}
{!mobile && (
<PanelHeaderBar $style={{ backgroundColor: t.surfaceSecondary, 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 })}>
<Tooltip label={pullRequestUrl ? "Open PR" : "Publish PR"} placement="bottom">
<button
onClick={() => {
if (pullRequestUrl) {
window.open(pullRequestUrl, "_blank", "noopener,noreferrer");
return;
}
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: t.textSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
})}
>
<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: t.textSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
})}
>
<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: t.textSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
})}
>
<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({
width: "26px",
height: "26px",
borderRadius: "6px",
color: t.textTertiary,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
<PanelRight size={14} />
</div>
) : null}
</div>
</PanelHeaderBar>
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: t.textSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
})}
>
<GitPullRequest size={12} style={{ flexShrink: 0 }} />
{!compact && <span>{pullRequestUrl ? "Open PR" : "Publish PR"}</span>}
</button>
</Tooltip>
<Tooltip label="Push" placement="bottom">
<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: t.textSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
})}
>
<ArrowUpFromLine size={12} style={{ flexShrink: 0 }} />
{!compact && <span>Push</span>}
</button>
</Tooltip>
<Tooltip label="Archive" placement="bottom">
<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: t.textSecondary,
cursor: "pointer",
transition: "all 200ms ease",
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
})}
>
<Archive size={12} style={{ flexShrink: 0 }} />
{!compact && <span>Archive</span>}
</button>
</Tooltip>
</div>
) : null}
{onToggleSidebar ? (
<Tooltip label="Toggle sidebar" placement="bottom">
<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: t.textTertiary,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
<PanelRight size={14} />
</div>
</Tooltip>
) : null}
</div>
</PanelHeaderBar>
)}
<div
style={{
@ -280,9 +292,13 @@ export const RightSidebar = memo(function RightSidebar({
minHeight: 0,
display: "flex",
flexDirection: "column",
borderTop: `1px solid ${t.borderDefault}`,
borderRight: `1px solid ${t.borderDefault}`,
borderTopRightRadius: "12px",
...(mobile
? {}
: {
borderTop: `1px solid ${t.borderDefault}`,
borderRight: `1px solid ${t.borderDefault}`,
borderTopRightRadius: "12px",
}),
overflow: "hidden",
}}
>
@ -296,7 +312,7 @@ export const RightSidebar = memo(function RightSidebar({
height: "41px",
minHeight: "41px",
flexShrink: 0,
borderTopRightRadius: "12px",
...(mobile ? {} : { borderTopRightRadius: "12px" }),
})}
>
<button

View file

@ -10,7 +10,7 @@ import {
CloudUpload,
CreditCard,
GitPullRequestDraft,
ListChecks,
List,
LogOut,
PanelLeft,
Plus,
@ -18,14 +18,166 @@ import {
User,
} from "lucide-react";
import type { WorkbenchPresence } from "@sandbox-agent/foundry-shared";
import { formatRelativeAge, type Task, type ProjectSection } from "./view-model";
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, Tooltip, useContextMenu } from "./ui";
import { activeMockOrganization, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../../lib/mock-app";
import { useFoundryTokens } from "../../app/theme";
import type { FoundryTokens } from "../../styles/tokens";
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
const AWAY_THRESHOLD_MS = 2 * 60 * 1000;
const PresenceAvatar = memo(function PresenceAvatar({ member, idx, isAway }: { member: WorkbenchPresence; idx: number; isAway: boolean }) {
const [css] = useStyletron();
const t = useFoundryTokens();
const label = `${member.name}${isAway ? " (away)" : ""}`;
return (
<div
className={css({
position: "relative",
marginLeft: idx > 0 ? "-5px" : "0",
flexShrink: 0,
":hover > div:last-child": {
opacity: 1,
transform: "translateX(-50%) translateY(0)",
pointerEvents: "auto",
},
})}
>
<div
className={css({
width: "18px",
height: "18px",
borderRadius: "50%",
border: `1.5px solid ${t.surfacePrimary}`,
overflow: "hidden",
backgroundColor: t.interactiveHover,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: isAway ? 0.35 : 1,
filter: isAway ? "grayscale(1)" : "none",
transition: "opacity 0.3s, filter 0.3s",
})}
>
{member.avatarUrl ? (
<img src={member.avatarUrl} alt={member.name} className={css({ width: "100%", height: "100%", objectFit: "cover", display: "block" })} />
) : (
<span className={css({ fontSize: "9px", fontWeight: 600, color: t.textTertiary })}>{member.name.charAt(0).toUpperCase()}</span>
)}
</div>
<div
className={css({
position: "absolute",
bottom: "calc(100% + 6px)",
left: "50%",
transform: "translateX(-50%) translateY(4px)",
padding: "4px 8px",
borderRadius: "6px",
backgroundColor: "rgba(32, 32, 32, 0.98)",
backdropFilter: "blur(12px)",
border: `1px solid ${t.borderDefault}`,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.4)",
color: "#e0e0e0",
fontSize: "10px",
fontWeight: 500,
whiteSpace: "nowrap",
pointerEvents: "none",
opacity: 0,
transition: "opacity 150ms ease, transform 150ms ease",
zIndex: 300,
})}
>
{label}
</div>
</div>
);
});
const PresenceAvatars = memo(function PresenceAvatars({ presence }: { presence: WorkbenchPresence[] }) {
const [css] = useStyletron();
const t = useFoundryTokens();
const maxShow = 3;
const visible = presence.slice(0, maxShow);
const overflow = presence.length - maxShow;
const now = Date.now();
return (
<div className={css({ display: "flex", alignItems: "center", gap: "6px", paddingLeft: "22px", paddingTop: "2px" })}>
<div className={css({ display: "flex", alignItems: "center" })}>
{visible.map((member, idx) => {
const isAway = now - member.lastSeenAtMs > AWAY_THRESHOLD_MS;
return <PresenceAvatar key={member.memberId} member={member} idx={idx} isAway={isAway} />;
})}
{overflow > 0 && (
<div
className={css({
position: "relative",
marginLeft: "-5px",
flexShrink: 0,
":hover > div:last-child": {
opacity: 1,
transform: "translateX(-50%) translateY(0)",
pointerEvents: "auto",
},
})}
>
<div
className={css({
width: "18px",
height: "18px",
borderRadius: "50%",
border: `1.5px solid ${t.surfacePrimary}`,
backgroundColor: t.interactiveHover,
display: "flex",
alignItems: "center",
justifyContent: "center",
})}
>
<span className={css({ fontSize: "8px", fontWeight: 600, color: t.textTertiary })}>+{overflow}</span>
</div>
<div
className={css({
position: "absolute",
bottom: "calc(100% + 6px)",
left: "50%",
transform: "translateX(-50%) translateY(4px)",
padding: "4px 8px",
borderRadius: "6px",
backgroundColor: "rgba(32, 32, 32, 0.98)",
backdropFilter: "blur(12px)",
border: `1px solid ${t.borderDefault}`,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.4)",
color: "#e0e0e0",
fontSize: "10px",
fontWeight: 500,
whiteSpace: "nowrap",
pointerEvents: "none",
opacity: 0,
transition: "opacity 150ms ease, transform 150ms ease",
zIndex: 300,
})}
>
{presence
.slice(maxShow)
.map((m) => m.name)
.join(", ")}
</div>
</div>
)}
</div>
{presence.length <= 2 && (
<span className={css({ fontSize: "10px", color: t.textTertiary, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
{presence.map((m) => m.name).join(", ")}
</span>
)}
</div>
);
});
function projectInitial(label: string): string {
const parts = label.split("/");
const name = parts[parts.length - 1] ?? label;
@ -55,13 +207,15 @@ export const Sidebar = memo(function Sidebar({
taskOrderByProject,
onReorderTasks,
onToggleSidebar,
hideSettings,
panelStyle,
}: {
projects: ProjectSection[];
newTaskRepos: Array<{ id: string; label: string }>;
selectedNewTaskRepoId: string;
activeId: string;
onSelect: (id: string) => void;
onCreate: () => void;
onCreate: (repoId?: string) => void;
onSelectNewTaskRepo: (repoId: string) => void;
onMarkUnread: (id: string) => void;
onRenameTask: (id: string) => void;
@ -70,6 +224,8 @@ export const Sidebar = memo(function Sidebar({
taskOrderByProject: Record<string, string[]>;
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
onToggleSidebar?: () => void;
hideSettings?: boolean;
panelStyle?: Record<string, string>;
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
@ -90,6 +246,7 @@ export const Sidebar = memo(function Sidebar({
// Attach global mousemove/mouseup when dragging
useEffect(() => {
if (!drag) return;
document.body.style.cursor = "grabbing";
const onMove = (e: MouseEvent) => {
// Detect which element is under the cursor using data attributes
const el = document.elementFromPoint(e.clientX, e.clientY);
@ -132,6 +289,7 @@ export const Sidebar = memo(function Sidebar({
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.body.style.cursor = "";
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
@ -152,12 +310,12 @@ export const Sidebar = memo(function Sidebar({
}, [createMenuOpen]);
return (
<SPanel>
<SPanel $style={panelStyle}>
<style>{`
[data-project-header]:hover [data-chevron] {
[data-project-header] [data-chevron] {
display: inline-flex !important;
}
[data-project-header]:hover [data-project-icon] {
[data-project-header] [data-project-icon] {
display: none !important;
}
`}</style>
@ -175,6 +333,43 @@ export const Sidebar = memo(function Sidebar({
})}
>
{onToggleSidebar ? (
<Tooltip label="Toggle sidebar" placement="bottom">
<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: t.textTertiary,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
<PanelLeft size={14} />
</div>
</Tooltip>
) : null}
</div>
) : null}
<PanelHeaderBar $style={{ backgroundColor: "transparent", borderBottom: "none" }}>
<LabelSmall
color={t.textPrimary}
$style={{ fontWeight: 600, flex: 1, fontSize: "16px", display: "flex", alignItems: "center", gap: "6px", lineHeight: 1 }}
>
<List size={16} />
Tasks
</LabelSmall>
{!import.meta.env.VITE_DESKTOP && onToggleSidebar ? (
<Tooltip label="Toggle sidebar" placement="bottom">
<div
role="button"
tabIndex={0}
@ -197,84 +392,53 @@ export const Sidebar = memo(function Sidebar({
>
<PanelLeft size={14} />
</div>
) : null}
</div>
) : null}
<PanelHeaderBar $style={{ backgroundColor: "transparent", borderBottom: "none" }}>
<LabelSmall
color={t.textPrimary}
$style={{ fontWeight: 500, flex: 1, fontSize: "13px", display: "flex", alignItems: "center", gap: "6px", lineHeight: 1 }}
>
<ListChecks size={14} />
Tasks
</LabelSmall>
{!import.meta.env.VITE_DESKTOP && 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: t.textTertiary,
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
>
<PanelLeft size={14} />
</div>
</Tooltip>
) : null}
<div ref={createMenuRef} className={css({ position: "relative", flexShrink: 0 })}>
<div
role="button"
tabIndex={0}
aria-disabled={newTaskRepos.length === 0}
onClick={() => {
if (newTaskRepos.length === 0) return;
if (newTaskRepos.length === 1) {
onSelectNewTaskRepo(newTaskRepos[0]!.id);
onCreate();
} else {
setCreateMenuOpen((prev) => !prev);
}
}}
onKeyDown={(event) => {
if (newTaskRepos.length === 0) return;
if (event.key === "Enter" || event.key === " ") {
<Tooltip label="New task" placement="bottom">
<div
role="button"
tabIndex={0}
aria-disabled={newTaskRepos.length === 0}
onClick={() => {
if (newTaskRepos.length === 0) return;
if (newTaskRepos.length === 1) {
onSelectNewTaskRepo(newTaskRepos[0]!.id);
onCreate();
onCreate(newTaskRepos[0]!.id);
} else {
setCreateMenuOpen((prev) => !prev);
}
}
}}
className={css({
width: "26px",
height: "26px",
borderRadius: "8px",
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
color: t.textPrimary,
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
flexShrink: 0,
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
})}
>
<Plus size={14} style={{ display: "block" }} />
</div>
}}
onKeyDown={(event) => {
if (newTaskRepos.length === 0) return;
if (event.key === "Enter" || event.key === " ") {
if (newTaskRepos.length === 1) {
onSelectNewTaskRepo(newTaskRepos[0]!.id);
onCreate(newTaskRepos[0]!.id);
} else {
setCreateMenuOpen((prev) => !prev);
}
}
}}
className={css({
width: "26px",
height: "26px",
borderRadius: "8px",
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
color: t.textPrimary,
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
display: "flex",
alignItems: "center",
justifyContent: "center",
transition: "background 200ms ease",
flexShrink: 0,
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
})}
>
<Plus size={14} style={{ display: "block" }} />
</div>
</Tooltip>
{createMenuOpen && newTaskRepos.length > 1 ? (
<div
className={css({
@ -303,7 +467,7 @@ export const Sidebar = memo(function Sidebar({
onClick={() => {
onSelectNewTaskRepo(repo.id);
setCreateMenuOpen(false);
onCreate();
onCreate(repo.id);
}}
className={css({
display: "flex",
@ -442,9 +606,31 @@ export const Sidebar = memo(function Sidebar({
>
{projectInitial(project.label)}
</span>
<span className={css({ position: "absolute", inset: 0, display: "none", alignItems: "center", justifyContent: "center" })} data-chevron>
<button
onClick={(e) => {
e.stopPropagation();
setCollapsedProjects((current) => ({
...current,
[project.id]: !current[project.id],
}));
}}
onMouseDown={(e) => e.stopPropagation()}
className={css({
position: "absolute",
inset: 0,
display: "none",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
background: "none",
border: "none",
padding: 0,
margin: 0,
})}
data-chevron
>
{isCollapsed ? <ChevronDown size={12} color={t.textTertiary} /> : <ChevronUp size={12} color={t.textTertiary} />}
</span>
</button>
</div>
<LabelSmall
color={t.textSecondary}
@ -468,7 +654,7 @@ export const Sidebar = memo(function Sidebar({
e.stopPropagation();
setHoveredProjectId(null);
onSelectNewTaskRepo(project.id);
onCreate();
onCreate(project.id);
}}
onMouseDown={(e) => e.stopPropagation()}
className={css({
@ -543,7 +729,7 @@ export const Sidebar = memo(function Sidebar({
position: "relative",
backgroundColor: isActive ? t.interactiveHover : "transparent",
opacity: isTaskBeingDragged ? 0.4 : 1,
cursor: "pointer",
cursor: drag?.type === "task" ? "grabbing" : "pointer",
transition: "all 150ms ease",
"::before": {
content: '""',
@ -607,6 +793,7 @@ export const Sidebar = memo(function Sidebar({
{formatRelativeAge(task.updatedAtMs)}
</LabelXSmall>
</div>
{task.presence.length > 0 && <PresenceAvatars presence={task.presence} />}
</div>
);
})}
@ -658,7 +845,7 @@ export const Sidebar = memo(function Sidebar({
/>
</div>
</ScrollBody>
<SidebarFooter />
{!hideSettings && <SidebarFooter />}
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</SPanel>
);
@ -945,34 +1132,36 @@ function SidebarFooter() {
</div>
) : null}
<div className={css({ padding: "8px" })}>
<button
type="button"
onClick={() => {
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 ? t.interactiveHover : "transparent",
color: open ? t.textPrimary : t.textTertiary,
cursor: "pointer",
transition: "all 160ms ease",
":hover": {
backgroundColor: t.interactiveHover,
color: t.textSecondary,
},
})}
>
<Settings size={14} />
</button>
<Tooltip label="Settings" placement="right">
<button
type="button"
onClick={() => {
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 ? t.interactiveHover : "transparent",
color: open ? t.textPrimary : t.textTertiary,
cursor: "pointer",
transition: "all 160ms ease",
":hover": {
backgroundColor: t.interactiveHover,
color: t.textSecondary,
},
})}
>
<Settings size={14} />
</button>
</Tooltip>
</div>
</div>
);

View file

@ -1,11 +1,11 @@
import { memo } from "react";
import { useStyletron } from "baseui";
import { LabelXSmall } from "baseui/typography";
import { FileCode, Plus, X } from "lucide-react";
import { FileCode, Plus, SquareTerminal, X } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui";
import { diffTabId, fileName, type Task } from "./view-model";
import { ContextMenuOverlay, TabAvatar, Tooltip, useContextMenu } from "./ui";
import { diffTabId, fileName, terminalTabId, type Task } from "./view-model";
export const TabStrip = memo(function TabStrip({
task,
@ -22,6 +22,8 @@ export const TabStrip = memo(function TabStrip({
onCloseTab,
onCloseDiffTab,
onAddTab,
terminalTabOpen,
onCloseTerminalTab,
sidebarCollapsed,
}: {
task: Task;
@ -38,6 +40,8 @@ export const TabStrip = memo(function TabStrip({
onCloseTab: (tabId: string) => void;
onCloseDiffTab: (path: string) => void;
onAddTab: () => void;
terminalTabOpen?: boolean;
onCloseTerminalTab?: () => void;
sidebarCollapsed?: boolean;
}) {
const [css] = useStyletron();
@ -216,21 +220,71 @@ export const TabStrip = memo(function TabStrip({
</div>
);
})}
<div
onClick={onAddTab}
className={css({
display: "flex",
alignItems: "center",
padding: "0 10px",
cursor: "pointer",
opacity: 0.4,
lineHeight: 0,
":hover": { opacity: 0.7 },
flexShrink: 0,
})}
>
<Plus size={14} color={t.textTertiary} />
</div>
{terminalTabOpen
? (() => {
const tabId = terminalTabId();
const isActive = tabId === activeTabId;
return (
<div
key={tabId}
onClick={() => onSwitchTab(tabId)}
onMouseDown={(event) => {
if (event.button === 1) {
event.preventDefault();
onCloseTerminalTab?.();
}
}}
data-tab
className={css({
display: "flex",
alignItems: "center",
gap: "6px",
padding: "4px 12px",
marginTop: "6px",
marginBottom: "6px",
borderRadius: "8px",
backgroundColor: isActive ? t.interactiveHover : "transparent",
cursor: "pointer",
transition: "color 200ms ease, background-color 200ms ease",
flexShrink: 0,
":hover": { color: t.textPrimary, backgroundColor: isActive ? t.interactiveHover : t.interactiveSubtle },
})}
>
<SquareTerminal size={12} color={isActive ? t.textPrimary : t.textSecondary} />
<LabelXSmall color={isActive ? t.textPrimary : t.textSecondary} $style={{ fontWeight: 500 }}>
Terminal
</LabelXSmall>
<X
size={11}
color={t.textTertiary}
data-tab-close
className={css({ cursor: "pointer", opacity: 0 })}
onClick={(event) => {
event.stopPropagation();
onCloseTerminalTab?.();
}}
/>
</div>
);
})()
: null}
<Tooltip label="New session" placement="bottom">
<div
onClick={onAddTab}
className={css({
display: "flex",
alignItems: "center",
padding: "0 10px",
cursor: "pointer",
opacity: 0.4,
lineHeight: 0,
":hover": { opacity: 0.7 },
flexShrink: 0,
})}
>
<Plus size={14} color={t.textTertiary} />
</div>
</Tooltip>
</div>
{contextMenu.menu ? <ContextMenuOverlay menu={contextMenu.menu} onClose={contextMenu.close} /> : null}
</>

View file

@ -3,7 +3,7 @@ import { ProcessTerminal } from "@sandbox-agent/react";
import { useQuery } from "@tanstack/react-query";
import { useStyletron } from "baseui";
import { useFoundryTokens } from "../../app/theme";
import { ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-react";
import { ArrowUpLeft, ChevronDown, ChevronUp, Plus, SquareTerminal, Trash2 } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { SandboxAgent } from "sandbox-agent";
import { backendClient } from "../../lib/backend";
@ -12,11 +12,21 @@ interface TerminalPaneProps {
workspaceId: string;
taskId: string | null;
isExpanded?: boolean;
hideHeader?: boolean;
onExpand?: () => void;
onCollapse?: () => void;
onStartResize?: (e: React.PointerEvent) => void;
onOpenTerminalTab?: () => void;
processTabs?: ProcessTab[];
onProcessTabsChange?: (tabs: ProcessTab[]) => void;
activeProcessTabId?: string | null;
onActiveProcessTabIdChange?: (id: string | null) => void;
customTabNames?: Record<string, string>;
onCustomTabNamesChange?: (names: Record<string, string>) => void;
}
export type { ProcessTab };
interface ProcessTab {
id: string;
processId: string;
@ -94,15 +104,66 @@ function HeaderIconButton({
);
}
export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onCollapse, onStartResize }: TerminalPaneProps) {
export function TerminalPane({
workspaceId,
taskId,
isExpanded,
hideHeader,
onExpand,
onCollapse,
onStartResize,
onOpenTerminalTab,
processTabs: controlledProcessTabs,
onProcessTabsChange,
activeProcessTabId: controlledActiveTabId,
onActiveProcessTabIdChange,
customTabNames: controlledCustomTabNames,
onCustomTabNamesChange,
}: TerminalPaneProps) {
const [css] = useStyletron();
const t = useFoundryTokens();
const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [processTabs, setProcessTabs] = useState<ProcessTab[]>([]);
const [internalActiveTabId, setInternalActiveTabId] = useState<string | null>(null);
const [internalProcessTabs, setInternalProcessTabs] = useState<ProcessTab[]>([]);
const [creatingProcess, setCreatingProcess] = useState(false);
const [hoveredTabId, setHoveredTabId] = useState<string | null>(null);
const [terminalClient, setTerminalClient] = useState<SandboxAgent | null>(null);
const [customTabNames, setCustomTabNames] = useState<Record<string, string>>({});
const [internalCustomTabNames, setInternalCustomTabNames] = useState<Record<string, string>>({});
const processTabs = controlledProcessTabs ?? internalProcessTabs;
const setProcessTabs = useCallback(
(update: ProcessTab[] | ((prev: ProcessTab[]) => ProcessTab[])) => {
if (onProcessTabsChange) {
const next = typeof update === "function" ? update(controlledProcessTabs ?? []) : update;
onProcessTabsChange(next);
} else {
setInternalProcessTabs(update);
}
},
[onProcessTabsChange, controlledProcessTabs],
);
const activeTabId = controlledActiveTabId !== undefined ? controlledActiveTabId : internalActiveTabId;
const setActiveTabId = useCallback(
(id: string | null) => {
if (onActiveProcessTabIdChange) {
onActiveProcessTabIdChange(id);
} else {
setInternalActiveTabId(id);
}
},
[onActiveProcessTabIdChange],
);
const customTabNames = controlledCustomTabNames ?? internalCustomTabNames;
const setCustomTabNames = useCallback(
(update: Record<string, string> | ((prev: Record<string, string>) => Record<string, string>)) => {
if (onCustomTabNamesChange) {
const next = typeof update === "function" ? update(controlledCustomTabNames ?? {}) : update;
onCustomTabNamesChange(next);
} else {
setInternalCustomTabNames(update);
}
},
[onCustomTabNamesChange, controlledCustomTabNames],
);
const [editingTabId, setEditingTabId] = useState<string | null>(null);
const editInputRef = useRef<HTMLInputElement>(null);
@ -135,7 +196,7 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
setProcessTabs((prev) => {
const next = [...prev];
const [moved] = next.splice(d.fromIdx, 1);
next.splice(d.overIdx!, 0, moved);
if (moved) next.splice(d.overIdx!, 0, moved);
return next;
});
}
@ -306,43 +367,48 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
};
}, [terminalClient]);
// Only reset on taskId change when using internal (uncontrolled) state.
// When controlled, the parent (MockLayout) owns per-task state via keyed records.
useEffect(() => {
setActiveTabId(null);
setProcessTabs([]);
}, [taskId]);
if (!controlledProcessTabs) {
setActiveTabId(null);
setProcessTabs([]);
}
}, [taskId]); // eslint-disable-line react-hooks/exhaustive-deps
const processes = processesQuery.data?.processes ?? [];
const openTerminalTab = useCallback((process: SandboxProcessRecord) => {
setProcessTabs((current) => {
const existing = current.find((tab) => tab.processId === process.id);
if (existing) {
setActiveTabId(existing.id);
return current;
}
const nextTab: ProcessTab = {
id: `terminal:${process.id}`,
processId: process.id,
title: formatProcessTabTitle(process, current.length + 1),
};
setActiveTabId(nextTab.id);
return [...current, nextTab];
});
}, []);
const closeTerminalTab = useCallback((tabId: string) => {
setProcessTabs((current) => {
const next = current.filter((tab) => tab.id !== tabId);
setActiveTabId((currentActive) => {
if (currentActive === tabId) {
return next.length > 0 ? next[next.length - 1]!.id : null;
const openTerminalTab = useCallback(
(process: SandboxProcessRecord) => {
setProcessTabs((current) => {
const existing = current.find((tab) => tab.processId === process.id);
if (existing) {
setActiveTabId(existing.id);
return current;
}
return currentActive;
const nextTab: ProcessTab = {
id: `terminal:${process.id}`,
processId: process.id,
title: formatProcessTabTitle(process, current.length + 1),
};
setActiveTabId(nextTab.id);
return [...current, nextTab];
});
return next;
});
}, []);
},
[setProcessTabs, setActiveTabId],
);
const closeTerminalTab = useCallback(
(tabId: string) => {
const next = processTabs.filter((tab) => tab.id !== tabId);
setProcessTabs(next);
if (activeTabId === tabId) {
setActiveTabId(next.length > 0 ? next[next.length - 1]!.id : null);
}
},
[processTabs, activeTabId, setProcessTabs, setActiveTabId],
);
const spawnTerminal = useCallback(async () => {
if (!activeSandbox?.sandboxId) {
@ -527,25 +593,27 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
overflow: "hidden",
})}
>
{/* Resize handle */}
<div
onPointerDown={onStartResize}
className={css({
height: "3px",
flexShrink: 0,
cursor: "ns-resize",
position: "relative",
"::before": {
content: '""',
position: "absolute",
top: "-2px",
left: 0,
right: 0,
height: "7px",
},
})}
/>
{/* Full-width header bar */}
{/* Resize handle — hidden when in tab view */}
{!hideHeader && (
<div
onPointerDown={onStartResize}
className={css({
height: "3px",
flexShrink: 0,
cursor: "ns-resize",
position: "relative",
"::before": {
content: '""',
position: "absolute",
top: "-2px",
left: 0,
right: 0,
height: "7px",
},
})}
/>
)}
{/* Header bar — in tab view, only show action buttons (no title/expand/chevron) */}
<div
className={css({
display: "flex",
@ -554,13 +622,17 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
minHeight: "39px",
maxHeight: "39px",
padding: "0 14px",
borderTop: `1px solid ${t.borderDefault}`,
borderTop: hideHeader ? "none" : `1px solid ${t.borderDefault}`,
backgroundColor: t.surfacePrimary,
flexShrink: 0,
})}
>
<SquareTerminal size={14} color={t.textTertiary} />
<span className={css({ fontSize: "12px", fontWeight: 600, color: t.textSecondary })}>Terminal</span>
{!hideHeader && (
<>
<SquareTerminal size={14} color={t.textTertiary} />
<span className={css({ fontSize: "12px", fontWeight: 600, color: t.textSecondary })}>Terminal</span>
</>
)}
<div className={css({ flex: 1 })} />
<div className={css({ display: "flex", alignItems: "center", gap: "2px" })}>
<HeaderIconButton
@ -585,14 +657,21 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
>
<Trash2 size={13} />
</HeaderIconButton>
<HeaderIconButton css={css} t={t} label={isExpanded ? "Collapse terminal" : "Expand terminal"} onClick={isExpanded ? onCollapse : onExpand}>
{isExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</HeaderIconButton>
{!hideHeader && onOpenTerminalTab ? (
<HeaderIconButton css={css} t={t} label="Open terminal in tab" onClick={onOpenTerminalTab}>
<ArrowUpLeft size={13} />
</HeaderIconButton>
) : null}
{!hideHeader && (
<HeaderIconButton css={css} t={t} label={isExpanded ? "Collapse terminal" : "Expand terminal"} onClick={isExpanded ? onCollapse : onExpand}>
{isExpanded ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</HeaderIconButton>
)}
</div>
</div>
{/* Two-column body: terminal left, list right — hidden when no tabs */}
{processTabs.length > 0 && (
{/* Two-column body: terminal left, list right — visible when expanded or when tabs exist */}
{(processTabs.length > 0 || hideHeader) && (
<div className={css({ flex: 1, minHeight: 0, display: "flex", flexDirection: "row" })}>
{/* Left: terminal content */}
<div className={css({ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" })}>{renderBody()}</div>

View file

@ -4,7 +4,7 @@ import { LabelSmall } from "baseui/typography";
import { Clock, PanelLeft, PanelRight } from "lucide-react";
import { useFoundryTokens } from "../../app/theme";
import { PanelHeaderBar } from "./ui";
import { PanelHeaderBar, Tooltip } from "./ui";
import { type AgentTab, type Task } from "./view-model";
export const TranscriptHeader = memo(function TranscriptHeader({
@ -50,25 +50,27 @@ export const TranscriptHeader = memo(function TranscriptHeader({
return (
<PanelHeaderBar $style={{ backgroundColor: t.surfaceSecondary, 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: t.textTertiary,
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
onClick={onToggleSidebar}
onMouseEnter={onSidebarPeekStart}
onMouseLeave={onSidebarPeekEnd}
>
<PanelLeft size={14} />
</div>
<Tooltip label="Toggle sidebar" placement="bottom">
<div
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: t.textTertiary,
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
onClick={onToggleSidebar}
onMouseEnter={onSidebarPeekStart}
onMouseLeave={onSidebarPeekEnd}
>
<PanelLeft size={14} />
</div>
</Tooltip>
) : null}
{editingField === "title" ? (
<input
@ -190,23 +192,25 @@ export const TranscriptHeader = memo(function TranscriptHeader({
<span>{task.minutesUsed ?? 0} min used</span>
</div>
{rightSidebarCollapsed && onToggleRightSidebar ? (
<div
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: t.textTertiary,
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
onClick={onToggleRightSidebar}
>
<PanelRight size={14} />
</div>
<Tooltip label="Toggle changes" placement="bottom">
<div
className={css({
width: "26px",
height: "26px",
borderRadius: "6px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
color: t.textTertiary,
flexShrink: 0,
":hover": { color: t.textSecondary, backgroundColor: t.interactiveHover },
})}
onClick={onToggleRightSidebar}
>
<PanelRight size={14} />
</div>
</Tooltip>
) : null}
</PanelHeaderBar>
);

View file

@ -1,4 +1,5 @@
import { memo, useCallback, useEffect, useState, type MouseEvent } from "react";
import { memo, useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { styled, useStyletron } from "baseui";
import { GitPullRequest, GitPullRequestDraft } from "lucide-react";
@ -210,6 +211,115 @@ export const ScrollBody = styled("div", () => ({
flexDirection: "column" as const,
}));
export const Tooltip = memo(function Tooltip({
label,
children,
placement = "bottom",
}: {
label: string;
children: ReactNode;
placement?: "top" | "bottom" | "left" | "right";
}) {
const [css] = useStyletron();
const t = useFoundryTokens();
const triggerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const show = useCallback(() => {
const el = triggerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
let top: number;
let left: number;
if (placement === "bottom") {
top = rect.bottom + 6;
left = rect.left + rect.width / 2;
} else if (placement === "top") {
top = rect.top - 6;
left = rect.left + rect.width / 2;
} else if (placement === "left") {
top = rect.top + rect.height / 2;
left = rect.left - 6;
} else {
top = rect.top + rect.height / 2;
left = rect.right + 6;
}
setPos({ top, left });
}, [placement]);
const hide = useCallback(() => setPos(null), []);
// Clamp tooltip position after it renders so it stays within the viewport
useEffect(() => {
if (!pos) return;
const tip = tooltipRef.current;
if (!tip) return;
const tipRect = tip.getBoundingClientRect();
const pad = 8;
let adjustLeft = 0;
let adjustTop = 0;
if (tipRect.right > window.innerWidth - pad) {
adjustLeft = window.innerWidth - pad - tipRect.right;
}
if (tipRect.left < pad) {
adjustLeft = pad - tipRect.left;
}
if (tipRect.bottom > window.innerHeight - pad) {
adjustTop = window.innerHeight - pad - tipRect.bottom;
}
if (tipRect.top < pad) {
adjustTop = pad - tipRect.top;
}
if (adjustLeft !== 0 || adjustTop !== 0) {
setPos((prev) => prev && { top: prev.top + adjustTop, left: prev.left + adjustLeft });
}
}, [pos]);
const transform =
placement === "bottom"
? "translateX(-50%)"
: placement === "top"
? "translateX(-50%) translateY(-100%)"
: placement === "left"
? "translateX(-100%) translateY(-50%)"
: "translateY(-50%)";
return (
<div ref={triggerRef} onMouseEnter={show} onMouseLeave={hide} className={css({ display: "inline-flex" })}>
{children}
{pos &&
createPortal(
<div
ref={tooltipRef}
className={css({
position: "fixed",
top: `${pos.top}px`,
left: `${pos.left}px`,
transform,
zIndex: 99999,
pointerEvents: "none",
padding: "4px 8px",
borderRadius: "6px",
backgroundColor: "rgba(32, 32, 32, 0.98)",
backdropFilter: "blur(12px)",
border: `1px solid ${t.borderDefault}`,
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.4)",
color: "#e0e0e0",
fontSize: "11px",
fontWeight: 500,
lineHeight: "1.3",
whiteSpace: "nowrap",
})}
>
{label}
</div>,
document.body,
)}
</div>
);
});
export const HEADER_HEIGHT = "42px";
export const PROMPT_TEXTAREA_MIN_HEIGHT = 56;
export const PROMPT_TEXTAREA_MAX_HEIGHT = 100;

View file

@ -102,6 +102,7 @@ export function providerAgent(provider: string): AgentKind {
}
const DIFF_PREFIX = "diff:";
const TERMINAL_PREFIX = "terminal:";
export function isDiffTab(id: string): boolean {
return id.startsWith(DIFF_PREFIX);
@ -115,6 +116,14 @@ export function diffTabId(path: string): string {
return `${DIFF_PREFIX}${path}`;
}
export function isTerminalTab(id: string): boolean {
return id.startsWith(TERMINAL_PREFIX);
}
export function terminalTabId(): string {
return `${TERMINAL_PREFIX}main`;
}
export function fileName(path: string): string {
return path.split("/").pop() ?? path;
}

View file

@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, 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, Clock, CreditCard, FileText, Github, LogOut, Moon, Settings, Sun, Users } from "lucide-react";
import { ArrowLeft, Clock, CreditCard, FileText, Github, LogOut, Moon, Settings, Sun, Users, Volume2 } from "lucide-react";
import { NOTIFICATION_SOUND_OPTIONS, previewNotificationSound, useNotificationSound } from "../lib/notification-sound";
import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
import { isMockFrontendClient } from "../lib/env";
import { useIsMobile } from "../lib/platform";
import { useColorMode, useFoundryTokens } from "../app/theme";
import type { FoundryTokens } from "../styles/tokens";
import { appSurfaceStyle, primaryButtonStyle, secondaryButtonStyle, subtleButtonStyle, cardStyle, badgeStyle, inputStyle } from "../styles/shared-styles";
@ -124,7 +126,7 @@ function statusBadge(t: FoundryTokens, organization: FoundryOrganization) {
function githubBadge(t: FoundryTokens, organization: FoundryOrganization) {
if (organization.github.installationStatus === "connected") {
return <span style={badgeStyle(t, "rgba(46, 160, 67, 0.16)", "#b7f0c3")}>GitHub connected</span>;
return <span style={badgeStyle(t, "rgba(46, 160, 67, 0.16)", "#1a7f37")}>GitHub connected</span>;
}
if (organization.github.installationStatus === "reconnect_required") {
return <span style={badgeStyle(t, "rgba(255, 193, 7, 0.18)", "#ffe6a6")}>Reconnect required</span>;
@ -164,9 +166,42 @@ function MemberRow({ member }: { member: FoundryOrganizationMember }) {
alignItems: "center",
}}
>
<div>
<div style={{ fontWeight: 500, fontSize: "12px" }}>{member.name}</div>
<div style={{ color: t.textSecondary, fontSize: "11px" }}>{member.email}</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px", overflow: "hidden" }}>
{member.avatarUrl ? (
<img
src={member.avatarUrl}
alt={member.name}
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
flexShrink: 0,
objectFit: "cover",
}}
/>
) : (
<div
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
flexShrink: 0,
backgroundColor: t.interactiveHover,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
fontWeight: 600,
color: t.textSecondary,
}}
>
{member.name.charAt(0).toUpperCase()}
</div>
)}
<div style={{ overflow: "hidden" }}>
<div style={{ fontWeight: 500, fontSize: "12px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{member.name}</div>
<div style={{ color: t.textSecondary, fontSize: "11px", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{member.email}</div>
</div>
</div>
<div style={{ color: t.textSecondary, fontSize: "12px", textTransform: "capitalize" }}>{member.role}</div>
<div>
@ -551,16 +586,130 @@ function SettingsLayout({
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const t = useFoundryTokens();
const isMobile = useIsMobile();
const navSections: Array<{ section: SettingsSection; icon: React.ReactNode; label: string }> = [
{ section: "settings", icon: <Settings size={13} />, label: "Settings" },
{ section: "members", icon: <Users size={13} />, label: "Members" },
{ section: "billing", icon: <CreditCard size={13} />, label: "Billing & Invoices" },
{ section: "billing", icon: <CreditCard size={13} />, label: "Billing" },
{ section: "docs", icon: <FileText size={13} />, label: "Docs" },
];
const goBack = () => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
};
const handleNavClick = (item: (typeof navSections)[0]) => {
if (item.section === "billing") {
void navigate({ to: billingPath(organization) });
} else if (onSectionChange) {
onSectionChange(item.section);
} else {
void navigate({ to: settingsPath(organization) });
}
};
if (isMobile) {
return (
<div
style={{
...appSurfaceStyle(t),
height: "100dvh",
maxHeight: "100dvh",
display: "flex",
flexDirection: "column",
paddingTop: "max(var(--safe-area-top), 47px)",
}}
>
{/* Mobile header */}
<div
style={{
flexShrink: 0,
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: "8px",
borderBottom: `1px solid ${t.borderSubtle}`,
}}
>
<button
type="button"
onClick={goBack}
style={{
...subtleButtonStyle(t),
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
padding: 0,
borderRadius: "8px",
flexShrink: 0,
}}
>
<ArrowLeft size={16} />
</button>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: "15px", fontWeight: 600 }}>{organization.settings.displayName}</div>
<div style={{ fontSize: "11px", color: t.textMuted }}>{planCatalog[organization.billing.planId]?.label ?? "Free"} Plan</div>
</div>
</div>
{/* Mobile tab strip */}
<div
style={{
flexShrink: 0,
display: "flex",
gap: "2px",
padding: "6px 12px",
overflowX: "auto",
borderBottom: `1px solid ${t.borderSubtle}`,
}}
>
{navSections.map((item) => {
const isActive = activeSection === item.section;
return (
<button
key={item.section}
type="button"
onClick={() => handleNavClick(item)}
style={{
display: "flex",
alignItems: "center",
gap: "5px",
padding: "6px 12px",
borderRadius: "8px",
border: "none",
background: isActive ? t.interactiveHover : "transparent",
color: isActive ? t.textPrimary : t.textMuted,
cursor: "pointer",
fontSize: "12px",
fontWeight: isActive ? 500 : 400,
whiteSpace: "nowrap",
fontFamily: "'IBM Plex Sans', 'Segoe UI', system-ui, sans-serif",
flexShrink: 0,
}}
>
{item.icon}
{item.label}
</button>
);
})}
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: "auto", padding: "20px 16px 40px" }}>
<div style={{ maxWidth: "560px" }}>{children}</div>
</div>
</div>
);
}
return (
<div style={appSurfaceStyle(t)}>
<div style={{ ...appSurfaceStyle(t), height: "100dvh", maxHeight: "100dvh" }}>
<DesktopDragRegion />
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
{/* Left nav */}
@ -579,12 +728,7 @@ function SettingsLayout({
{/* Back to workspace */}
<button
type="button"
onClick={() => {
void (async () => {
await client.selectOrganization(organization.id);
await navigate({ to: workspacePath(organization) });
})();
}}
onClick={goBack}
style={{
...subtleButtonStyle(t),
display: "flex",
@ -612,15 +756,7 @@ function SettingsLayout({
icon={item.icon}
label={item.label}
active={activeSection === item.section}
onClick={() => {
if (item.section === "billing") {
void navigate({ to: billingPath(organization) });
} else if (onSectionChange) {
onSectionChange(item.section);
} else {
void navigate({ to: settingsPath(organization) });
}
}}
onClick={() => handleNavClick(item)}
/>
))}
</div>
@ -692,6 +828,8 @@ export function MockOrganizationSettingsPage({ organization }: { organization: F
<AppearanceSection />
<NotificationSoundSection />
<SettingsContentSection
title="GitHub"
description={`Connected as ${organization.github.connectedAccount}. ${organization.github.importedRepoCount} repos imported.`}
@ -1090,6 +1228,7 @@ export function MockAccountSettingsPage() {
const user = activeMockUser(snapshot);
const navigate = useNavigate();
const t = useFoundryTokens();
const isMobile = useIsMobile();
const [name, setName] = useState(user?.name ?? "");
const [email, setEmail] = useState(user?.email ?? "");
@ -1098,8 +1237,168 @@ export function MockAccountSettingsPage() {
setEmail(user?.email ?? "");
}, [user?.name, user?.email]);
const accountContent = (
<div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
<div>
<h1 style={{ margin: "0 0 2px", fontSize: "15px", fontWeight: 600 }}>Account</h1>
<p style={{ margin: 0, fontSize: "11px", color: t.textMuted }}>Manage your personal account settings.</p>
</div>
<SettingsContentSection title="Profile">
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "4px" }}>
{user?.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name}
style={{
width: "48px",
height: "48px",
borderRadius: "50%",
objectFit: "cover",
border: `1px solid ${t.borderSubtle}`,
}}
/>
) : (
<div
style={{
width: "48px",
height: "48px",
borderRadius: "50%",
backgroundColor: t.interactiveHover,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "18px",
fontWeight: 600,
color: t.textSecondary,
border: `1px solid ${t.borderSubtle}`,
}}
>
{(user?.name ?? "U").charAt(0).toUpperCase()}
</div>
)}
<div>
<div style={{ fontSize: "13px", fontWeight: 600 }}>{user?.name ?? "User"}</div>
<div style={{ fontSize: "11px", color: t.textMuted }}>@{user?.githubLogin ?? ""}</div>
</div>
</div>
<label style={{ display: "grid", gap: "4px" }}>
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Display name</span>
<input value={name} onChange={(e) => setName(e.target.value)} style={inputStyle(t)} />
</label>
<label style={{ display: "grid", gap: "4px" }}>
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Email</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle(t)} />
</label>
<label style={{ display: "grid", gap: "4px" }}>
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>GitHub</span>
<input value={`@${user?.githubLogin ?? ""}`} readOnly style={{ ...inputStyle(t), color: t.textMuted }} />
</label>
<div>
<button type="button" style={primaryButtonStyle(t)}>
Save changes
</button>
</div>
</SettingsContentSection>
<SettingsContentSection title="Sessions" description="Manage your active sessions across devices.">
<SettingsRow label="Current session" description="This device — signed in via GitHub OAuth." />
</SettingsContentSection>
<SettingsContentSection title="Sign out" description="Sign out of Foundry on this device.">
<div>
<button
type="button"
onClick={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
style={{ ...secondaryButtonStyle(t), display: "inline-flex", alignItems: "center", gap: "6px" }}
>
<LogOut size={12} />
Sign out
</button>
</div>
</SettingsContentSection>
<SettingsContentSection title="Danger zone">
<SettingsRow
label="Delete account"
description="Permanently delete your account and all data."
action={
<button
type="button"
style={{
...secondaryButtonStyle(t),
borderColor: "rgba(255, 110, 110, 0.24)",
color: t.statusError,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
Delete
</button>
}
/>
</SettingsContentSection>
</div>
);
if (isMobile) {
return (
<div
style={{
...appSurfaceStyle(t),
height: "100dvh",
maxHeight: "100dvh",
display: "flex",
flexDirection: "column",
paddingTop: "max(var(--safe-area-top), 47px)",
}}
>
{/* Mobile header */}
<div
style={{
flexShrink: 0,
padding: "8px 12px",
display: "flex",
alignItems: "center",
gap: "8px",
borderBottom: `1px solid ${t.borderSubtle}`,
}}
>
<button
type="button"
onClick={() => void navigate({ to: "/" })}
style={{
...subtleButtonStyle(t),
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
padding: 0,
borderRadius: "8px",
flexShrink: 0,
}}
>
<ArrowLeft size={16} />
</button>
<div style={{ fontSize: "15px", fontWeight: 600 }}>Account</div>
</div>
{/* Content */}
<div style={{ flex: 1, overflowY: "auto", padding: "20px 16px 40px" }}>
<div style={{ maxWidth: "560px" }}>{accountContent}</div>
</div>
</div>
);
}
return (
<div style={appSurfaceStyle(t)}>
<div style={{ ...appSurfaceStyle(t), height: "100dvh", maxHeight: "100dvh" }}>
<DesktopDragRegion />
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
{/* Left nav */}
@ -1131,9 +1430,32 @@ export function MockAccountSettingsPage() {
Back to workspace
</button>
<div style={{ padding: "2px 10px 12px", display: "flex", flexDirection: "column", gap: "1px" }}>
<span style={{ fontSize: "12px", fontWeight: 600 }}>{user?.name ?? "User"}</span>
<span style={{ fontSize: "10px", color: t.textMuted }}>{user?.email ?? ""}</span>
<div style={{ padding: "2px 10px 12px", display: "flex", alignItems: "center", gap: "8px" }}>
{user?.avatarUrl ? (
<img src={user.avatarUrl} alt={user.name} style={{ width: "24px", height: "24px", borderRadius: "50%", objectFit: "cover", flexShrink: 0 }} />
) : (
<div
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
flexShrink: 0,
backgroundColor: t.interactiveHover,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
fontWeight: 600,
color: t.textSecondary,
}}
>
{(user?.name ?? "U").charAt(0).toUpperCase()}
</div>
)}
<div style={{ display: "flex", flexDirection: "column", gap: "1px", overflow: "hidden" }}>
<span style={{ fontSize: "12px", fontWeight: 600 }}>{user?.name ?? "User"}</span>
<span style={{ fontSize: "10px", color: t.textMuted }}>{user?.email ?? ""}</span>
</div>
</div>
<SettingsNavItem icon={<Settings size={13} />} label="General" active onClick={() => {}} />
@ -1141,77 +1463,7 @@ export function MockAccountSettingsPage() {
{/* Content */}
<div style={{ flex: 1, overflowY: "auto", padding: "80px 36px 40px" }}>
<div style={{ maxWidth: "560px" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "24px" }}>
<div>
<h1 style={{ margin: "0 0 2px", fontSize: "15px", fontWeight: 600 }}>Account</h1>
<p style={{ margin: 0, fontSize: "11px", color: t.textMuted }}>Manage your personal account settings.</p>
</div>
<SettingsContentSection title="Profile">
<label style={{ display: "grid", gap: "4px" }}>
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Display name</span>
<input value={name} onChange={(e) => setName(e.target.value)} style={inputStyle(t)} />
</label>
<label style={{ display: "grid", gap: "4px" }}>
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>Email</span>
<input value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle(t)} />
</label>
<label style={{ display: "grid", gap: "4px" }}>
<span style={{ fontSize: "11px", fontWeight: 500, color: t.textMuted }}>GitHub</span>
<input value={`@${user?.githubLogin ?? ""}`} readOnly style={{ ...inputStyle(t), color: t.textMuted }} />
</label>
<div>
<button type="button" style={primaryButtonStyle(t)}>
Save changes
</button>
</div>
</SettingsContentSection>
<SettingsContentSection title="Sessions" description="Manage your active sessions across devices.">
<SettingsRow label="Current session" description="This device — signed in via GitHub OAuth." />
</SettingsContentSection>
<SettingsContentSection title="Sign out" description="Sign out of Foundry on this device.">
<div>
<button
type="button"
onClick={() => {
void (async () => {
await client.signOut();
await navigate({ to: "/signin" });
})();
}}
style={{ ...secondaryButtonStyle(t), display: "inline-flex", alignItems: "center", gap: "6px" }}
>
<LogOut size={12} />
Sign out
</button>
</div>
</SettingsContentSection>
<SettingsContentSection title="Danger zone">
<SettingsRow
label="Delete account"
description="Permanently delete your account and all data."
action={
<button
type="button"
style={{
...secondaryButtonStyle(t),
borderColor: "rgba(255, 110, 110, 0.24)",
color: t.statusError,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
Delete
</button>
}
/>
</SettingsContentSection>
</div>
</div>
<div style={{ maxWidth: "560px" }}>{accountContent}</div>
</div>
</div>
</div>
@ -1238,7 +1490,7 @@ function AppearanceSection() {
height: "20px",
borderRadius: "10px",
border: "1px solid rgba(128, 128, 128, 0.3)",
background: isDark ? t.borderDefault : t.accent,
background: isDark ? t.borderDefault : t.textPrimary,
cursor: "pointer",
padding: 0,
transition: "background 0.2s",
@ -1260,7 +1512,7 @@ function AppearanceSection() {
justifyContent: "center",
}}
>
{isDark ? <Moon size={8} /> : <Sun size={8} color={t.accent} />}
{isDark ? <Moon size={8} /> : <Sun size={8} color={t.textPrimary} />}
</div>
</button>
}
@ -1268,3 +1520,130 @@ function AppearanceSection() {
</SettingsContentSection>
);
}
function NotificationSoundSection() {
const t = useFoundryTokens();
const [selected, setSelected] = useNotificationSound();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const selectedLabel = NOTIFICATION_SOUND_OPTIONS.find((o) => o.id === selected)?.label ?? "None";
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<SettingsContentSection title="Notifications" description="Play a sound when the agent finishes and needs your input.">
<SettingsRow
label="Completion sound"
description={selected === "none" ? "No sound will play." : `"${selectedLabel}" will play when the agent is done.`}
action={
<div ref={containerRef} style={{ position: "relative", display: "flex", alignItems: "center", gap: "6px" }}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
style={{
display: "flex",
alignItems: "center",
gap: "6px",
padding: "5px 10px",
borderRadius: "6px",
border: `1px solid ${t.borderDefault}`,
background: t.interactiveSubtle,
color: t.textPrimary,
fontSize: "11px",
fontWeight: 500,
fontFamily: "inherit",
cursor: "pointer",
minWidth: "90px",
justifyContent: "space-between",
}}
>
<span>{selectedLabel}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke={t.textTertiary} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d={open ? "m18 15-6-6-6 6" : "m6 9 6 6 6-6"} />
</svg>
</button>
{open && (
<div
style={{
position: "absolute",
top: "calc(100% + 4px)",
right: 0,
minWidth: "160px",
backgroundColor: "rgba(32, 32, 32, 0.98)",
backdropFilter: "blur(12px)",
borderRadius: "10px",
border: `1px solid ${t.borderDefault}`,
boxShadow: `0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px ${t.interactiveSubtle}`,
padding: "4px 0",
zIndex: 200,
}}
>
{NOTIFICATION_SOUND_OPTIONS.map((option) => {
const isActive = option.id === selected;
return (
<button
key={option.id}
type="button"
onClick={() => {
setSelected(option.id);
setOpen(false);
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = t.borderSubtle;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.backgroundColor = "transparent";
}}
style={
{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "8px",
width: "calc(100% - 8px)",
padding: "6px 12px",
border: "none",
background: "transparent",
color: isActive ? t.textPrimary : t.textSecondary,
fontSize: "12px",
fontWeight: isActive ? 600 : 400,
fontFamily: "inherit",
cursor: "pointer",
borderRadius: "6px",
margin: "0 4px",
boxSizing: "border-box",
} as React.CSSProperties
}
>
<span>{option.label}</span>
{option.id !== "none" && (
<Volume2
size={11}
color={isActive ? t.accent : t.textTertiary}
style={{ cursor: "pointer", flexShrink: 0 }}
onClick={(e) => {
e.stopPropagation();
previewNotificationSound(option.id);
}}
/>
)}
</button>
);
})}
</div>
)}
</div>
}
/>
</SettingsContentSection>
);
}

View file

@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
export type NotificationSoundId = "none" | "chime" | "ping";
const SOUNDS: Record<Exclude<NotificationSoundId, "none">, { label: string; src: string }> = {
chime: { label: "Chime", src: "/sounds/notification-1.mp3" },
ping: { label: "Ping", src: "/sounds/notification-2.mp3" },
};
const STORAGE_KEY = "foundry:notification-sound";
let currentValue: NotificationSoundId = (localStorage.getItem(STORAGE_KEY) as NotificationSoundId) || "none";
const listeners = new Set<() => void>();
function notify() {
for (const listener of listeners) {
listener();
}
}
function getSnapshot(): NotificationSoundId {
return currentValue;
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function setNotificationSound(id: NotificationSoundId) {
currentValue = id;
localStorage.setItem(STORAGE_KEY, id);
notify();
}
export function useNotificationSound(): [NotificationSoundId, (id: NotificationSoundId) => void] {
const value = useSyncExternalStore(subscribe, getSnapshot);
return [value, setNotificationSound];
}
export function playNotificationSound() {
const id = getSnapshot();
if (id === "none") return;
const sound = SOUNDS[id];
if (!sound) return;
const audio = new Audio(sound.src);
audio.volume = 0.6;
audio.play().catch(() => {});
}
export function previewNotificationSound(id: NotificationSoundId) {
if (id === "none") return;
const sound = SOUNDS[id];
if (!sound) return;
const audio = new Audio(sound.src);
audio.volume = 0.6;
audio.play().catch(() => {});
}
export function useAgentDoneNotification(status: "running" | "idle" | "error" | undefined) {
const prevStatus = useRef(status);
useEffect(() => {
const prev = prevStatus.current;
prevStatus.current = status;
if (prev === "running" && status === "idle") {
playNotificationSound();
}
}, [status]);
}
export const NOTIFICATION_SOUND_OPTIONS: { id: NotificationSoundId; label: string }[] = [
{ id: "none", label: "None" },
{ id: "chime", label: "Chime" },
{ id: "ping", label: "Ping" },
];

View file

@ -0,0 +1,55 @@
import { useSyncExternalStore } from "react";
const MOBILE_BREAKPOINT = 768;
/** True when built with VITE_MOBILE=1 (Tauri mobile build) */
export const isNativeMobile = !!import.meta.env.VITE_MOBILE;
/** True when built with VITE_DESKTOP=1 (Tauri desktop build) */
export const isNativeDesktop = !!import.meta.env.VITE_DESKTOP;
/** True when running inside any Tauri shell */
export const isNativeApp = isNativeMobile || isNativeDesktop;
function getIsMobileViewport(): boolean {
if (typeof window === "undefined") return false;
return window.innerWidth < MOBILE_BREAKPOINT;
}
let currentIsMobile = isNativeMobile || getIsMobileViewport();
const listeners = new Set<() => void>();
if (typeof window !== "undefined") {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
mql.addEventListener("change", (e) => {
const next = isNativeMobile || e.matches;
if (next !== currentIsMobile) {
currentIsMobile = next;
for (const fn of listeners) fn();
}
});
}
function subscribe(cb: () => void) {
listeners.add(cb);
return () => {
listeners.delete(cb);
};
}
function getSnapshot() {
return currentIsMobile;
}
/**
* Returns true when the app should render in mobile layout.
* This is true when:
* - Built with VITE_MOBILE=1 (always mobile), OR
* - Viewport width is below 768px (responsive web)
*
* Re-renders when the viewport crosses the breakpoint.
*/
export function useIsMobile(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -5,6 +5,10 @@
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: var(--f-surface-primary, #000000);
color: var(--f-text-primary, #ffffff);
--safe-area-top: env(safe-area-inset-top, 0px);
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
--safe-area-left: env(safe-area-inset-left, 0px);
--safe-area-right: env(safe-area-inset-right, 0px);
}
html,
@ -44,6 +48,41 @@ a {
}
}
@keyframes hf-dot-fade {
0%, 80%, 100% {
opacity: 0.2;
}
40% {
opacity: 1;
}
}
@keyframes hf-typing-in {
from {
opacity: 0;
max-height: 0;
transform: translateY(8px);
}
to {
opacity: 1;
max-height: 40px;
transform: translateY(0);
}
}
@keyframes hf-typing-out {
from {
opacity: 1;
max-height: 40px;
transform: translateY(0);
}
to {
opacity: 0;
max-height: 0;
transform: translateY(8px);
}
}
button,
input,
textarea,