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>
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient {
|
|||
diffs: {},
|
||||
fileTree: [],
|
||||
minutesUsed: 0,
|
||||
presence: [],
|
||||
};
|
||||
|
||||
this.updateState((current) => ({
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
42
foundry/packages/desktop/scripts/build-frontend-mobile.ts
Normal 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");
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"identifier": "mobile",
|
||||
"description": "Capability for Foundry mobile (iOS/Android)",
|
||||
"windows": ["main"],
|
||||
"platforms": ["iOS", "android"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
3
foundry/packages/desktop/src-tauri/gen/apple/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
xcuserdata/
|
||||
build/
|
||||
Externals/
|
||||
|
After Width: | Height: | Size: 1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "xcode"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
21
foundry/packages/desktop/src-tauri/gen/apple/Podfile
Normal 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
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#pragma once
|
||||
|
||||
namespace ffi {
|
||||
extern "C" {
|
||||
void start_app();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
#include "bindings/bindings.h"
|
||||
|
||||
int main(int argc, char * argv[]) {
|
||||
ffi::start_app();
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -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 */;
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
88
foundry/packages/desktop/src-tauri/gen/apple/project.yml
Normal 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
|
||||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2216
foundry/packages/desktop/src-tauri/gen/schemas/iOS-schema.json
Normal file
2216
foundry/packages/desktop/src-tauri/gen/schemas/mobile-schema.json
Normal 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!())
|
||||
|
|
|
|||
10
foundry/packages/desktop/src-tauri/tauri.android.conf.json
Normal 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": {}
|
||||
}
|
||||
11
foundry/packages/desktop/src-tauri/tauri.ios.conf.json
Normal 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": {}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
BIN
foundry/packages/frontend/public/sounds/notification-1.mp3
Normal file
BIN
foundry/packages/frontend/public/sounds/notification-2.mp3
Normal 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={() => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
77
foundry/packages/frontend/src/lib/notification-sound.ts
Normal 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" },
|
||||
];
|
||||
55
foundry/packages/frontend/src/lib/platform.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||