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

@ -245,6 +245,7 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
name: session.currentUserName ?? session.currentUserGithubLogin ?? "GitHub user",
email: session.currentUserEmail ?? "",
githubLogin: session.currentUserGithubLogin ?? "",
avatarUrl: session.currentUserGithubLogin ? `https://github.com/${session.currentUserGithubLogin}.png` : null,
roleLabel: session.currentUserRoleLabel ?? "GitHub user",
eligibleOrganizationIds: organizations.map((organization) => organization.id),
}

View file

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

View file

@ -100,6 +100,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
diffs: {},
fileTree: [],
minutesUsed: 0,
presence: [],
};
this.updateState((current) => ({

View file

@ -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: [],
},
];
}

View file

@ -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": {

View file

@ -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(/<script\s+src="https:\/\/unpkg\.com\/react-scan\/dist\/auto\.global\.js"[^>]*><\/script>\s*/g, "");
writeFileSync(indexPath, html);
console.log("\n=== Mobile frontend build complete ===\n");

View file

@ -3,13 +3,20 @@ name = "foundry"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["staticlib", "cdylib", "lib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["time"] }
# Shell plugin is desktop-only (used for sidecar spawning).
# Exclude iOS and Android targets.
[target.'cfg(not(any(target_os = "ios", target_os = "android")))'.dependencies]
tauri-plugin-shell = "2"

View file

@ -2,6 +2,7 @@
"identifier": "default",
"description": "Default capability for Foundry desktop",
"windows": ["main"],
"platforms": ["macOS", "windows", "linux"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",

View file

@ -0,0 +1,7 @@
{
"identifier": "mobile",
"description": "Capability for Foundry mobile (iOS/Android)",
"windows": ["main"],
"platforms": ["iOS", "android"],
"permissions": ["core:default"]
}

View file

@ -0,0 +1,3 @@
xcuserdata/
build/
Externals/

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -0,0 +1,116 @@
{
"images": [
{
"size": "20x20",
"idiom": "iphone",
"filename": "AppIcon-20x20@2x.png",
"scale": "2x"
},
{
"size": "20x20",
"idiom": "iphone",
"filename": "AppIcon-20x20@3x.png",
"scale": "3x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "AppIcon-29x29@2x-1.png",
"scale": "2x"
},
{
"size": "29x29",
"idiom": "iphone",
"filename": "AppIcon-29x29@3x.png",
"scale": "3x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "AppIcon-40x40@2x.png",
"scale": "2x"
},
{
"size": "40x40",
"idiom": "iphone",
"filename": "AppIcon-40x40@3x.png",
"scale": "3x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "AppIcon-60x60@2x.png",
"scale": "2x"
},
{
"size": "60x60",
"idiom": "iphone",
"filename": "AppIcon-60x60@3x.png",
"scale": "3x"
},
{
"size": "20x20",
"idiom": "ipad",
"filename": "AppIcon-20x20@1x.png",
"scale": "1x"
},
{
"size": "20x20",
"idiom": "ipad",
"filename": "AppIcon-20x20@2x-1.png",
"scale": "2x"
},
{
"size": "29x29",
"idiom": "ipad",
"filename": "AppIcon-29x29@1x.png",
"scale": "1x"
},
{
"size": "29x29",
"idiom": "ipad",
"filename": "AppIcon-29x29@2x.png",
"scale": "2x"
},
{
"size": "40x40",
"idiom": "ipad",
"filename": "AppIcon-40x40@1x.png",
"scale": "1x"
},
{
"size": "40x40",
"idiom": "ipad",
"filename": "AppIcon-40x40@2x-1.png",
"scale": "2x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "AppIcon-76x76@1x.png",
"scale": "1x"
},
{
"size": "76x76",
"idiom": "ipad",
"filename": "AppIcon-76x76@2x.png",
"scale": "2x"
},
{
"size": "83.5x83.5",
"idiom": "ipad",
"filename": "AppIcon-83.5x83.5@2x.png",
"scale": "2x"
},
{
"size": "1024x1024",
"idiom": "ios-marketing",
"filename": "AppIcon-512@2x.png",
"scale": "1x"
}
],
"info": {
"version": 1,
"author": "xcode"
}
}

View file

@ -0,0 +1,6 @@
{
"info": {
"version": 1,
"author": "xcode"
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>debugging</string>
</dict>
</plist>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="17150" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="Y6W-OH-hqX">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="17122"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="s0d-6b-0kx">
<objects>
<viewController id="Y6W-OH-hqX" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" id="5EZ-qb-Rvc">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vDu-zF-Fre"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Ief-a0-LHa" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
<resources>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
</resources>
</document>

View file

@ -0,0 +1,21 @@
# Uncomment the next line to define a global platform for your project
target 'foundry_iOS' do
platform :ios, '14.0'
# Pods for foundry_iOS
end
target 'foundry_macOS' do
platform :osx, '11.0'
# Pods for foundry_macOS
end
# Delete the deployment target for iOS and macOS, causing it to be inherited from the Podfile
post_install do |installer|
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET'
config.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET'
end
end
end

View file

@ -0,0 +1,8 @@
#pragma once
namespace ffi {
extern "C" {
void start_app();
}
}

View file

@ -0,0 +1,6 @@
#include "bindings/bindings.h"
int main(int argc, char * argv[]) {
ffi::start_app();
return 0;
}

View file

@ -0,0 +1,460 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
18AA2DEF7E37375920E5C2A0 /* assets in Resources */ = {isa = PBXBuildFile; fileRef = 5839511CE1969F167776E5BA /* assets */; };
41F61AA6A6395C0E1A22A01D /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C796B246B7EFC59BE22BEAE /* Metal.framework */; };
46451E98CD8CC918B270DF4E /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4CA3839E082C9EE1FBE47017 /* CoreGraphics.framework */; };
5823D1D3CF428AB8675028CA /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 916B38007890BE6F5229D88A /* QuartzCore.framework */; };
63828790DD4F121B20E1314C /* libapp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 61706E406133F9FEA10A02CF /* libapp.a */; };
6DF20201B9860B37359D8373 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FB0AF0861142E98564AE14D /* WebKit.framework */; };
84F2A1451B638E04DC7D9C77 /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0FFD378E56937244899E32B9 /* UIKit.framework */; };
A391FE2BDE082320542B786E /* MetalKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9503D8DA5A521E10DE980308 /* MetalKit.framework */; };
A9D64B11D3591BA69A9FF24D /* main.mm in Sources */ = {isa = PBXBuildFile; fileRef = 41768F9AEEC30C6F3A657349 /* main.mm */; };
C2EC09AB9A1F311F389731DA /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 73239B7F777F17978C5F9FB3 /* LaunchScreen.storyboard */; };
E05514D9EF3C91D648336FAF /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1BE59BDC6646E6E88C37132A /* Security.framework */; };
EF8153BCCF98E828B21D22B8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2C2D23C60F5F67FF1B8EF9BE /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
0FFD378E56937244899E32B9 /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
1BE59BDC6646E6E88C37132A /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; };
2C2D23C60F5F67FF1B8EF9BE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
41768F9AEEC30C6F3A657349 /* main.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = main.mm; sourceTree = "<group>"; };
46675940F25D441B14F4D9BD /* bindings.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = bindings.h; sourceTree = "<group>"; };
4BF135A484B3FD794B6BE633 /* main.rs */ = {isa = PBXFileReference; path = main.rs; sourceTree = "<group>"; };
4C796B246B7EFC59BE22BEAE /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
4CA3839E082C9EE1FBE47017 /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
5839511CE1969F167776E5BA /* assets */ = {isa = PBXFileReference; lastKnownFileType = folder; path = assets; sourceTree = SOURCE_ROOT; };
61706E406133F9FEA10A02CF /* libapp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libapp.a; sourceTree = "<group>"; };
6C52C06D919E696C88F980CA /* lib.rs */ = {isa = PBXFileReference; path = lib.rs; sourceTree = "<group>"; };
73239B7F777F17978C5F9FB3 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = "<group>"; };
7A2D8F7E3AD2D4401F4C016B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
7CD23EC29DD8E675CF62425F /* foundry_iOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = foundry_iOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
7FB0AF0861142E98564AE14D /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = System/Library/Frameworks/WebKit.framework; sourceTree = SDKROOT; };
916B38007890BE6F5229D88A /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; };
9503D8DA5A521E10DE980308 /* MetalKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MetalKit.framework; path = System/Library/Frameworks/MetalKit.framework; sourceTree = SDKROOT; };
E33D510DA88C9082AA568044 /* foundry_iOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = foundry_iOS.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
2A55D5F956F7DFC89A1EF6A3 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
63828790DD4F121B20E1314C /* libapp.a in Frameworks */,
46451E98CD8CC918B270DF4E /* CoreGraphics.framework in Frameworks */,
41F61AA6A6395C0E1A22A01D /* Metal.framework in Frameworks */,
A391FE2BDE082320542B786E /* MetalKit.framework in Frameworks */,
5823D1D3CF428AB8675028CA /* QuartzCore.framework in Frameworks */,
E05514D9EF3C91D648336FAF /* Security.framework in Frameworks */,
84F2A1451B638E04DC7D9C77 /* UIKit.framework in Frameworks */,
6DF20201B9860B37359D8373 /* WebKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1C4A0974016D1DFD8F8C4DDB /* Frameworks */ = {
isa = PBXGroup;
children = (
4CA3839E082C9EE1FBE47017 /* CoreGraphics.framework */,
61706E406133F9FEA10A02CF /* libapp.a */,
4C796B246B7EFC59BE22BEAE /* Metal.framework */,
9503D8DA5A521E10DE980308 /* MetalKit.framework */,
916B38007890BE6F5229D88A /* QuartzCore.framework */,
1BE59BDC6646E6E88C37132A /* Security.framework */,
0FFD378E56937244899E32B9 /* UIKit.framework */,
7FB0AF0861142E98564AE14D /* WebKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
2FD745FA11757414C89CF97B /* Products */ = {
isa = PBXGroup;
children = (
7CD23EC29DD8E675CF62425F /* foundry_iOS.app */,
);
name = Products;
sourceTree = "<group>";
};
3BB81CF687DD217A69E52F28 /* src */ = {
isa = PBXGroup;
children = (
6C52C06D919E696C88F980CA /* lib.rs */,
4BF135A484B3FD794B6BE633 /* main.rs */,
);
name = src;
path = ../../src;
sourceTree = "<group>";
};
57E35B817B051AAFCDF3B583 /* bindings */ = {
isa = PBXGroup;
children = (
46675940F25D441B14F4D9BD /* bindings.h */,
);
path = bindings;
sourceTree = "<group>";
};
8A9C64E5F1382764F31A9C3E = {
isa = PBXGroup;
children = (
5839511CE1969F167776E5BA /* assets */,
2C2D23C60F5F67FF1B8EF9BE /* Assets.xcassets */,
73239B7F777F17978C5F9FB3 /* LaunchScreen.storyboard */,
D848E3356050EA6A0C214268 /* Externals */,
A8C8B8DF2C7F39FBBD9C49F8 /* foundry_iOS */,
F7BF3354D37B8C8E0475FD89 /* Sources */,
3BB81CF687DD217A69E52F28 /* src */,
1C4A0974016D1DFD8F8C4DDB /* Frameworks */,
2FD745FA11757414C89CF97B /* Products */,
);
sourceTree = "<group>";
};
A8C8B8DF2C7F39FBBD9C49F8 /* foundry_iOS */ = {
isa = PBXGroup;
children = (
E33D510DA88C9082AA568044 /* foundry_iOS.entitlements */,
7A2D8F7E3AD2D4401F4C016B /* Info.plist */,
);
path = foundry_iOS;
sourceTree = "<group>";
};
D848E3356050EA6A0C214268 /* Externals */ = {
isa = PBXGroup;
children = (
);
path = Externals;
sourceTree = "<group>";
};
F5B02224172A9BB7345E5123 /* foundry */ = {
isa = PBXGroup;
children = (
41768F9AEEC30C6F3A657349 /* main.mm */,
57E35B817B051AAFCDF3B583 /* bindings */,
);
path = foundry;
sourceTree = "<group>";
};
F7BF3354D37B8C8E0475FD89 /* Sources */ = {
isa = PBXGroup;
children = (
F5B02224172A9BB7345E5123 /* foundry */,
);
path = Sources;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
B133A72EA416CD7D39FE3E92 /* foundry_iOS */ = {
isa = PBXNativeTarget;
buildConfigurationList = D445EACB90281484F8FFC686 /* Build configuration list for PBXNativeTarget "foundry_iOS" */;
buildPhases = (
FB6BE1E1013DC03BA292046D /* Build Rust Code */,
5D12D268150FB53F48EDE74C /* Sources */,
0391435C164ED2F1E2300246 /* Resources */,
2A55D5F956F7DFC89A1EF6A3 /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = foundry_iOS;
packageProductDependencies = (
);
productName = foundry_iOS;
productReference = 7CD23EC29DD8E675CF62425F /* foundry_iOS.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
376A77EF2CEF8254085FAA46 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
};
};
buildConfigurationList = 98E4F3D9D125FB8973826CB2 /* Build configuration list for PBXProject "foundry" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 8A9C64E5F1382764F31A9C3E;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = 2FD745FA11757414C89CF97B /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
B133A72EA416CD7D39FE3E92 /* foundry_iOS */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
0391435C164ED2F1E2300246 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
EF8153BCCF98E828B21D22B8 /* Assets.xcassets in Resources */,
C2EC09AB9A1F311F389731DA /* LaunchScreen.storyboard in Resources */,
18AA2DEF7E37375920E5C2A0 /* assets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
FB6BE1E1013DC03BA292046D /* Build Rust Code */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Build Rust Code";
outputFileListPaths = (
);
outputPaths = (
"$(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a",
"$(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths \"${FRAMEWORK_SEARCH_PATHS:?}\" --header-search-paths \"${HEADER_SEARCH_PATHS:?}\" --gcc-preprocessor-definitions \"${GCC_PREPROCESSOR_DEFINITIONS:-}\" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
5D12D268150FB53F48EDE74C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A9D64B11D3591BA69A9FF24D /* main.mm in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
2B285D0ECCDEA51DB6635985 /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
};
name = debug;
};
362B62575264ECB0984A8FC7 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = foundry_iOS/foundry_iOS.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = foundry_iOS/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = dev.sandboxagent.foundry;
PRODUCT_NAME = "Foundry";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = release;
};
6503634097DB1CB8102F723F /* debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ARCHS = (
arm64,
);
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = foundry_iOS/foundry_iOS.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_BITCODE = NO;
"EXCLUDED_ARCHS[sdk=iphoneos*]" = x86_64;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"\".\"",
);
INFOPLIST_FILE = foundry_iOS/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = dev.sandboxagent.foundry;
PRODUCT_NAME = "Foundry";
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
};
name = debug;
};
7DD9B8183C37530AE219A355 /* release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
};
name = release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
98E4F3D9D125FB8973826CB2 /* Build configuration list for PBXProject "foundry" */ = {
isa = XCConfigurationList;
buildConfigurations = (
2B285D0ECCDEA51DB6635985 /* debug */,
7DD9B8183C37530AE219A355 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
D445EACB90281484F8FFC686 /* Build configuration list for PBXNativeTarget "foundry_iOS" */ = {
isa = XCConfigurationList;
buildConfigurations = (
6503634097DB1CB8102F723F /* debug */,
362B62575264ECB0984A8FC7 /* release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = debug;
};
/* End XCConfigurationList section */
};
rootObject = 376A77EF2CEF8254085FAA46 /* Project object */;
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildSystemType</key>
<string>Original</string>
<key>DisableBuildSystemDeprecationDiagnostic</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B133A72EA416CD7D39FE3E92"
BuildableName = "Foundry.app"
BlueprintName = "foundry_iOS"
ReferencedContainer = "container:foundry.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B133A72EA416CD7D39FE3E92"
BuildableName = "Foundry.app"
BlueprintName = "foundry_iOS"
ReferencedContainer = "container:foundry.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B133A72EA416CD7D39FE3E92"
BuildableName = "Foundry.app"
BlueprintName = "foundry_iOS"
ReferencedContainer = "container:foundry.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "release"
shouldUseLaunchSchemeArgsEnv = "NO"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "B133A72EA416CD7D39FE3E92"
BuildableName = "Foundry.app"
BlueprintName = "foundry_iOS"
ReferencedContainer = "container:foundry.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<EnvironmentVariables>
<EnvironmentVariable
key = "RUST_BACKTRACE"
value = "full"
isEnabled = "YES">
</EnvironmentVariable>
<EnvironmentVariable
key = "RUST_LOG"
value = "info"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
<string>metal</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>

View file

@ -0,0 +1,88 @@
name: foundry
options:
bundleIdPrefix: dev.sandboxagent.foundry
deploymentTarget:
iOS: 14.0
fileGroups: [../../src]
configs:
debug: debug
release: release
settingGroups:
app:
base:
PRODUCT_NAME: Foundry
PRODUCT_BUNDLE_IDENTIFIER: dev.sandboxagent.foundry
targetTemplates:
app:
type: application
sources:
- path: Sources
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
groups: [app]
targets:
foundry_iOS:
type: application
platform: iOS
sources:
- path: Sources
- path: Assets.xcassets
- path: Externals
- path: foundry_iOS
- path: assets
buildPhase: resources
type: folder
- path: LaunchScreen.storyboard
info:
path: foundry_iOS/Info.plist
properties:
LSRequiresIPhoneOS: true
UILaunchStoryboardName: LaunchScreen
UIRequiredDeviceCapabilities: [arm64, metal]
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
CFBundleShortVersionString: 0.1.0
CFBundleVersion: "0.1.0"
entitlements:
path: foundry_iOS/foundry_iOS.entitlements
scheme:
environmentVariables:
RUST_BACKTRACE: full
RUST_LOG: info
settings:
base:
ENABLE_BITCODE: false
ARCHS: [arm64]
VALID_ARCHS: arm64
LIBRARY_SEARCH_PATHS[arch=x86_64]: $(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
LIBRARY_SEARCH_PATHS[arch=arm64]: $(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES: true
EXCLUDED_ARCHS[sdk=iphoneos*]: x86_64
groups: [app]
dependencies:
- framework: libapp.a
embed: false
- sdk: CoreGraphics.framework
- sdk: Metal.framework
- sdk: MetalKit.framework
- sdk: QuartzCore.framework
- sdk: Security.framework
- sdk: UIKit.framework
- sdk: WebKit.framework
preBuildScripts:
- script: pnpm tauri ios xcode-script -v --platform ${PLATFORM_DISPLAY_NAME:?} --sdk-root ${SDKROOT:?} --framework-search-paths "${FRAMEWORK_SEARCH_PATHS:?}" --header-search-paths "${HEADER_SEARCH_PATHS:?}" --gcc-preprocessor-definitions "${GCC_PREPROCESSOR_DEFINITIONS:-}" --configuration ${CONFIGURATION:?} ${FORCE_COLOR} ${ARCHS:?}
name: Build Rust Code
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a

View file

@ -1 +1,24 @@
{"default":{"identifier":"default","description":"Default capability for Foundry desktop","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-start-dragging","shell:allow-open",{"identifier":"shell:allow-execute","allow":[{"args":true,"name":"sidecars/foundry-backend","sidecar":true}]},{"identifier":"shell:allow-spawn","allow":[{"args":true,"name":"sidecars/foundry-backend","sidecar":true}]}]}}
{
"default": {
"identifier": "default",
"description": "Default capability for Foundry desktop",
"local": true,
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-start-dragging",
"shell:allow-open",
{ "identifier": "shell:allow-execute", "allow": [{ "args": true, "name": "sidecars/foundry-backend", "sidecar": true }] },
{ "identifier": "shell:allow-spawn", "allow": [{ "args": true, "name": "sidecars/foundry-backend", "sidecar": true }] }
],
"platforms": ["macOS", "windows", "linux"]
},
"mobile": {
"identifier": "mobile",
"description": "Capability for Foundry mobile (iOS/Android)",
"local": true,
"windows": ["main"],
"permissions": ["core:default"],
"platforms": ["iOS", "android"]
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,28 +1,44 @@
use std::sync::Mutex;
use tauri::{AppHandle, LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder};
#[cfg(not(mobile))]
use tauri::{LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder};
#[cfg(not(mobile))]
use tauri_plugin_shell::process::CommandChild;
#[cfg(not(mobile))]
use tauri_plugin_shell::ShellExt;
struct BackendState {
#[cfg(not(mobile))]
child: Mutex<Option<CommandChild>>,
backend_url: Mutex<String>,
}
#[tauri::command]
fn get_backend_url() -> String {
"http://127.0.0.1:7741".to_string()
fn get_backend_url(state: tauri::State<BackendState>) -> String {
state.backend_url.lock().unwrap().clone()
}
#[tauri::command]
async fn backend_health() -> Result<bool, String> {
match reqwest::get("http://127.0.0.1:7741/api/rivet/metadata").await {
fn set_backend_url(url: String, state: tauri::State<BackendState>) {
*state.backend_url.lock().unwrap() = url;
}
#[tauri::command]
async fn backend_health(state: tauri::State<'_, BackendState>) -> Result<bool, String> {
let base = state.backend_url.lock().unwrap().clone();
let url = format!("{}/api/rivet/metadata", base);
match reqwest::get(&url).await {
Ok(resp) => Ok(resp.status().is_success()),
Err(_) => Ok(false),
}
}
async fn wait_for_backend(timeout_secs: u64) -> Result<(), String> {
#[cfg(not(mobile))]
async fn wait_for_backend(base_url: String, timeout_secs: u64) -> Result<(), String> {
let start = std::time::Instant::now();
let timeout = std::time::Duration::from_secs(timeout_secs);
let url = format!("{}/api/rivet/metadata", base_url);
loop {
if start.elapsed() > timeout {
@ -32,7 +48,7 @@ async fn wait_for_backend(timeout_secs: u64) -> Result<(), String> {
));
}
match reqwest::get("http://127.0.0.1:7741/api/rivet/metadata").await {
match reqwest::get(&url).await {
Ok(resp) if resp.status().is_success() => return Ok(()),
_ => {}
}
@ -41,7 +57,8 @@ async fn wait_for_backend(timeout_secs: u64) -> Result<(), String> {
}
}
fn spawn_backend(app: &AppHandle) -> Result<(), String> {
#[cfg(not(mobile))]
fn spawn_backend(app: &tauri::AppHandle) -> Result<(), String> {
let sidecar = app
.shell()
.sidecar("sidecars/foundry-backend")
@ -88,65 +105,95 @@ fn spawn_backend(app: &AppHandle) -> Result<(), String> {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
let builder = tauri::Builder::default();
// Shell plugin is desktop-only (used for sidecar spawning)
#[cfg(not(mobile))]
let builder = builder.plugin(tauri_plugin_shell::init());
builder
.manage(BackendState {
#[cfg(not(mobile))]
child: Mutex::new(None),
backend_url: Mutex::new("http://127.0.0.1:7741".to_string()),
})
.invoke_handler(tauri::generate_handler![get_backend_url, backend_health])
.setup(|app| {
// Create main window programmatically so we can set traffic light position
let url = if cfg!(debug_assertions) {
WebviewUrl::External("http://localhost:4173".parse().unwrap())
} else {
WebviewUrl::default()
};
let mut builder = WebviewWindowBuilder::new(app, "main", url)
.title("Foundry")
.inner_size(1280.0, 800.0)
.min_inner_size(900.0, 600.0)
.resizable(true)
.theme(Some(tauri::Theme::Dark))
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
#[cfg(target_os = "macos")]
.invoke_handler(tauri::generate_handler![
get_backend_url,
set_backend_url,
backend_health
])
.setup(|_app| {
#[cfg(not(mobile))]
let app = _app;
// On desktop, create window programmatically for traffic light position
#[cfg(not(mobile))]
{
builder = builder.traffic_light_position(LogicalPosition::new(14.0, 14.0));
}
let url = if cfg!(debug_assertions) {
WebviewUrl::External("http://localhost:4173".parse().unwrap())
} else {
WebviewUrl::default()
};
builder.build()?;
let mut win_builder = WebviewWindowBuilder::new(app, "main", url)
.title("Foundry")
.inner_size(1280.0, 800.0)
.min_inner_size(900.0, 600.0)
.resizable(true)
.theme(Some(tauri::Theme::Dark))
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true);
// In debug mode, assume the developer is running the backend externally
if cfg!(debug_assertions) {
eprintln!("[foundry-desktop] Dev mode: skipping sidecar spawn. Run the backend separately.");
return Ok(());
}
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = spawn_backend(&handle) {
eprintln!("[foundry-desktop] Failed to start backend: {}", e);
return;
#[cfg(target_os = "macos")]
{
win_builder =
win_builder.traffic_light_position(LogicalPosition::new(14.0, 14.0));
}
match wait_for_backend(30).await {
Ok(()) => eprintln!("[foundry-desktop] Backend is ready."),
Err(e) => eprintln!("[foundry-desktop] {}", e),
win_builder.build()?;
}
// On mobile, Tauri creates the webview automatically — no window setup needed.
// The backend URL will be set by the frontend via set_backend_url.
// Sidecar spawning is desktop-only
#[cfg(not(mobile))]
{
// In debug mode, assume the developer is running the backend externally
if cfg!(debug_assertions) {
eprintln!(
"[foundry] Dev mode: skipping sidecar spawn. Run the backend separately."
);
return Ok(());
}
});
let handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = spawn_backend(&handle) {
eprintln!("[foundry] Failed to start backend: {}", e);
return;
}
match wait_for_backend("http://127.0.0.1:7741".to_string(), 30).await {
Ok(()) => eprintln!("[foundry] Backend is ready."),
Err(e) => eprintln!("[foundry] {}", e),
}
});
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::Destroyed = event {
let state = window.state::<BackendState>();
let child = state.child.lock().unwrap().take();
if let Some(child) = child {
let _ = child.kill();
eprintln!("[foundry-desktop] Backend sidecar killed.");
#[cfg(not(mobile))]
{
let state = window.state::<BackendState>();
let child = state.child.lock().unwrap().take();
if let Some(child) = child {
let _ = child.kill();
eprintln!("[foundry] Backend sidecar killed.");
}
}
let _ = window; // suppress unused warning on mobile
}
})
.run(tauri::generate_context!())

View file

@ -0,0 +1,10 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"build": {
"beforeDevCommand": "FOUNDRY_FRONTEND_CLIENT_MODE=mock VITE_MOBILE=1 pnpm --filter @sandbox-agent/foundry-frontend dev --host 0.0.0.0 --port 4173"
},
"bundle": {
"externalBin": []
},
"plugins": {}
}

View file

@ -0,0 +1,11 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"build": {
"beforeDevCommand": "FOUNDRY_FRONTEND_CLIENT_MODE=mock VITE_MOBILE=1 pnpm --filter @sandbox-agent/foundry-frontend dev --host 0.0.0.0 --port 4173",
"devUrl": "http://localhost:4173"
},
"bundle": {
"externalBin": []
},
"plugins": {}
}

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,

View file

@ -10,6 +10,7 @@ export interface FoundryUser {
name: string;
email: string;
githubLogin: string;
avatarUrl: string | null;
roleLabel: string;
eligibleOrganizationIds: string[];
}
@ -20,6 +21,8 @@ export interface FoundryOrganizationMember {
email: string;
role: "owner" | "admin" | "member";
state: "active" | "invited";
avatarUrl: string | null;
githubLogin: string | null;
}
export interface FoundryInvoice {

View file

@ -76,6 +76,14 @@ export interface WorkbenchPullRequestSummary {
status: "draft" | "ready";
}
export interface WorkbenchPresence {
memberId: string;
name: string;
avatarUrl: string | null;
lastSeenAtMs: number;
typing?: boolean;
}
export interface WorkbenchTask {
id: string;
repoId: string;
@ -90,6 +98,7 @@ export interface WorkbenchTask {
diffs: Record<string, string>;
fileTree: WorkbenchFileTreeNode[];
minutesUsed: number;
presence: WorkbenchPresence[];
}
export interface WorkbenchRepo {