diff --git a/foundry/packages/backend/src/actors/workspace/app-shell.ts b/foundry/packages/backend/src/actors/workspace/app-shell.ts
index aff0fe18..8ac6b400 100644
--- a/foundry/packages/backend/src/actors/workspace/app-shell.ts
+++ b/foundry/packages/backend/src/actors/workspace/app-shell.ts
@@ -245,6 +245,7 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise organization.id),
}
diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts
index 61dadd27..bcc5ea85 100644
--- a/foundry/packages/client/src/mock-app.ts
+++ b/foundry/packages/client/src/mock-app.ts
@@ -12,6 +12,7 @@ export interface MockFoundryUser {
name: string;
email: string;
githubLogin: string;
+ avatarUrl: string | null;
roleLabel: string;
eligibleOrganizationIds: string[];
}
@@ -22,6 +23,8 @@ export interface MockFoundryOrganizationMember {
email: string;
role: "owner" | "admin" | "member";
state: "active" | "invited";
+ avatarUrl: string | null;
+ githubLogin: string | null;
}
export interface MockFoundryInvoice {
@@ -162,6 +165,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
name: "Nathan",
email: "nathan@acme.dev",
githubLogin: "nathan",
+ avatarUrl: "https://github.com/NathanFlurry.png",
roleLabel: "Founder",
eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"],
},
@@ -170,6 +174,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
name: "Maya",
email: "maya@acme.dev",
githubLogin: "maya",
+ avatarUrl: "https://github.com/octocat.png",
roleLabel: "Staff Engineer",
eligibleOrganizationIds: ["acme"],
},
@@ -178,6 +183,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
name: "Jamie",
email: "jamie@rivet.dev",
githubLogin: "jamie",
+ avatarUrl: "https://github.com/defunkt.png",
roleLabel: "Platform Lead",
eligibleOrganizationIds: ["personal-jamie", "rivet"],
},
@@ -213,7 +219,17 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
paymentMethodLabel: "No card required",
invoices: [],
},
- members: [{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" }],
+ members: [
+ {
+ id: "member-nathan",
+ name: "Nathan",
+ email: "nathan@acme.dev",
+ role: "owner",
+ state: "active",
+ avatarUrl: "https://github.com/NathanFlurry.png",
+ githubLogin: "NathanFlurry",
+ },
+ ],
seatAssignments: ["nathan@acme.dev"],
repoCatalog: ["nathan/personal-site"],
},
@@ -251,10 +267,34 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
],
},
members: [
- { id: "member-acme-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" },
- { id: "member-acme-maya", name: "Maya", email: "maya@acme.dev", role: "admin", state: "active" },
- { id: "member-acme-priya", name: "Priya", email: "priya@acme.dev", role: "member", state: "active" },
- { id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited" },
+ {
+ id: "member-acme-nathan",
+ name: "Nathan",
+ email: "nathan@acme.dev",
+ role: "owner",
+ state: "active",
+ avatarUrl: "https://github.com/NathanFlurry.png",
+ githubLogin: "NathanFlurry",
+ },
+ {
+ id: "member-acme-maya",
+ name: "Maya",
+ email: "maya@acme.dev",
+ role: "admin",
+ state: "active",
+ avatarUrl: "https://github.com/octocat.png",
+ githubLogin: "octocat",
+ },
+ {
+ id: "member-acme-priya",
+ name: "Priya",
+ email: "priya@acme.dev",
+ role: "member",
+ state: "active",
+ avatarUrl: "https://github.com/mona.png",
+ githubLogin: "mona",
+ },
+ { id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited", avatarUrl: null, githubLogin: null },
],
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
@@ -290,9 +330,33 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
},
members: [
- { id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
- { id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
- { id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" },
+ {
+ id: "member-rivet-jamie",
+ name: "Jamie",
+ email: "jamie@rivet.dev",
+ role: "owner",
+ state: "active",
+ avatarUrl: "https://github.com/defunkt.png",
+ githubLogin: "defunkt",
+ },
+ {
+ id: "member-rivet-nathan",
+ name: "Nathan",
+ email: "nathan@acme.dev",
+ role: "member",
+ state: "active",
+ avatarUrl: "https://github.com/NathanFlurry.png",
+ githubLogin: "NathanFlurry",
+ },
+ {
+ id: "member-rivet-lena",
+ name: "Lena",
+ email: "lena@rivet.dev",
+ role: "admin",
+ state: "active",
+ avatarUrl: "https://github.com/mojombo.png",
+ githubLogin: "mojombo",
+ },
],
seatAssignments: ["jamie@rivet.dev"],
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
@@ -327,7 +391,17 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
paymentMethodLabel: "No card required",
invoices: [],
},
- members: [{ id: "member-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }],
+ members: [
+ {
+ id: "member-jamie",
+ name: "Jamie",
+ email: "jamie@rivet.dev",
+ role: "owner",
+ state: "active",
+ avatarUrl: "https://github.com/defunkt.png",
+ githubLogin: "defunkt",
+ },
+ ],
seatAssignments: ["jamie@rivet.dev"],
repoCatalog: ["jamie/demo-app"],
},
diff --git a/foundry/packages/client/src/mock/workbench-client.ts b/foundry/packages/client/src/mock/workbench-client.ts
index f27c4363..fc2ce668 100644
--- a/foundry/packages/client/src/mock/workbench-client.ts
+++ b/foundry/packages/client/src/mock/workbench-client.ts
@@ -100,6 +100,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
diffs: {},
fileTree: [],
minutesUsed: 0,
+ presence: [],
};
this.updateState((current) => ({
diff --git a/foundry/packages/client/src/workbench-model.ts b/foundry/packages/client/src/workbench-model.ts
index 42cff084..01a75fb1 100644
--- a/foundry/packages/client/src/workbench-model.ts
+++ b/foundry/packages/client/src/workbench-model.ts
@@ -435,6 +435,10 @@ export function buildInitialTasks(): Task[] {
},
],
minutesUsed: 42,
+ presence: [
+ { memberId: "member-acme-nathan", name: "Nathan", avatarUrl: "https://github.com/NathanFlurry.png", lastSeenAtMs: minutesAgo(1) },
+ { memberId: "member-acme-maya", name: "Maya", avatarUrl: "https://github.com/octocat.png", lastSeenAtMs: minutesAgo(0), typing: true },
+ ],
},
{
id: "h2",
@@ -535,6 +539,7 @@ export function buildInitialTasks(): Task[] {
},
],
minutesUsed: 187,
+ presence: [{ memberId: "member-acme-priya", name: "Priya", avatarUrl: "https://github.com/mona.png", lastSeenAtMs: minutesAgo(0) }],
},
{
id: "h3",
@@ -609,6 +614,7 @@ export function buildInitialTasks(): Task[] {
},
],
minutesUsed: 23,
+ presence: [],
},
// ── rivet-dev/rivet ──
{
@@ -744,6 +750,11 @@ export function buildInitialTasks(): Task[] {
},
],
minutesUsed: 5,
+ presence: [
+ { memberId: "member-acme-nathan", name: "Nathan", avatarUrl: "https://github.com/NathanFlurry.png", lastSeenAtMs: minutesAgo(0) },
+ { memberId: "member-acme-maya", name: "Maya", avatarUrl: "https://github.com/octocat.png", lastSeenAtMs: minutesAgo(2) },
+ { memberId: "member-acme-priya", name: "Priya", avatarUrl: "https://github.com/mona.png", lastSeenAtMs: minutesAgo(5) },
+ ],
},
{
id: "h5",
@@ -800,6 +811,7 @@ export function buildInitialTasks(): Task[] {
diffs: {},
fileTree: [],
minutesUsed: 312,
+ presence: [{ memberId: "member-acme-maya", name: "Maya", avatarUrl: "https://github.com/octocat.png", lastSeenAtMs: minutesAgo(45) }],
},
// ── rivet-dev/cloud ──
{
@@ -909,6 +921,7 @@ export function buildInitialTasks(): Task[] {
},
],
minutesUsed: 0,
+ presence: [],
},
// ── rivet-dev/engine-ee ──
{
@@ -1023,6 +1036,7 @@ export function buildInitialTasks(): Task[] {
},
],
minutesUsed: 78,
+ presence: [],
},
// ── rivet-dev/engine-ee (archived) ──
{
@@ -1065,6 +1079,7 @@ export function buildInitialTasks(): Task[] {
diffs: {},
fileTree: [],
minutesUsed: 15,
+ presence: [],
},
// ── rivet-dev/secure-exec ──
{
@@ -1118,6 +1133,7 @@ export function buildInitialTasks(): Task[] {
diffs: {},
fileTree: [],
minutesUsed: 3,
+ presence: [],
},
];
}
diff --git a/foundry/packages/desktop/package.json b/foundry/packages/desktop/package.json
index 825d62d4..e3b8c966 100644
--- a/foundry/packages/desktop/package.json
+++ b/foundry/packages/desktop/package.json
@@ -5,10 +5,16 @@
"type": "module",
"scripts": {
"dev": "tauri dev",
+ "dev:ios": "VITE_MOBILE=1 tauri ios dev",
+ "dev:android": "VITE_MOBILE=1 tauri android dev",
"build": "tauri build",
+ "build:ios": "tauri ios build",
+ "build:android": "tauri android build",
"build:sidecar": "tsx scripts/build-sidecar.ts",
"build:frontend": "tsx scripts/build-frontend.ts",
+ "build:frontend:mobile": "tsx scripts/build-frontend-mobile.ts",
"build:all": "pnpm build:sidecar && pnpm build:frontend && pnpm build",
+ "build:all:ios": "pnpm build:frontend:mobile && pnpm build:ios",
"tauri": "tauri"
},
"devDependencies": {
diff --git a/foundry/packages/desktop/scripts/build-frontend-mobile.ts b/foundry/packages/desktop/scripts/build-frontend-mobile.ts
new file mode 100644
index 00000000..38d02a48
--- /dev/null
+++ b/foundry/packages/desktop/scripts/build-frontend-mobile.ts
@@ -0,0 +1,42 @@
+import { execSync } from "node:child_process";
+import { cpSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
+import { resolve, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const desktopRoot = resolve(__dirname, "..");
+const repoRoot = resolve(desktopRoot, "../../..");
+const frontendDist = resolve(desktopRoot, "../frontend/dist");
+const destDir = resolve(desktopRoot, "frontend-dist");
+
+function run(cmd: string, opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
+ console.log(`> ${cmd}`);
+ execSync(cmd, {
+ stdio: "inherit",
+ cwd: opts?.cwd ?? repoRoot,
+ env: { ...process.env, ...opts?.env },
+ });
+}
+
+// Step 1: Build the frontend for mobile (no hardcoded backend endpoint)
+console.log("\n=== Building frontend for mobile ===\n");
+run("pnpm --filter @sandbox-agent/foundry-frontend build", {
+ env: {
+ VITE_MOBILE: "1",
+ },
+});
+
+// Step 2: Copy dist to frontend-dist/
+console.log("\n=== Copying frontend build output ===\n");
+if (existsSync(destDir)) {
+ rmSync(destDir, { recursive: true });
+}
+cpSync(frontendDist, destDir, { recursive: true });
+
+// Step 3: Strip react-scan script from index.html (it loads unconditionally)
+const indexPath = resolve(destDir, "index.html");
+let html = readFileSync(indexPath, "utf-8");
+html = html.replace(/
-
+
Foundry
diff --git a/foundry/packages/frontend/public/sounds/notification-1.mp3 b/foundry/packages/frontend/public/sounds/notification-1.mp3
new file mode 100644
index 00000000..d678983c
Binary files /dev/null and b/foundry/packages/frontend/public/sounds/notification-1.mp3 differ
diff --git a/foundry/packages/frontend/public/sounds/notification-2.mp3 b/foundry/packages/frontend/public/sounds/notification-2.mp3
new file mode 100644
index 00000000..677a5810
Binary files /dev/null and b/foundry/packages/frontend/public/sounds/notification-2.mp3 differ
diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx
index 213109ee..0c4c2b11 100644
--- a/foundry/packages/frontend/src/components/mock-layout.tsx
+++ b/foundry/packages/frontend/src/components/mock-layout.tsx
@@ -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 (
+ {
+ if (animState === "out") setAnimState("hidden");
+ }}
+ >
+
+ {members.slice(0, 3).map((member) =>
+ member.avatarUrl ? (
+

+ ) : (
+
+ {member.name.charAt(0).toUpperCase()}
+
+ ),
+ )}
+
+
+ {label}
+ {[0, 1, 2].map((i) => (
+
+ .
+
+ ))}
+
+
+ );
+}
+
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;
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;
+ onTerminalCustomNamesChange?: (names: Record) => void;
+ mobile?: boolean;
}) {
const t = useFoundryTokens();
+ const transcriptAppSnapshot = useMockAppSnapshot();
+ const currentUser = activeMockUser(transcriptAppSnapshot);
const [defaultModel, setDefaultModel] = useState("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(null);
const messageRefs = useRef(new Map());
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 (
- {
- if (activeAgentTab) {
- setTabUnread(activeAgentTab.id, unread);
- }
- }}
- sidebarCollapsed={sidebarCollapsed}
- onToggleSidebar={onToggleSidebar}
- onSidebarPeekStart={onSidebarPeekStart}
- onSidebarPeekEnd={onSidebarPeekEnd}
- rightSidebarCollapsed={rightSidebarCollapsed}
- onToggleRightSidebar={onToggleRightSidebar}
- onNavigateToUsage={onNavigateToUsage}
- />
+ {!mobile && (
+ {
+ if (activeAgentTab) {
+ setTabUnread(activeAgentTab.id, unread);
+ }
+ }}
+ sidebarCollapsed={sidebarCollapsed}
+ onToggleSidebar={onToggleSidebar}
+ onSidebarPeekStart={onSidebarPeekStart}
+ onSidebarPeekEnd={onSidebarPeekEnd}
+ rightSidebarCollapsed={rightSidebarCollapsed}
+ onToggleRightSidebar={onToggleRightSidebar}
+ onNavigateToUsage={onNavigateToUsage}
+ />
+ )}
- {activeDiff ? (
+ {activeTerminal ? (
+
+
+
+ ) : activeDiff ? (
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}
/>
)}
- {!isTerminal && promptTab ? (
- updateDraft(value, attachments)}
- onSend={sendMessage}
- onStop={stopAgent}
- onRemoveAttachment={removeAttachment}
- onChangeModel={changeModel}
- onSetDefaultModel={setDefaultModel}
- />
+ {!isTerminal && !activeTerminal && promptTab ? (
+ <>
+
+ updateDraft(value, attachments)}
+ onSend={sendMessage}
+ onStop={stopAgent}
+ onRemoveAttachment={removeAttachment}
+ onChangeModel={changeModel}
+ onSetDefaultModel={setDefaultModel}
+ />
+ >
) : null}
@@ -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;
+ onTerminalCustomNamesChange?: (names: Record) => 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",
+ }
+ : {}),
})}
>
@@ -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}
/>
@@ -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(null);
const projects = useMemo(() => {
if (!projectOrder) return rawProjects;
@@ -922,6 +1131,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
const [activeTabIdByTask, setActiveTabIdByTask] = useState>({});
const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState>({});
const [openDiffsByTask, setOpenDiffsByTask] = useState>({});
+ const [terminalTabOpenByTask, setTerminalTabOpenByTask] = useState>({});
+ const [terminalProcessTabsByTask, setTerminalProcessTabsByTask] = useState>({});
+ const [terminalActiveTabIdByTask, setTerminalActiveTabIdByTask] = useState>({});
+ const [terminalCustomNamesByTask, setTerminalCustomNamesByTask] = useState>>({});
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) => {
+ 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 (
+ 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 ? (
{leftSidebarOpen ? null : (
-
setLeftSidebarOpen(true)}>
-
-
+
+ setLeftSidebarOpen(true)}>
+
+
+
)}
{rightSidebarOpen ? null : (
-
setRightSidebarOpen(true)}>
-
-
+
+ setRightSidebarOpen(true)}>
+
+
+
)}
) : null}
@@ -1409,7 +1727,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M