diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index ab3ef5af4..49116ef54 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -970,6 +970,22 @@ describe("adeRpcServer", () => { expect(names).not.toContain("spawn_worker"); expect(names).not.toContain("read_mission_status"); expect(names).not.toContain("get_cto_state"); + expect(names).not.toContain("get_environment_info"); + expect(names).not.toContain("launch_app"); + expect(names).not.toContain("interact_gui"); + expect(names).not.toContain("screenshot_environment"); + expect(names).not.toContain("record_environment"); + + const denied = await callTool(handler, "screenshot_environment", {}); + expect(denied.isError).toBe(true); + expect(JSON.stringify(denied.error ?? denied.structuredContent ?? {})).toContain( + "Unsupported tool: screenshot_environment", + ); + const environmentDenied = await callTool(handler, "get_environment_info", {}); + expect(environmentDenied.isError).toBe(true); + expect(JSON.stringify(environmentDenied.error ?? environmentDenied.structuredContent ?? {})).toContain( + "Unsupported tool: get_environment_info", + ); } finally { if (previousRole == null) delete process.env.ADE_DEFAULT_ROLE; else process.env.ADE_DEFAULT_ROLE = previousRole; diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 100821bc9..249518762 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2843,8 +2843,10 @@ function canCallerAccessCoordinatorTool(name: string, callerCtx: CallerContext): return false; } -function isLocalComputerUseAllowed(): boolean { - return true; +function isLocalComputerUseAllowed(callerCtx: CallerContext): boolean { + return callerCtx.role === "cto" + || callerCtx.role === "orchestrator" + || callerCtx.role === "agent"; } async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionState): Promise { @@ -2852,7 +2854,7 @@ async function listToolSpecsForSession(runtime: AdeRuntime, session: SessionStat const externalComputerUseAvailable = runtime.computerUseArtifactBrokerService ?.getBackendStatus() ?.backends.some((backend) => backend.available) ?? false; - const localComputerUseAllowed = isLocalComputerUseAllowed(); + const localComputerUseAllowed = isLocalComputerUseAllowed(callerCtx); const shouldHideLocalComputerUse = !localComputerUseAllowed || externalComputerUseAvailable; const visibleBaseTools = shouldHideLocalComputerUse ? TOOL_SPECS.filter((tool) => !LOCAL_COMPUTER_USE_TOOL_NAMES.has(tool.name)) @@ -4135,6 +4137,12 @@ async function runTool(args: { toolName: string, capabilityKey: "screenshot" | "browser_verification" | "browser_trace" | "video_recording" | "console_logs" | "appLaunch" | "guiInteraction" | "environmentInfo", ) => { + if (!isLocalComputerUseAllowed(callerCtx)) { + throw new JsonRpcError( + JsonRpcErrorCode.methodNotFound, + `Unsupported tool: ${toolName}`, + ); + } const capabilities = getLocalComputerUseCapabilities(); const capability = capabilityKey === "appLaunch" || capabilityKey === "guiInteraction" || capabilityKey === "environmentInfo" @@ -4786,6 +4794,9 @@ async function runTool(args: { if (name === "get_environment_info") { const includeDisplays = asBoolean(toolArgs.includeDisplays, false); + if (!isLocalComputerUseAllowed(callerCtx)) { + ensureLocalComputerUse(name, "environmentInfo"); + } const capabilities = getLocalComputerUseCapabilities(); const frontmostApp = capabilities.environmentInfo.available ? tryLocalCommand("osascript", [ diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 7e41d6d8d..f7e9b05b8 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -48,11 +48,12 @@ import { toProjectInfo, upsertProjectRow, } from "./services/projects/projectService"; +import { toRecentProjectSummary } from "./services/projects/recentProjectSummary"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; import { resolveAdeLayout } from "../shared/adeLayout"; -import type { PortLease, ProjectInfo } from "../shared/types"; +import type { PortLease, ProjectInfo, RecentProjectSummary, SyncMobileProjectSummary, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload } from "../shared/types"; import type { AppContext } from "./services/ipc/registerIpc"; import fs from "node:fs"; import net from "node:net"; @@ -747,7 +748,11 @@ app.whenReady().then(async () => { const closeContextPromises = new Map>(); const rpcSocketCleanupByRoot = new Map void>(); const projectLastActivatedAt = new Map(); + const mobileSyncHandoffLeases = new Map(); + const mobileSyncHandoffLeaseTimers = new Map>(); + const mobileSyncPreparationPromises = new Map>(); const MAX_WARM_IDLE_PROJECT_CONTEXTS = 1; + const MOBILE_SYNC_HANDOFF_LEASE_MS = 60_000; let activeProjectRoot: string | null = null; let dormantContext!: AppContext; let projectContextRebalancePromise: Promise = Promise.resolve(); @@ -880,6 +885,14 @@ app.whenReady().then(async () => { } try { + const leaseExpiresAt = mobileSyncHandoffLeases.get(projectRoot) ?? 0; + if (leaseExpiresAt > Date.now()) { + return true; + } + if (leaseExpiresAt > 0) { + mobileSyncHandoffLeases.delete(projectRoot); + } + if ((ctx.syncHostService?.getPeerStates().length ?? 0) > 0) { return true; } @@ -2454,6 +2467,10 @@ app.whenReady().then(async () => { processService, hostStartupEnabled: process.env.ADE_DISABLE_SYNC_HOST !== "1", notificationEventBus, + projectCatalogProvider: { + listProjects: listMobileSyncProjects, + prepareProjectConnection: prepareMobileSyncProjectConnection, + }, onStatusChanged: (snapshot) => emitProjectEvent(projectRoot, IPC.syncEvent, { type: "sync-status", @@ -3450,6 +3467,12 @@ app.whenReady().then(async () => { await disposeContextResources(ctx); projectContexts.delete(normalizedRoot); projectLastActivatedAt.delete(normalizedRoot); + const leaseTimer = mobileSyncHandoffLeaseTimers.get(normalizedRoot); + if (leaseTimer) { + clearTimeout(leaseTimer); + mobileSyncHandoffLeaseTimers.delete(normalizedRoot); + } + mobileSyncHandoffLeases.delete(normalizedRoot); if (activeProjectRoot === normalizedRoot) { activeProjectRoot = null; } @@ -3468,6 +3491,224 @@ app.whenReady().then(async () => { setActiveProject(null); }; + async function mobileProjectSummaryForContext( + ctx: AppContext, + recent?: RecentProjectSummary | null, + ): Promise { + let laneCount = recent?.laneCount ?? 0; + if (!recent?.laneCount) { + try { + laneCount = (await ctx.laneService.list({ includeArchived: false })).length; + } catch { + laneCount = 0; + } + } + return { + id: `root:${normalizeProjectRoot(ctx.project.rootPath)}`, + displayName: ctx.project.displayName, + rootPath: ctx.project.rootPath, + defaultBaseRef: ctx.project.baseRef, + lastOpenedAt: recent?.lastOpenedAt ?? null, + laneCount, + isAvailable: fs.existsSync(ctx.project.rootPath), + isCached: false, + }; + } + + function mobileProjectSummaryForRecent(recent: RecentProjectSummary): SyncMobileProjectSummary { + const normalizedRoot = normalizeProjectRoot(recent.rootPath); + return { + id: `root:${normalizedRoot}`, + displayName: recent.displayName, + rootPath: recent.rootPath, + defaultBaseRef: null, + lastOpenedAt: recent.lastOpenedAt, + laneCount: recent.laneCount ?? 0, + isAvailable: recent.exists, + isCached: false, + }; + } + + async function listMobileSyncProjects(): Promise<{ projects: SyncMobileProjectSummary[] }> { + const recentProjects = (readGlobalState(globalStatePath).recentProjects ?? []) + .map(toRecentProjectSummary); + const recentByRoot = new Map( + recentProjects.map((entry) => [normalizeProjectRoot(entry.rootPath), entry] as const), + ); + const byRoot = new Map(); + for (const recent of recentProjects) { + byRoot.set(normalizeProjectRoot(recent.rootPath), mobileProjectSummaryForRecent(recent)); + } + const contextSummaries = await Promise.all( + [...projectContexts.entries()].map(async ([root, ctx]) => + [root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const + ), + ); + for (const [root, summary] of contextSummaries) { + byRoot.set(root, summary); + } + const projects = [...byRoot.entries()] + .sort(([leftRoot], [rightRoot]) => { + if (leftRoot === activeProjectRoot) return -1; + if (rightRoot === activeProjectRoot) return 1; + return 0; + }) + .map(([, project]) => project); + return { projects }; + } + + async function ensureProjectContextForMobileSync(projectRoot: string): Promise { + const normalizedRoot = normalizeProjectRoot(projectRoot); + const existing = projectContexts.get(normalizedRoot); + if (existing) return existing; + if (!fs.existsSync(normalizedRoot)) { + throw new Error("Project is no longer available on this desktop."); + } + + let initPromise = projectInitPromises.get(normalizedRoot); + if (!initPromise) { + initPromise = (async () => { + const baseRef = await detectDefaultBaseRef(normalizedRoot); + const ctx = await initContextForProjectRoot({ + projectRoot: normalizedRoot, + baseRef, + ensureExclude: true, + recordLastProject: false, + recordRecent: true, + userSelectedProject: false, + }); + projectContexts.set(normalizedRoot, ctx); + return ctx; + })().finally(() => { + projectInitPromises.delete(normalizedRoot); + }) as Promise; + projectInitPromises.set(normalizedRoot, initPromise); + } + return initPromise; + } + + async function prepareMobileSyncProjectConnection( + args: SyncProjectSwitchRequestPayload, + ): Promise { + const catalog = await listMobileSyncProjects(); + const requestedRoot = typeof args.rootPath === "string" && args.rootPath.trim() + ? normalizeProjectRoot(args.rootPath) + : null; + const requestedProjectId = typeof args.projectId === "string" && args.projectId.trim() + ? args.projectId.trim() + : null; + let catalogEntry = catalog.projects.find((entry) => { + const entryRoot = entry.rootPath ? normalizeProjectRoot(entry.rootPath) : null; + if (requestedRoot != null && requestedProjectId != null) { + if (entryRoot !== requestedRoot) return false; + return entry.id === requestedProjectId || !requestedProjectId.startsWith("root:"); + } + return (requestedRoot != null && entryRoot === requestedRoot) + || (requestedProjectId != null && entry.id === requestedProjectId); + }); + if (!catalogEntry && requestedProjectId) { + for (const [root, ctx] of projectContexts) { + if (ctx.projectId === requestedProjectId) { + catalogEntry = catalog.projects.find((entry) => + entry.rootPath != null && normalizeProjectRoot(entry.rootPath) === root + ) ?? await mobileProjectSummaryForContext(ctx, null); + break; + } + } + } + if (!catalogEntry || !catalogEntry.isAvailable) { + return { + ok: false, + message: "That project is not available from this desktop.", + }; + } + const targetRoot = catalogEntry.rootPath ? normalizeProjectRoot(catalogEntry.rootPath) : null; + if (!targetRoot) { + return { + ok: false, + message: "Choose a desktop project first.", + }; + } + + const existingPreparation = mobileSyncPreparationPromises.get(targetRoot); + if (existingPreparation) return existingPreparation; + + const preparationPromise = (async (): Promise => { + const hadExistingContext = projectContexts.has(targetRoot); + let createdLeaseExpiresAt: number | null = null; + let createdLeaseTimer: ReturnType | null = null; + try { + const ctx = await ensureProjectContextForMobileSync(targetRoot); + if (!ctx.syncService) { + throw new Error("Sync is not available for that project."); + } + await ctx.syncService.initialize(); + const status = await ctx.syncService.getStatus(); + if (!status.bootstrapToken || !status.pairingConnectInfo) { + throw new Error("That project is not ready for phone sync yet."); + } + const recent = (readGlobalState(globalStatePath).recentProjects ?? []) + .map(toRecentProjectSummary) + .find((entry) => normalizeProjectRoot(entry.rootPath) === targetRoot) ?? null; + const project = await mobileProjectSummaryForContext(ctx, recent); + const leaseExpiresAt = Date.now() + MOBILE_SYNC_HANDOFF_LEASE_MS; + createdLeaseExpiresAt = leaseExpiresAt; + mobileSyncHandoffLeases.set(targetRoot, leaseExpiresAt); + const existingLeaseTimer = mobileSyncHandoffLeaseTimers.get(targetRoot); + if (existingLeaseTimer) clearTimeout(existingLeaseTimer); + const leaseTimer = setTimeout(() => { + mobileSyncHandoffLeaseTimers.delete(targetRoot); + if (mobileSyncHandoffLeases.get(targetRoot) === leaseExpiresAt) { + mobileSyncHandoffLeases.delete(targetRoot); + } + scheduleProjectContextRebalance(); + }, MOBILE_SYNC_HANDOFF_LEASE_MS + 100); + leaseTimer.unref?.(); + createdLeaseTimer = leaseTimer; + mobileSyncHandoffLeaseTimers.set(targetRoot, leaseTimer); + projectLastActivatedAt.set(targetRoot, Date.now()); + scheduleProjectContextRebalance(); + return { + ok: true, + project, + connection: { + authKind: "bootstrap", + token: status.bootstrapToken, + hostIdentity: status.pairingConnectInfo.hostIdentity, + port: status.pairingConnectInfo.port, + addressCandidates: status.pairingConnectInfo.addressCandidates, + }, + }; + } catch (error) { + const currentLeaseTimer = mobileSyncHandoffLeaseTimers.get(targetRoot); + if (createdLeaseTimer != null && currentLeaseTimer === createdLeaseTimer) { + clearTimeout(createdLeaseTimer); + mobileSyncHandoffLeaseTimers.delete(targetRoot); + } + if (createdLeaseExpiresAt != null && mobileSyncHandoffLeases.get(targetRoot) === createdLeaseExpiresAt) { + mobileSyncHandoffLeases.delete(targetRoot); + } + if (!hadExistingContext && projectContexts.has(targetRoot) && !mobileSyncHandoffLeases.has(targetRoot)) { + await closeProjectContext(targetRoot); + } else { + scheduleProjectContextRebalance(); + } + return { + ok: false, + message: error instanceof Error ? error.message : "Unable to prepare phone sync for that project.", + }; + } + })(); + mobileSyncPreparationPromises.set(targetRoot, preparationPromise); + try { + return await preparationPromise; + } finally { + if (mobileSyncPreparationPromises.get(targetRoot) === preparationPromise) { + mobileSyncPreparationPromises.delete(targetRoot); + } + } + } + const persistRecentProject = ( project: ProjectInfo, options: { recordLastProject?: boolean; recordRecent?: boolean } = {}, diff --git a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts index 1fd53b1ae..3af4cd33a 100644 --- a/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts +++ b/apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts @@ -16,6 +16,7 @@ import type { ComputerUseArtifactRouteArgs, ComputerUseArtifactView, ComputerUseBackendStatus, + ComputerUseExternalBackendStatus, ComputerUseArtifactWorkflowState, ComputerUseEventPayload, } from "../../../shared/types"; @@ -38,6 +39,7 @@ import { toOptionalString, writeTextAtomic, } from "../shared/utils"; +import { commandExists } from "../ai/utils"; import { createComputerUseArtifactPath, getLocalComputerUseCapabilities, toProjectArtifactUri } from "./localComputerUse"; type StoredArtifactRow = { @@ -489,10 +491,43 @@ export function createComputerUseArtifactBrokerService(args: { if (local.proofRequirements.browser_verification.available) localKinds.push("browser_verification"); if (local.proofRequirements.console_logs.available) localKinds.push("console_logs"); + const backends: ComputerUseExternalBackendStatus[] = []; + const ghostInstalled = commandExists("ghost"); + backends.push({ + name: "Ghost OS", + available: false, + state: ghostInstalled ? "installed" : "missing", + detail: ghostInstalled + ? "Ghost OS CLI is installed, but ADE Ghost integration readiness is not enabled yet." + : "Ghost OS CLI is not installed on this machine.", + supportedKinds: [ + "screenshot", + "browser_verification", + ], + }); + + const agentBrowserInstalled = commandExists("agent-browser"); + backends.push({ + name: "agent-browser", + available: agentBrowserInstalled, + state: agentBrowserInstalled ? "installed" : "missing", + detail: agentBrowserInstalled + ? "agent-browser CLI is installed and can produce artifacts for ADE ingestion." + : "agent-browser CLI is not installed on this machine.", + supportedKinds: [ + "screenshot", + "video_recording", + "browser_trace", + "browser_verification", + "console_logs", + ], + }); + return { - backends: [], + backends, localFallback: { available: local.overallState === "present", + state: local.overallState, detail: local.overallState === "present" ? "ADE local computer-use tools are available as a fallback." : `ADE local computer-use tools are fallback-only and currently ${local.overallState}.`, diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts index 72b25f76c..03cd28004 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.test.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.test.ts @@ -13,7 +13,10 @@ vi.mock("node:child_process", () => ({ })), })); -import { buildComputerUseOwnerSnapshot } from "./controlPlane"; +import { + buildComputerUseOwnerSnapshot, + collectRequiredComputerUseKindsFromPhases, +} from "./controlPlane"; function createBackendStatus(): ComputerUseBackendStatus { return { @@ -47,4 +50,33 @@ describe("computer use control plane", () => { expect(snapshot.summary).toContain("Ghost OS is available and ready to capture proof"); expect(snapshot.activity.some((item) => item.kind === "backend_available")).toBe(true); }); + + it("collects only supported proof kinds from required phases", () => { + const phases = [ + { + validationGate: { + required: true, + evidenceRequirements: ["screenshot", "browser_verification", "unsupported-evidence"], + }, + }, + { + validationGate: { + required: false, + evidenceRequirements: ["video_recording"], + }, + }, + { + validationGate: { + required: true, + evidenceRequirements: ["screenshot", "console_logs"], + }, + }, + ] as any; + + expect(collectRequiredComputerUseKindsFromPhases(phases)).toEqual([ + "screenshot", + "browser_verification", + "console_logs", + ]); + }); }); diff --git a/apps/desktop/src/main/services/computerUse/controlPlane.ts b/apps/desktop/src/main/services/computerUse/controlPlane.ts index 718211ed4..b8e6e70f5 100644 --- a/apps/desktop/src/main/services/computerUse/controlPlane.ts +++ b/apps/desktop/src/main/services/computerUse/controlPlane.ts @@ -5,6 +5,7 @@ import type { ComputerUseArtifactView, ComputerUseBackendStatus, ComputerUseOwnerSnapshot, + PhaseCard, } from "../../../shared/types"; import type { ComputerUseArtifactBrokerService } from "./computerUseArtifactBrokerService"; @@ -20,6 +21,22 @@ export function getComputerUseArtifactKinds(): ComputerUseArtifactKind[] { return [...COMPUTER_USE_KINDS]; } +export function collectRequiredComputerUseKindsFromPhases( + phases: PhaseCard[], +): ComputerUseArtifactKind[] { + const required = new Set(); + const supported = new Set(COMPUTER_USE_KINDS); + for (const phase of phases) { + if (!phase.validationGate.required) continue; + for (const requirement of phase.validationGate.evidenceRequirements ?? []) { + if (supported.has(requirement as ComputerUseArtifactKind)) { + required.add(requirement as ComputerUseArtifactKind); + } + } + } + return Array.from(required); +} + function buildActivity( artifacts: ComputerUseArtifactView[], backendStatus: ComputerUseBackendStatus, diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts index bb24f5bae..2eb6aa19f 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.test.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.test.ts @@ -193,6 +193,277 @@ describe("missionPreflightService", () => { expect(result.checklist.find((item) => item.id === "budget")?.severity).toBe("fail"); }); + it("blocks launch when required computer-use proof has no backend coverage", async () => { + const profiles = createProfiles(); + const proofPhases = profiles[0]!.phases.map((phase, index) => + index === 0 + ? { + ...phase, + validationGate: { + ...phase.validationGate, + required: true, + evidenceRequirements: ["screenshot" as const], + capabilityFallback: "block" as const, + }, + } + : phase, + ); + const service = createMissionPreflightService({ + logger: createLogger(), + projectRoot: "/tmp/ade-preflight", + missionService: { + listPhaseProfiles: () => profiles + } as any, + laneService: { + list: async () => [ + { id: "lane-1", archivedAt: null }, + { id: "lane-2", archivedAt: null }, + { id: "lane-3", archivedAt: null }, + ] + } as any, + aiIntegrationService: { + getAvailabilityAsync: async () => ({ + availableModels: [ + { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, + { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, + { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, + { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, + ] + }), + executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) + } as any, + projectConfigService: { + get: () => ({ + effective: { + ai: { + permissions: { + cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, + inProcess: { mode: "full-auto" }, + } + } + } + }) + } as any, + missionBudgetService: { + estimateLaunchBudget: async () => ({ + estimate: createBudgetEstimate("subscription"), + hardLimitExceeded: false, + windowUsageCostUsd: 0.6, + remainingWindowCostUsd: 10.4, + budgetLimitCostUsd: 11 + }) + } as any, + computerUseArtifactBrokerService: { + getBackendStatus: () => ({ + backends: [], + localFallback: { + available: false, + detail: "No local fallback in test.", + supportedKinds: [], + }, + }), + } as any, + }); + + const result = await service.runPreflight({ + launch: { + prompt: "Capture required proof.", + phaseProfileId: profiles[0]!.id, + phaseOverride: proofPhases, + modelConfig: { + orchestratorModel: { + provider: "claude", + modelId: "claude-sonnet-4-6" + } + }, + } + }); + + expect(result.canLaunch).toBe(false); + expect(result.computerUse?.blocked).toBe(true); + expect(result.computerUse?.missingKinds).toEqual(["screenshot"]); + expect(result.checklist.find((item) => item.id === "computer_use")?.severity).toBe("fail"); + }); + + it("allows runtime-discovered models when external proof backend covers browser evidence", async () => { + const profiles = createProfiles(); + const proofPhases = profiles[0]!.phases.map((phase, index) => ({ + ...phase, + model: { + ...phase.model, + provider: "opencode", + modelId: index === 0 ? "runtime/non-registry-model" : phase.model.modelId, + }, + validationGate: index === 0 + ? { + ...phase.validationGate, + required: true, + evidenceRequirements: ["screenshot" as const], + capabilityFallback: "block" as const, + } + : phase.validationGate, + })); + const service = createMissionPreflightService({ + logger: createLogger(), + projectRoot: "/tmp/ade-preflight", + missionService: { + listPhaseProfiles: () => profiles + } as any, + laneService: { + list: async () => [ + { id: "lane-1", archivedAt: null }, + { id: "lane-2", archivedAt: null }, + { id: "lane-3", archivedAt: null }, + ] + } as any, + aiIntegrationService: { + getAvailabilityAsync: async () => ({ + availableModels: [ + { id: "runtime/non-registry-model", shortId: "runtime-model", family: "opencode", displayName: "Runtime model" }, + { id: "anthropic/claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "anthropic", displayName: "Claude Sonnet 4.6" }, + { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, + { id: "openai/gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "openai", displayName: "GPT-5.3 Codex" }, + { id: "gpt-5.3-codex", shortId: "gpt-5.3-codex", family: "codex", displayName: "GPT-5.3 Codex" }, + ] + }), + executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) + } as any, + projectConfigService: { + get: () => ({ + effective: { + ai: { + permissions: { + cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, + inProcess: { mode: "full-auto" }, + } + } + } + }) + } as any, + missionBudgetService: { + estimateLaunchBudget: async () => ({ + estimate: createBudgetEstimate("subscription"), + hardLimitExceeded: false, + windowUsageCostUsd: 0.6, + remainingWindowCostUsd: 10.4, + budgetLimitCostUsd: 11 + }) + } as any, + computerUseArtifactBrokerService: { + getBackendStatus: () => ({ + backends: [ + { + name: "agent-browser", + available: true, + state: "installed", + detail: "agent-browser is available.", + supportedKinds: ["screenshot"], + }, + ], + localFallback: { + available: false, + detail: "No local fallback in test.", + supportedKinds: [], + }, + }), + } as any, + }); + + const result = await service.runPreflight({ + launch: { + prompt: "Capture required proof.", + phaseProfileId: profiles[0]!.id, + phaseOverride: proofPhases, + modelConfig: { + orchestratorModel: { + provider: "opencode", + modelId: "runtime/non-registry-model" + } + }, + } + }); + + expect(result.canLaunch).toBe(true); + expect(result.checklist.find((item) => item.id === "computer_use")?.severity).toBe("pass"); + expect(result.checklist.find((item) => item.id === "capabilities")?.severity).toBe("pass"); + }); + + it("reports local computer-use platform blockers when backend status is wired", async () => { + const profiles = createProfiles(); + const proofPhases = profiles[0]!.phases.map((phase, index) => ({ + ...phase, + validationGate: index === 0 + ? { + ...phase.validationGate, + required: true, + evidenceRequirements: ["video_recording" as const], + capabilityFallback: "block" as const, + } + : phase.validationGate, + })); + const service = createMissionPreflightService({ + logger: createLogger(), + projectRoot: "/tmp/ade-preflight", + missionService: { + listPhaseProfiles: () => profiles + } as any, + laneService: { + list: async () => [{ id: "lane-1", archivedAt: null }] + } as any, + aiIntegrationService: { + getAvailabilityAsync: async () => ({ + availableModels: [ + { id: "claude-sonnet-4-6", shortId: "claude-sonnet-4-6", family: "claude", displayName: "Claude Sonnet 4.6" }, + ] + }), + executeTask: async () => ({ structuredOutput: { clear: true, feedback: [] } }) + } as any, + projectConfigService: { + get: () => ({ + effective: { + ai: { + permissions: { + cli: { mode: "full-auto", sandboxPermissions: "workspace-write" }, + inProcess: { mode: "full-auto" }, + } + } + } + }) + } as any, + missionBudgetService: { + estimateLaunchBudget: async () => ({ + estimate: createBudgetEstimate("subscription"), + hardLimitExceeded: false, + windowUsageCostUsd: 0, + remainingWindowCostUsd: 1, + budgetLimitCostUsd: 1 + }) + } as any, + computerUseArtifactBrokerService: { + getBackendStatus: () => ({ + backends: [], + localFallback: { + available: false, + state: "blocked_by_capability", + detail: "ADE local computer-use tools are fallback-only and currently blocked_by_capability.", + supportedKinds: [], + }, + }), + } as any, + }); + + const result = await service.runPreflight({ + launch: { + prompt: "Capture required proof.", + phaseProfileId: profiles[0]!.id, + phaseOverride: proofPhases, + } + }); + + expect(result.canLaunch).toBe(false); + expect(result.checklist.find((item) => item.id === "capabilities")?.details.join("\n")).toContain("blocked by platform support"); + }); + it("shows warning (not fail) for non-full-auto permissions and still allows launch", async () => { const profiles = createProfiles(); const service = createMissionPreflightService({ diff --git a/apps/desktop/src/main/services/missions/missionPreflightService.ts b/apps/desktop/src/main/services/missions/missionPreflightService.ts index 3a36b32fc..28a6e8501 100644 --- a/apps/desktop/src/main/services/missions/missionPreflightService.ts +++ b/apps/desktop/src/main/services/missions/missionPreflightService.ts @@ -19,7 +19,11 @@ import type { MissionPermissionConfig } from "../../../shared/types/missions"; import { isRecord, nowIso, toOptionalString } from "../shared/utils"; import type { HumanWorkDigestService } from "../memory/humanWorkDigestService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import { getComputerUseArtifactKinds } from "../computerUse/controlPlane"; +import { + collectRequiredComputerUseKindsFromPhases, + getComputerUseArtifactKinds, +} from "../computerUse/controlPlane"; +import { getCapabilityForRequirement } from "../computerUse/localComputerUse"; function normalizePhaseCards(phases: PhaseCard[]): PhaseCard[] { @@ -112,15 +116,49 @@ export function createMissionPreflightService(args: { const checklist: MissionPreflightChecklistItem[] = []; const backendStatus = computerUseArtifactBrokerService?.getBackendStatus() ?? null; - // Computer-use policy/proof gating has been removed; treat preflight as permissive. - const requiredComputerUseKinds: ComputerUseArtifactKind[] = []; + const supportedComputerUseKinds = new Set(getComputerUseArtifactKinds()); + const requiredComputerUseKinds = collectRequiredComputerUseKindsFromPhases(selected.phases); + const hasExternalComputerUseCoverage = (kind: ComputerUseArtifactKind): boolean => + backendStatus?.backends.some((backend) => + backend.available && backend.supportedKinds.includes(kind) + ) ?? false; + const hasLocalComputerUseCoverage = (kind: ComputerUseArtifactKind): boolean => + backendStatus + ? (backendStatus.localFallback?.supportedKinds.includes(kind) ?? false) + : (getCapabilityForRequirement(kind)?.available ?? false); const availableExternalBackends = (backendStatus?.backends ?? []) - .filter((backend) => backend.available) + .filter((backend) => + backend.available + && ( + requiredComputerUseKinds.length === 0 + || requiredComputerUseKinds.some((kind) => backend.supportedKinds.includes(kind)) + ) + ) .map((backend) => backend.name); - const missingComputerUseKinds: ComputerUseArtifactKind[] = []; - const computerUseBlocked = false; - // Retain module side-effect reference so unused-import lint stays green. - void getComputerUseArtifactKinds; + const fallbackCoverageKinds = requiredComputerUseKinds.filter((kind) => + hasLocalComputerUseCoverage(kind) + ); + const missingComputerUseKinds = requiredComputerUseKinds.filter((kind) => { + return !hasExternalComputerUseCoverage(kind) && !hasLocalComputerUseCoverage(kind); + }); + const blockingMissingComputerUseKinds = Array.from(new Set( + selected.phases.flatMap((phase) => { + if (!phase.validationGate.required) return []; + if ((phase.validationGate.capabilityFallback ?? "block") !== "block") return []; + return (phase.validationGate.evidenceRequirements ?? []).filter((requirement): requirement is ComputerUseArtifactKind => + supportedComputerUseKinds.has(requirement as ComputerUseArtifactKind) + && missingComputerUseKinds.includes(requirement as ComputerUseArtifactKind) + ); + }), + )); + const warningMissingComputerUseKinds = missingComputerUseKinds.filter((kind) => + !blockingMissingComputerUseKinds.includes(kind) + ); + const fallbackOnlyKinds = requiredComputerUseKinds.filter((kind) => { + return !hasExternalComputerUseCoverage(kind) && hasLocalComputerUseCoverage(kind); + }); + const computerUseBlocked = requiredComputerUseKinds.length > 0 + && blockingMissingComputerUseKinds.length > 0; const structuralIssues: string[] = []; for (const [index, phase] of selected.phases.entries()) { @@ -278,25 +316,115 @@ export function createMissionPreflightService(args: { const activeLanes = await laneService.list({ includeArchived: false }).catch(() => []); checklist.push( - toChecklistItem({ - id: "computer_use", - severity: "pass", - title: "Computer use readiness", - summary: "Computer-use policy gating has been retired; proof capture is best-effort only.", - details: [ - `External backends detected: ${availableExternalBackends.length > 0 ? availableExternalBackends.join(", ") : "none"}`, - ], - }), + requiredComputerUseKinds.length === 0 + ? toChecklistItem({ + id: "computer_use", + severity: "pass", + title: "Computer use readiness", + summary: "The selected phases do not require computer-use proof artifacts.", + details: [ + `External backends detected: ${availableExternalBackends.length > 0 ? availableExternalBackends.join(", ") : "none"}`, + ], + }) + : toChecklistItem({ + id: "computer_use", + severity: computerUseBlocked ? "fail" : fallbackOnlyKinds.length > 0 || warningMissingComputerUseKinds.length > 0 ? "warning" : "pass", + title: "Computer use readiness", + summary: computerUseBlocked + ? "Required computer-use proof is not fully covered in the current environment." + : warningMissingComputerUseKinds.length > 0 + ? "Some required computer-use proof is warning-only and not covered in the current environment." + : fallbackOnlyKinds.length > 0 + ? "Required proof is covered, but some evidence depends on ADE-local fallback support." + : "Required proof is covered by an available external computer-use backend.", + details: [ + `Required proof kinds: ${requiredComputerUseKinds.join(", ")}`, + `External backends detected: ${availableExternalBackends.length > 0 ? availableExternalBackends.join(", ") : "none"}`, + ...(fallbackCoverageKinds.length > 0 ? [`Local fallback can cover: ${fallbackCoverageKinds.join(", ")}`] : []), + ...(blockingMissingComputerUseKinds.length > 0 ? [`Blocking missing coverage: ${blockingMissingComputerUseKinds.join(", ")}`] : []), + ...(warningMissingComputerUseKinds.length > 0 ? [`Warning-only missing coverage: ${warningMissingComputerUseKinds.join(", ")}`] : []), + ], + ...(computerUseBlocked + ? { + fixHint: "Install or enable an approved external backend such as Ghost OS or agent-browser, run on a platform with ADE-local proof support, or relax the mission proof contract before launch.", + } + : {}), + }), ); + const capabilityIssues: string[] = []; + const capabilityWarnings: string[] = []; + for (const phase of selected.phases) { + const evidenceRequirements = phase.validationGate.evidenceRequirements ?? []; + if (!phase.validationGate.required || evidenceRequirements.length === 0) continue; + const descriptor = getModelById(phase.model.modelId) ?? resolveModelAlias(phase.model.modelId); + const requiresBrowserEvidence = evidenceRequirements.some((requirement) => + requirement === "screenshot" + || requirement === "browser_verification" + || requirement === "browser_trace" + || requirement === "video_recording" + ); + const browserEvidenceRequirements = evidenceRequirements.filter((requirement) => + requirement === "screenshot" + || requirement === "browser_verification" + || requirement === "browser_trace" + || requirement === "video_recording" + ); + const browserEvidenceCoveredByBackend = browserEvidenceRequirements.length > 0 && browserEvidenceRequirements.every((requirement) => { + const requirementKind = requirement as ComputerUseArtifactKind; + return supportedComputerUseKinds.has(requirementKind) + && (hasExternalComputerUseCoverage(requirementKind) || hasLocalComputerUseCoverage(requirementKind)); + }); + if (requiresBrowserEvidence && descriptor) { + const likelyBrowserCapable = browserEvidenceCoveredByBackend + || (descriptor.capabilities.tools && descriptor.capabilities.vision); + if (!likelyBrowserCapable) { + const message = `${phase.name}: requires browser/screenshot evidence, but ${descriptor.displayName} does not advertise the tool/vision support needed without an available proof backend.`; + if ((phase.validationGate.capabilityFallback ?? "block") === "block") capabilityIssues.push(message); + else capabilityWarnings.push(message); + } + } + if (requiresBrowserEvidence && !descriptor && !browserEvidenceCoveredByBackend) { + const message = `${phase.name}: requires browser/screenshot evidence, but model ${phase.model.modelId} could not be resolved for capability checks.`; + if ((phase.validationGate.capabilityFallback ?? "block") === "block") capabilityIssues.push(message); + else capabilityWarnings.push(message); + } + for (const requirement of evidenceRequirements) { + const requirementKind = requirement as ComputerUseArtifactKind; + if (!supportedComputerUseKinds.has(requirementKind)) continue; + if (hasExternalComputerUseCoverage(requirementKind)) continue; + if (hasLocalComputerUseCoverage(requirementKind)) continue; + const localCapability = getCapabilityForRequirement(requirementKind); + const localDetail = backendStatus?.localFallback?.detail ?? localCapability?.detail; + const localFallbackBlocked = backendStatus?.localFallback?.state === "blocked_by_capability"; + const localStateReason = localCapability?.state === "blocked_by_capability" || localFallbackBlocked + ? "blocked by platform support" + : "missing required tooling"; + const message = `${phase.name}: ${requirement.replace(/_/g, " ")} is required, but no external backend is available and the local runtime is ${localStateReason}${localDetail ? ` (${localDetail})` : ""}.`; + if ((phase.validationGate.capabilityFallback ?? "block") === "block") capabilityIssues.push(message); + else capabilityWarnings.push(message); + } + } + checklist.push( - toChecklistItem({ - id: "capabilities", - severity: "pass", - title: "Capability contracts", - summary: "Capability pre-flight is a no-op; runtime will determine coverage on demand.", - details: ["No blocking capability gaps detected in the selected phase configuration."], - }), + capabilityIssues.length === 0 && capabilityWarnings.length === 0 + ? toChecklistItem({ + id: "capabilities", + severity: "pass", + title: "Capability contracts", + summary: "Configured evidence contracts have matching runtime capabilities.", + details: ["No blocking capability gaps detected in the selected phase configuration."], + }) + : toChecklistItem({ + id: "capabilities", + severity: capabilityIssues.length > 0 ? "fail" : "warning", + title: "Capability contracts", + summary: capabilityIssues.length > 0 + ? "One or more required capabilities are missing for the selected mission contract." + : "Some optional capability-backed evidence may require fallback or operator review.", + details: [...capabilityIssues, ...capabilityWarnings], + fixHint: "Switch to models that support the required evidence flow, install an approved computer-use backend, or relax the phase contract before launch.", + }), ); const requestedDescriptors = new Map>(); @@ -632,10 +760,18 @@ export function createMissionPreflightService(args: { approvalSummary, computerUse: { requiredKinds: requiredComputerUseKinds, - missingKinds: missingComputerUseKinds, + missingKinds: blockingMissingComputerUseKinds, availableExternalBackends, blocked: computerUseBlocked, - summary: "Computer-use policy gating has been retired; missions always proceed.", + summary: requiredComputerUseKinds.length === 0 + ? "This mission does not require computer-use proof." + : computerUseBlocked + ? `Required proof coverage is missing for ${blockingMissingComputerUseKinds.join(", ") || requiredComputerUseKinds.join(", ")}.` + : warningMissingComputerUseKinds.length > 0 + ? `Warning-only proof coverage is missing for ${warningMissingComputerUseKinds.join(", ")}.` + : fallbackOnlyKinds.length > 0 + ? `Required proof is covered, but ${fallbackOnlyKinds.join(", ")} still rely on ADE-local fallback support.` + : `Required proof can be satisfied through approved external backends: ${availableExternalBackends.join(", ")}.`, }, }; }; diff --git a/apps/desktop/src/main/services/missions/missionService.test.ts b/apps/desktop/src/main/services/missions/missionService.test.ts index cc912820a..640dfd178 100644 --- a/apps/desktop/src/main/services/missions/missionService.test.ts +++ b/apps/desktop/src/main/services/missions/missionService.test.ts @@ -577,6 +577,32 @@ describe("missionService lifecycle", () => { }); expect(imported.isDefault).toBe(true); + const proofProfile = service.savePhaseProfile({ + profile: { + ...defaultProfile!, + id: "proof-profile", + name: "Proof Required", + isDefault: false, + phases: defaultProfile!.phases.map((phase) => + phase.phaseKey === "testing" || phase.phaseKey === "validation" + ? { + ...phase, + validationGate: { + ...phase.validationGate, + evidenceRequirements: ["screenshot"], + capabilityFallback: "warn", + }, + } + : phase + ), + }, + }); + const proofPhase = proofProfile.phases.find((phase) => + phase.phaseKey === "testing" || phase.phaseKey === "validation" + ); + expect(proofPhase?.validationGate.evidenceRequirements).toEqual(["screenshot"]); + expect(proofPhase?.validationGate.capabilityFallback).toBe("warn"); + const profiles = service.listPhaseProfiles(); const defaultCount = profiles.filter((profile) => profile.isDefault).length; expect(defaultCount).toBe(1); diff --git a/apps/desktop/src/main/services/missions/missionService.ts b/apps/desktop/src/main/services/missions/missionService.ts index caeff8bb9..976b7e056 100644 --- a/apps/desktop/src/main/services/missions/missionService.ts +++ b/apps/desktop/src/main/services/missions/missionService.ts @@ -47,6 +47,8 @@ import type { ThinkingLevel, PhaseCard, PhaseProfile, + ValidationCapabilityFallbackPolicy, + ValidationEvidenceRequirement, SavePhaseItemArgs, SavePhaseProfileArgs, ExportPhaseProfileArgs, @@ -324,6 +326,33 @@ function coerceBoolean(value: unknown, fallback = false): boolean { return fallback; } +const VALID_EVIDENCE_REQUIREMENTS = new Set([ + "planning_document", + "research_summary", + "changed_files_summary", + "test_report", + "review_summary", + "risk_notes", + "final_outcome_summary", + "screenshot", + "browser_verification", + "video_recording", + "browser_trace", + "console_logs", +]); + +function toEvidenceRequirements(value: unknown): ValidationEvidenceRequirement[] | undefined { + if (!Array.isArray(value)) return undefined; + const requirements = value.filter((entry): entry is ValidationEvidenceRequirement => + typeof entry === "string" && VALID_EVIDENCE_REQUIREMENTS.has(entry as ValidationEvidenceRequirement) + ); + return requirements.length > 0 ? Array.from(new Set(requirements)) : undefined; +} + +function toCapabilityFallback(value: unknown): ValidationCapabilityFallbackPolicy | undefined { + return value === "block" || value === "warn" ? value : undefined; +} + function normalizePlannerClarifyingQuestion(value: unknown): PlannerClarifyingQuestion | null { if (!isRecord(value)) return null; const question = String(value.question ?? "").trim(); @@ -468,6 +497,8 @@ function toPhaseCard(value: unknown, fallbackPosition = 0): PhaseCard | null { tier, required: coerceBoolean(validationGate.required, tier !== "none"), criteria: typeof validationGate.criteria === "string" ? validationGate.criteria : undefined, + evidenceRequirements: toEvidenceRequirements(validationGate.evidenceRequirements), + capabilityFallback: toCapabilityFallback(validationGate.capabilityFallback), }, isBuiltIn: coerceBoolean(value.isBuiltIn), isCustom: coerceBoolean(value.isCustom, true), diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index f694f5085..56e9b9769 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -3172,12 +3172,19 @@ describe("aiOrchestratorService", () => { ["2000-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z", attempt.id] ); - // The explicit sweep may do the recovery itself, or a background startup/interval - // sweep may have already reconciled the stale attempt before this call returns. - await fixture.aiOrchestratorService.runHealthSweep("test"); - - const refreshedGraph = fixture.orchestratorService.getRunGraph({ runId }); - const refreshedAttempt = refreshedGraph.attempts.find((entry) => entry.id === attempt.id); + // A startup/interval sweep can be in flight on slower CI runners. Retry the + // explicit sweep until this attempt is reconciled instead of racing it. + let refreshedAttempt = fixture.orchestratorService + .getRunGraph({ runId }) + .attempts.find((entry) => entry.id === attempt.id); + for (let tries = 0; tries < 20 && refreshedAttempt?.status === "running"; tries += 1) { + await fixture.aiOrchestratorService.runHealthSweep("test"); + refreshedAttempt = fixture.orchestratorService + .getRunGraph({ runId }) + .attempts.find((entry) => entry.id === attempt.id); + if (refreshedAttempt?.status !== "running") break; + await new Promise((resolve) => setTimeout(resolve, 100)); + } expect(refreshedAttempt?.status).toBe("failed"); expect(refreshedAttempt?.errorMessage ?? "").toContain("stagnating"); } finally { diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index f75835882..e9b7702a7 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -130,10 +130,11 @@ async function connectClient(args: { }, compressionThresholdBytes: 100_000, })); - await queue.next("hello_ok"); + const helloOk = await queue.next("hello_ok"); return { ws, queue, + helloOk, close: async () => { ws.close(); await new Promise((resolve) => ws.once("close", resolve)); @@ -330,6 +331,139 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { await expect(host.waitUntilListening()).rejects.toMatchObject({ code: "EADDRINUSE" }); }, 30_000); + it("advertises the mobile project catalog and handles project switch requests", async () => { + const brainDb = await openKvDb(makeDbPath("ade-sync-project-catalog-"), createLogger() as any); + const projectRoot = makeProjectRoot("ade-sync-project-catalog-project-"); + const workspaceRoot = path.join(projectRoot, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + + const project = { + id: "project-1", + displayName: "ADE", + rootPath: projectRoot, + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T12:00:00.000Z", + laneCount: 4, + isAvailable: true, + isCached: false, + }; + const connection = { + authKind: "bootstrap" as const, + token: "project-bootstrap-token", + hostIdentity: { + deviceId: "host-1", + siteId: "host-site-1", + name: "ADE Desktop", + platform: "macOS" as const, + deviceType: "desktop" as const, + }, + port: 8788, + addressCandidates: [{ host: "192.168.1.24", kind: "lan" as const }], + }; + const projectCatalogProvider = { + listProjects: vi.fn(async () => ({ projects: [project] })), + prepareProjectConnection: vi.fn(async () => ({ + ok: true, + project: { ...project, id: "project-row-1", isCached: true }, + connection, + })), + }; + + const host = createSyncHostService({ + db: brainDb, + logger: createLogger() as any, + projectRoot, + port: 0, + pinStore: createStubPinStore(), + fileService: createStubFileService(workspaceRoot) as any, + laneService: { + list: vi.fn().mockResolvedValue([]), + create: vi.fn(), + archive: vi.fn(), + } as any, + prService: { + listAll: vi.fn().mockResolvedValue([]), + getDetail: vi.fn(), + getStatus: vi.fn(), + getChecks: vi.fn(), + getReviews: vi.fn(), + getComments: vi.fn(), + getFiles: vi.fn(), + createFromLane: vi.fn(), + land: vi.fn(), + closePr: vi.fn(), + requestReviewers: vi.fn(), + } as any, + sessionService: { + list: () => [], + get: () => null, + readTranscriptTail: async () => "", + } as any, + ptyService: { + create: vi.fn(), + enrichSessions: (rows: any[]) => rows, + } as any, + computerUseArtifactBrokerService: { + listArtifacts: () => [], + } as any, + projectCatalogProvider, + }); + activeDisposers.push(async () => { + await host.dispose(); + brainDb.close(); + }); + + const port = await host.waitUntilListening(); + const client = await connectClient({ + port, + token: host.getBootstrapToken(), + deviceId: "ios-phone-1", + deviceName: "Arul iPhone", + siteId: "ios-site-1", + dbVersion: 0, + platform: "iOS", + deviceType: "phone", + }); + + const helloPayload = client.helloOk.payload as { + projects?: unknown[]; + features: { projectCatalog?: { enabled: boolean } }; + }; + expect(helloPayload.projects).toEqual([project]); + expect(helloPayload.features.projectCatalog?.enabled).toBe(true); + + client.ws.send(encodeSyncEnvelope({ + type: "project_catalog_request", + requestId: "catalog-1", + payload: {}, + })); + const catalog = await client.queue.next("project_catalog"); + expect(catalog.requestId).toBe("catalog-1"); + expect(catalog.payload).toEqual({ projects: [project] }); + + client.ws.send(encodeSyncEnvelope({ + type: "project_switch_request", + requestId: "switch-1", + payload: { + projectId: project.id, + rootPath: project.rootPath, + }, + })); + const switchResult = await client.queue.next("project_switch_result"); + expect(switchResult.requestId).toBe("switch-1"); + expect(switchResult.payload).toEqual({ + ok: true, + project: { ...project, id: "project-row-1", isCached: true }, + connection, + }); + expect(projectCatalogProvider.prepareProjectConnection).toHaveBeenCalledWith({ + projectId: project.id, + rootPath: project.rootPath, + }); + + await client.close(); + }); + it("authenticates peers, relays CRDT changes, and rebroadcasts to other peers", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-brain-"), createLogger() as any); const dbA = await openKvDb(makeDbPath("ade-sync-peer-a-"), createLogger() as any); diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index cd520cb05..bad0fc8cb 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -36,6 +36,9 @@ import type { SyncPairingRequestPayload, SyncPeerConnectionState, SyncPeerMetadata, + SyncProjectCatalogPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, SyncRemoteCommandDescriptor, SyncTailnetDiscoveryStatus, SyncTerminalSnapshotPayload, @@ -175,6 +178,10 @@ type SyncHostServiceArgs = { brainStatusIntervalMs?: number; compressionThresholdBytes?: number; deviceRegistryService?: DeviceRegistryService; + projectCatalogProvider?: { + listProjects: () => Promise; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + }; onStateChanged?: () => void; notificationEventBus?: NotificationEventBus | null; }; @@ -1032,6 +1039,45 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ws.send(encodeSyncEnvelope({ type, payload, requestId, compressionThresholdBytes })); } + async function buildProjectCatalogPayload(): Promise { + if (!args.projectCatalogProvider) { + return { projects: [] }; + } + try { + return await args.projectCatalogProvider.listProjects(); + } catch (error) { + args.logger.warn("sync_host.project_catalog_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return { projects: [] }; + } + } + + async function handleProjectSwitchRequest( + peer: PeerState, + requestId: string | null | undefined, + payload: SyncProjectSwitchRequestPayload | null, + ): Promise { + if (!args.projectCatalogProvider) { + send(peer.ws, "project_switch_result", { + ok: false, + message: "Desktop project switching is not available.", + }, requestId); + return; + } + try { + const result = await args.projectCatalogProvider.prepareProjectConnection(payload ?? {}); + send(peer.ws, "project_switch_result", result, requestId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + args.logger.warn("sync_host.project_switch_failed", { message }); + send(peer.ws, "project_switch_result", { + ok: false, + message, + }, requestId); + } + } + function buildBrainStatus(): SyncBrainStatusPayload { const brainMetadata = readBrainMetadata(); if (disposed) { @@ -1588,18 +1634,23 @@ export function createSyncHostService(args: SyncHostServiceArgs) { lastHost: peer.remoteAddress, lastPort: peer.remotePort, }); + const projectCatalog = await buildProjectCatalogPayload(); send(peer.ws, "hello_ok", { peer: hello.peer, brain: readBrainMetadata(), serverDbVersion: args.db.sync.getDbVersion(), heartbeatIntervalMs, pollIntervalMs, + projects: projectCatalog.projects, features: { fileAccess: true, terminalStreaming: true, chatStreaming: { enabled: true, }, + projectCatalog: { + enabled: Boolean(args.projectCatalogProvider), + }, bootstrapAuth: true, pairingAuth: { enabled: true, @@ -1625,6 +1676,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } switch (envelope.type) { + case "project_catalog_request": { + send(peer.ws, "project_catalog", await buildProjectCatalogPayload(), envelope.requestId); + break; + } + case "project_switch_request": { + await handleProjectSwitchRequest(peer, envelope.requestId, envelope.payload as SyncProjectSwitchRequestPayload); + break; + } case "heartbeat": { const payload = envelope.payload as { kind?: string; sentAt?: string } | null; if (payload?.kind === "ping") { diff --git a/apps/desktop/src/main/services/sync/syncProtocol.test.ts b/apps/desktop/src/main/services/sync/syncProtocol.test.ts index 25715372e..380db8966 100644 --- a/apps/desktop/src/main/services/sync/syncProtocol.test.ts +++ b/apps/desktop/src/main/services/sync/syncProtocol.test.ts @@ -59,4 +59,45 @@ describe("syncProtocol", () => { expect(parsed.compression).toBe("gzip"); expect(parsed.payload).toEqual(payload); }); + + it("round-trips project switch result payloads with request ids", () => { + const payload = { + ok: true, + project: { + id: "project-1", + displayName: "ADE", + rootPath: "/Users/arul/ADE", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T12:00:00.000Z", + laneCount: 3, + isAvailable: true, + isCached: true, + }, + connection: { + authKind: "bootstrap", + token: "bootstrap-token", + hostIdentity: { + deviceId: "host-1", + siteId: "site-1", + name: "ADE Desktop", + platform: "macOS", + deviceType: "desktop", + }, + port: 8787, + addressCandidates: [{ host: "192.168.1.44", kind: "lan" }], + }, + }; + + const encoded = encodeSyncEnvelope({ + type: "project_switch_result", + requestId: "switch-1", + payload, + compressionThresholdBytes: 10_000, + }); + + const parsed = parseSyncEnvelope(encoded); + expect(parsed.type).toBe("project_switch_result"); + expect(parsed.requestId).toBe("switch-1"); + expect(parsed.payload).toEqual(payload); + }); }); diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index c5692d213..b78d3b56d 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -7,6 +7,9 @@ import type { SyncDeviceRuntimeState, SyncPairingConnectInfo, SyncPairingQrPayload, + SyncProjectCatalogPayload, + SyncProjectSwitchRequestPayload, + SyncProjectSwitchResultPayload, SyncRoleSnapshot, SyncTailnetDiscoveryStatus, SyncTransferBlocker, @@ -108,6 +111,10 @@ type SyncServiceArgs = { * connected iOS peers. */ notificationEventBus?: NotificationEventBus | null; + projectCatalogProvider?: { + listProjects: () => Promise; + prepareProjectConnection: (args: SyncProjectSwitchRequestPayload) => Promise; + }; }; const DRAFT_FILE = "sync-peer-draft.json"; @@ -427,6 +434,7 @@ export function createSyncService(args: SyncServiceArgs) { port: attemptedPort, deviceRegistryService, notificationEventBus: args.notificationEventBus ?? null, + projectCatalogProvider: args.projectCatalogProvider, onStateChanged: () => { void refreshRoleState(); }, diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx new file mode 100644 index 000000000..c66978ff3 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -0,0 +1,194 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { TopBar } from "./TopBar"; +import { useAppStore } from "../../state/appStore"; + +vi.mock("../settings/SyncDevicesSection", () => ({ + SyncDevicesSection: () =>
Sync devices panel
, +})); + +vi.mock("./AutoUpdateControl", () => ({ + AutoUpdateControl: () => null, +})); + +vi.mock("./FeedbackReporterModal", () => ({ + FeedbackReporterModal: () => null, +})); + +vi.mock("../onboarding/HelpMenu", () => ({ + HelpMenu: () => null, +})); + +vi.mock("../../lib/sessions", () => ({ + isRunOwnedSession: () => false, +})); + +vi.mock("../../lib/zoom", () => ({ + ZOOM_LEVEL_KEY: "ade.zoomLevel", + MIN_ZOOM_LEVEL: 50, + MAX_ZOOM_LEVEL: 200, + displayZoomToLevel: (value: number) => value, + getStoredZoomLevel: () => 100, +})); + +function makeSyncSnapshot(overrides: Record = {}) { + return { + mode: "standalone", + role: "brain", + localDevice: { + deviceId: "desktop-1", + siteId: "site-1", + name: "ADE Desktop", + platform: "macOS", + deviceType: "desktop", + createdAt: "2026-04-22T00:00:00.000Z", + updatedAt: "2026-04-22T00:00:00.000Z", + lastSeenAt: "2026-04-22T00:00:00.000Z", + lastHost: null, + lastPort: null, + tailscaleIp: null, + ipAddresses: [], + metadata: {}, + }, + currentBrain: null, + clusterState: null, + bootstrapToken: "bootstrap-token", + pairingPin: null, + pairingPinConfigured: false, + pairingConnectInfo: null, + connectedPeers: [ + { deviceId: "phone-1", deviceName: "Arul iPhone", platform: "iOS", deviceType: "phone" }, + ], + tailnetDiscovery: { + state: "disabled", + serviceName: "svc:ade-sync", + servicePort: 8787, + target: null, + updatedAt: null, + error: null, + stderr: null, + }, + client: { state: "disconnected" }, + transferReadiness: { ready: true, blockers: [], survivableState: [] }, + survivableStateText: "", + blockingStateText: "", + ...overrides, + }; +} + +function resetStore() { + useAppStore.setState({ + project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, + terminalAttention: { + runningCount: 0, + activeCount: 0, + needsAttentionCount: 0, + indicator: "none", + byLaneId: {}, + }, + closeProject: vi.fn(async () => undefined), + openRepo: vi.fn(async () => ({ rootPath: "/Users/arul/ADE", name: "ADE" })), + isNewTabOpen: false, + openNewTab: vi.fn(), + cancelNewTab: vi.fn(), + projectTransition: null, + projectTransitionError: null, + clearProjectTransitionError: vi.fn(), + switchProjectToPath: vi.fn(async () => undefined), + } as any); +} + +describe("TopBar", () => { + const originalAde = globalThis.window.ade; + + beforeEach(() => { + resetStore(); + globalThis.window.ade = { + project: { + listRecent: vi.fn(async () => [ + { + rootPath: "/Users/arul/ADE", + displayName: "ADE", + exists: true, + lastOpenedAt: "2026-04-22T00:00:00.000Z", + laneCount: 3, + }, + ]), + onMissing: vi.fn(() => () => {}), + forgetRecent: vi.fn(async () => []), + reorderRecent: vi.fn(async () => undefined), + }, + sync: { + getStatus: vi.fn(async () => makeSyncSnapshot()), + onEvent: vi.fn(() => () => {}), + }, + zoom: { + setLevel: vi.fn(), + }, + lanes: { list: vi.fn(async () => []) }, + sessions: { list: vi.fn(async () => []) }, + agentChat: { list: vi.fn(async () => []) }, + missions: { list: vi.fn(async () => []) }, + processes: { listRuntime: vi.fn(async () => []) }, + } as any; + }); + + afterEach(() => { + cleanup(); + if (originalAde === undefined) { + delete (globalThis.window as any).ade; + } else { + globalThis.window.ade = originalAde; + } + }); + + it("opens the phone sync drawer from the host status control", async () => { + render(); + + expect(await screen.findByText("1 phone connected")).toBeTruthy(); + + fireEvent.click(screen.getByTitle("Connect a phone to this computer")); + + expect(screen.getByText("Phone sync")).toBeTruthy(); + expect(screen.getByTestId("sync-devices-section")).toBeTruthy(); + expect(screen.getByTitle("Connect a phone to this computer").getAttribute("aria-expanded")).toBe("true"); + + fireEvent.click(screen.getByTitle("Close phone sync")); + + await waitFor(() => { + expect(screen.queryByTestId("sync-devices-section")).toBeNull(); + }); + }); + + it("refreshes the phone sync label after switching projects", async () => { + const getStatus = vi.fn() + .mockResolvedValueOnce(makeSyncSnapshot({ connectedPeers: [] })) + .mockResolvedValueOnce(makeSyncSnapshot({ + connectedPeers: [ + { deviceId: "phone-1", deviceName: "Arul iPhone", platform: "iOS", deviceType: "phone" }, + ], + })); + globalThis.window.ade.sync.getStatus = getStatus as any; + + render(); + + expect(await screen.findByText("Phone sync ready")).toBeTruthy(); + + await act(async () => { + useAppStore.setState({ + project: { + rootPath: "/Users/arul/ADE/.ade/worktrees/mobile-lanes-tab-2d82c012", + name: "mobile-lanes-tab-2d82c012", + } as any, + }); + }); + + await waitFor(() => { + expect(getStatus).toHaveBeenCalledTimes(2); + }); + expect(await screen.findByText("1 phone connected")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index ae604c494..8f5815c5e 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; -import { ChatCircleDots, CircleNotch, Folder, FolderOpen, Plus, Minus, Trash, X } from "@phosphor-icons/react"; -import { useNavigate } from "react-router-dom"; +import { ChatCircleDots, CircleNotch, DeviceMobile, Folder, FolderOpen, Plus, Minus, Trash, X } from "@phosphor-icons/react"; import { useAppStore } from "../../state/appStore"; import { isRunOwnedSession } from "../../lib/sessions"; @@ -16,36 +15,55 @@ import type { ProcessRuntime, RecentProjectSummary, SyncRoleSnapshot } from "../ import { AutoUpdateControl } from "./AutoUpdateControl"; import { FeedbackReporterModal } from "./FeedbackReporterModal"; import { HelpMenu } from "../onboarding/HelpMenu"; +import { SyncDevicesSection } from "../settings/SyncDevicesSection"; const RUNNING_LANE_PROCESS_STATES: ProcessRuntime["status"][] = ["starting", "running", "degraded"]; +const PHONE_SYNC_FOCUSABLE_SELECTOR = [ + "a[href]", + "button:not([disabled])", + "textarea:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "[tabindex]:not([tabindex=\"-1\"])", +].join(","); + +function getFocusableElements(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(PHONE_SYNC_FOCUSABLE_SELECTOR)) + .filter((element) => + element.getAttribute("aria-hidden") !== "true" + && !element.hasAttribute("disabled") + && element.tabIndex >= 0 + ); +} function syncDotClass(snapshot: SyncRoleSnapshot): string { if (snapshot.client.state === "error") return "ade-status-dot-error"; - if (snapshot.client.state === "connected" || snapshot.mode === "brain") return "ade-status-dot-active"; + if (snapshot.client.state === "connected" || snapshot.role === "brain") return "ade-status-dot-active"; return "ade-status-dot-warning"; } function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { if (!snapshot) return null; - if (snapshot.mode === "brain") { + if (snapshot.client.state === "error") return "Phone sync error"; + if (snapshot.role === "brain") { const count = snapshot.connectedPeers.length; - return `Hosting locally · ${count} controller${count === 1 ? "" : "s"}`; + if (count > 0) { + return `${count} phone${count === 1 ? "" : "s"} connected`; + } + return "Phone sync ready"; } - if (snapshot.mode === "standalone") return "Phone pairing ready"; + if (snapshot.mode === "standalone") return "Phone sync ready"; switch (snapshot.client.state) { case "connected": - return `Connected to host · ${snapshot.currentBrain?.name ?? "host"}`; + return `Linked to ${snapshot.currentBrain?.name ?? "host"}`; case "connecting": - return "Connecting to host…"; - case "error": - return "Host link error"; + return "Connecting…"; default: - return "No host link"; + return "Phone sync offline"; } } export function TopBar() { - const navigate = useNavigate(); const project = useAppStore((s) => s.project); const closeProject = useAppStore((s) => s.closeProject); const terminalAttention = useAppStore((s) => s.terminalAttention); @@ -61,9 +79,11 @@ export function TopBar() { const [relocatingPath, setRelocatingPath] = useState(null); const [zoom, setZoom] = useState(getStoredZoomLevel); const [syncSnapshot, setSyncSnapshot] = useState(null); + const [phoneSyncOpen, setPhoneSyncOpen] = useState(false); const [feedbackOpen, setFeedbackOpen] = useState(false); const [dragIdx, setDragIdx] = useState(null); const [dropIdx, setDropIdx] = useState(null); + const phoneSyncPanelRef = useRef(null); const dragCounterRef = useRef(0); const isProjectBusy = projectTransition != null || relocatingPath != null; @@ -88,6 +108,14 @@ export function TopBar() { fetchRecent(); }, [project?.rootPath, fetchRecent]); + useEffect(() => { + if (!phoneSyncOpen) return; + const frame = window.requestAnimationFrame(() => { + phoneSyncPanelRef.current?.focus(); + }); + return () => window.cancelAnimationFrame(frame); + }, [phoneSyncOpen]); + // Re-fetch when app regains focus (catches external deletions). useEffect(() => { const onFocus = () => fetchRecent(); @@ -103,9 +131,17 @@ export function TopBar() { useEffect(() => { let cancelled = false; - void window.ade.sync.getStatus().then((snapshot) => { - if (!cancelled) setSyncSnapshot(snapshot); - }).catch(() => {}); + const refreshSyncStatus = () => { + void window.ade.sync.getStatus().then((snapshot) => { + if (!cancelled) setSyncSnapshot(snapshot); + }).catch(() => { + if (!cancelled) setSyncSnapshot(null); + }); + }; + setSyncSnapshot(null); + refreshSyncStatus(); + const interval = window.setInterval(refreshSyncStatus, 5_000); + window.addEventListener("focus", refreshSyncStatus); const dispose = window.ade.sync.onEvent((event) => { if (!cancelled && event.type === "sync-status") { setSyncSnapshot(event.snapshot); @@ -113,9 +149,11 @@ export function TopBar() { }); return () => { cancelled = true; + window.clearInterval(interval); + window.removeEventListener("focus", refreshSyncStatus); dispose(); }; - }, []); + }, [project?.rootPath]); const checkForActiveWorkloads = useCallback(async (projectRootPath: string): Promise => { if (project?.rootPath !== projectRootPath) return true; @@ -248,6 +286,37 @@ export function TopBar() { setDropIdx(null); }, []); + const handlePhoneSyncDialogKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + setPhoneSyncOpen(false); + return; + } + if (event.key !== "Tab") return; + + const panel = phoneSyncPanelRef.current; + if (!panel) return; + const focusable = getFocusableElements(panel); + if (focusable.length === 0) { + event.preventDefault(); + panel.focus(); + return; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (document.activeElement === panel) { + event.preventDefault(); + (event.shiftKey ? last : first).focus(); + } else if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }, []); + const syncLabel = deriveSyncLabel(syncSnapshot); const transitionTargetName = projectTransition?.rootPath @@ -542,9 +611,11 @@ export function TopBar() { )} data-variant="ghost" style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties} - title={`Sync mode: ${syncSnapshot.mode}`} - onClick={() => navigate("/settings")} + title="Connect a phone to this computer" + aria-expanded={phoneSyncOpen} + onClick={() => setPhoneSyncOpen((open) => !open)} > + ) : null} + {phoneSyncOpen ? ( +
setPhoneSyncOpen(false)} + > +
event.stopPropagation()} + onKeyDown={handlePhoneSyncDialogKeyDown} + > +
+
+ +
+
Phone sync
+
{syncLabel}
+
+
+ +
+
+ +
+
+
+ ) : null} + diff --git a/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx b/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx index 1cccbf3e3..6fa4d9851 100644 --- a/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx +++ b/apps/desktop/src/renderer/components/onboarding/HelpMenu.tsx @@ -231,8 +231,9 @@ export function HelpMenu() { function waitForTourFirstTarget(tourId: string, signal: AbortSignal): Promise { const tour = getTour(tourId); - const selector = tour?.steps[0]?.waitForSelector ?? tour?.steps[0]?.target; - if (!selector) return Promise.resolve(false); + const firstStep = tour?.steps[0]; + const selector = firstStep?.waitForSelector?.trim() || firstStep?.target?.trim(); + if (!selector) return Promise.resolve(Boolean(firstStep)); const hasTarget = () => { if (signal.aborted) return false; diff --git a/apps/desktop/src/renderer/components/onboarding/tour/TourHost.tsx b/apps/desktop/src/renderer/components/onboarding/tour/TourHost.tsx index 0209edb5f..610ffbc9d 100644 --- a/apps/desktop/src/renderer/components/onboarding/tour/TourHost.tsx +++ b/apps/desktop/src/renderer/components/onboarding/tour/TourHost.tsx @@ -11,6 +11,7 @@ export function TourHost() { const onboardingEnabled = useAppStore((s) => s.onboardingEnabled); const activeTourId = useOnboardingStore((s) => s.activeTourId); const activeStepIndex = useOnboardingStore((s) => s.activeStepIndex); + const activeTourCtx = useOnboardingStore((s) => s.activeTourCtx); if (!onboardingEnabled) return null; if (!activeTourId) return null; @@ -23,5 +24,12 @@ export function TourHost() { const step = tour.steps[clampedIndex]; if (!step) return null; - return ; + return ( + + ); } diff --git a/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.test.tsx b/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.test.tsx index 3491f9e11..a4bb5235a 100644 --- a/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.test.tsx +++ b/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.test.tsx @@ -57,6 +57,33 @@ describe("TourOverlay", () => { expect(dismissSpy).toHaveBeenCalledTimes(1); }); + it("lets act intro own Escape handling", () => { + const nextSpy = vi.fn().mockResolvedValue(undefined); + const dismissSpy = vi.fn().mockResolvedValue(undefined); + useOnboardingStore.setState({ + nextStep: nextSpy as any, + dismissCurrentTour: dismissSpy as any, + }); + + render( + , + ); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(nextSpy).toHaveBeenCalledTimes(1); + expect(dismissSpy).not.toHaveBeenCalled(); + }); + it("ArrowRight advances to the next step when not last", () => { const nextSpy = vi.fn().mockResolvedValue(undefined); const completeSpy = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.tsx b/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.tsx index bbb529ebe..108b46b67 100644 --- a/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.tsx +++ b/apps/desktop/src/renderer/components/onboarding/tour/TourOverlay.tsx @@ -1,7 +1,8 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import type { TourStep as TourStepType } from "../../../onboarding/registry"; +import type { TourCtx, TourStep as TourStepType } from "../../../onboarding/registry"; import { useOnboardingStore } from "../../../state/onboardingStore"; +import { ActIntro } from "../fx/ActIntro"; import { TourStep } from "./TourStep"; const SELECTOR_RETRY_MS = 500; @@ -13,6 +14,7 @@ type TourOverlayProps = { step: TourStepType; stepIndex: number; totalSteps: number; + ctx?: TourCtx | null; }; type TargetState = @@ -33,7 +35,11 @@ function shouldLetEnterActivateTarget(target: EventTarget | null): boolean { return target.closest(INTERACTIVE_SHORTCUT_SELECTOR) != null; } -export function TourOverlay({ step, stepIndex, totalSteps }: TourOverlayProps) { +function hasTargetSelector(step: TourStepType): boolean { + return step.target.trim().length > 0; +} + +export function TourOverlay({ step, stepIndex, totalSteps, ctx }: TourOverlayProps) { const nextStep = useOnboardingStore((s) => s.nextStep); const prevStep = useOnboardingStore((s) => s.prevStep); const dismissCurrentTour = useOnboardingStore((s) => s.dismissCurrentTour); @@ -46,7 +52,18 @@ export function TourOverlay({ step, stepIndex, totalSteps }: TourOverlayProps) { const isLast = stepIndex >= totalSteps - 1; const measure = useCallback(() => { - const el = document.querySelector(step.target) as HTMLElement | null; + const selector = step.target.trim(); + if (!selector) { + setTarget({ kind: "missing" }); + return true; + } + let el: HTMLElement | null = null; + try { + el = document.querySelector(selector) as HTMLElement | null; + } catch { + setTarget({ kind: "missing" }); + return true; + } if (el) { setTarget({ kind: "found", rect: el.getBoundingClientRect() }); return true; @@ -139,6 +156,8 @@ export function TourOverlay({ step, stepIndex, totalSteps }: TourOverlayProps) { // Keyboard handling at the document level. useEffect(() => { + if (step.actIntro) return; + const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); @@ -158,12 +177,25 @@ export function TourOverlay({ step, stepIndex, totalSteps }: TourOverlayProps) { }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); - }, [handleNext, handlePrev, handleDismiss]); + }, [handleNext, handlePrev, handleDismiss, step.actIntro]); if (typeof document === "undefined") return null; const targetRect = target.kind === "found" ? target.rect : null; - const missing = target.kind !== "found"; + const missing = hasTargetSelector(step) && target.kind !== "found"; + + if (step.actIntro) { + return createPortal( + , + document.body, + ); + } return createPortal(
: null} (null); const estHeight = 180; const pos = computePosition(targetRect, step.placement, CARD_WIDTH, estHeight); + const body = step.bodyTemplate && ctx ? step.bodyTemplate(ctx) : step.body; // Focus-trap: intercept Tab inside the card. Also focus the primary action on mount. useEffect(() => { @@ -186,7 +189,7 @@ export function TourStep({ color: "var(--color-muted-fg, #B7B6C3)", }} > - {step.body} + {body} {missing ? ( <> {" "} diff --git a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx index f55f0d2d7..c67e9a83c 100644 --- a/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx +++ b/apps/desktop/src/renderer/components/settings/SyncDevicesSection.tsx @@ -168,8 +168,17 @@ export function SyncDevicesSection() { if (!cancelled) setDevices(nextDevices); }).catch(() => {}); }); + const refreshWhenVisible = () => { + if (!cancelled) { + void refresh().catch(() => {}); + } + }; + const interval = window.setInterval(refreshWhenVisible, 5_000); + window.addEventListener("focus", refreshWhenVisible); return () => { cancelled = true; + window.clearInterval(interval); + window.removeEventListener("focus", refreshWhenVisible); dispose(); }; }, [refresh]); diff --git a/apps/desktop/src/renderer/onboarding/docsLinks.test.ts b/apps/desktop/src/renderer/onboarding/docsLinks.test.ts index 20ebab70d..0c1142b56 100644 --- a/apps/desktop/src/renderer/onboarding/docsLinks.test.ts +++ b/apps/desktop/src/renderer/onboarding/docsLinks.test.ts @@ -1,19 +1,39 @@ import { describe, it, expect } from "vitest"; import { docs, DOCS_HOME } from "./docsLinks"; +const EXPECTED_DOCS = { + home: "https://www.ade-app.dev/docs", + welcome: "https://www.ade-app.dev/docs/welcome", + lanesCreating: "https://www.ade-app.dev/docs/lanes/creating", + lanesStacks: "https://www.ade-app.dev/docs/lanes/stacks", + lanesPacks: "https://www.ade-app.dev/docs/lanes/packs", + lanesEnvironment: "https://www.ade-app.dev/docs/lanes/environment", + chatOverview: "https://www.ade-app.dev/docs/chat/overview", + chatContext: "https://www.ade-app.dev/docs/chat/context", + chatCapabilities: "https://www.ade-app.dev/docs/chat/capabilities", + terminals: "https://www.ade-app.dev/docs/tools/terminals", + filesEditor: "https://www.ade-app.dev/docs/tools/files-editor", + multiAgentSetup: "https://www.ade-app.dev/docs/guides/multi-agent-setup", +} as const; + describe("docsLinks", () => { - it("every URL is under ade-app.dev without /docs/ prefix", () => { + it("every URL is under ade-app.dev", () => { for (const [key, url] of Object.entries(docs)) { expect(url, key).toMatch(/^https:\/\/www\.ade-app\.dev(\/|$)/); - expect(url, `${key} must not contain /docs/`).not.toContain("/docs/"); } }); - it("home points at the site root", () => { - expect(docs.home).toBe("https://www.ade-app.dev"); + it("home points at the docs root", () => { + expect(docs.home).toBe("https://www.ade-app.dev/docs"); expect(DOCS_HOME).toBe(docs.home); }); + it("canonical docs entries point at their expected routes", () => { + for (const key of Object.keys(EXPECTED_DOCS) as Array) { + expect(docs[key], key).toBe(EXPECTED_DOCS[key]); + } + }); + it("all values are non-empty strings", () => { const values = Object.values(docs); expect(values.length).toBeGreaterThan(0); diff --git a/apps/desktop/src/renderer/onboarding/docsLinks.ts b/apps/desktop/src/renderer/onboarding/docsLinks.ts index c44466b03..1d03402d2 100644 --- a/apps/desktop/src/renderer/onboarding/docsLinks.ts +++ b/apps/desktop/src/renderer/onboarding/docsLinks.ts @@ -1,36 +1,34 @@ // Centralised docs URLs referenced from tours and the Help menu. // -// The public docs site paths DO NOT include a `/docs/` prefix — the real site -// structure is `https://www.ade-app.dev/
/`. Do not add `/docs/`. -// If a key points at a page that may not exist yet, prefer falling back to -// `docs.home`. Keys marked `// TODO: verify` are best-guess paths that match -// the site's section layout but have not been confirmed live. +// Public docs live under the Mintlify `/docs` prefix. Keep these paths in sync +// with the public site so onboarding links never send users to dead pages. -const DOCS_BASE = "https://www.ade-app.dev"; +const SITE_BASE = "https://www.ade-app.dev"; +const DOCS_BASE = `${SITE_BASE}/docs`; export const docs = { // Root / welcome home: DOCS_BASE, welcome: `${DOCS_BASE}/welcome`, - keyConcepts: `${DOCS_BASE}/welcome`, // TODO: verify — no dedicated key-concepts page yet + keyConcepts: `${DOCS_BASE}/key-concepts`, // Lanes lanesOverview: `${DOCS_BASE}/lanes/overview`, - lanesCreating: `${DOCS_BASE}/lanes/overview`, // TODO: verify — creating lanes covered in overview today - lanesStacks: `${DOCS_BASE}/lanes/overview`, // TODO: verify — stacks live under lanes section - lanesPacks: `${DOCS_BASE}/lanes/overview`, // TODO: verify - lanesEnvironment: `${DOCS_BASE}/lanes/overview`, // TODO: verify + lanesCreating: `${DOCS_BASE}/lanes/creating`, + lanesStacks: `${DOCS_BASE}/lanes/stacks`, + lanesPacks: `${DOCS_BASE}/lanes/packs`, + lanesEnvironment: `${DOCS_BASE}/lanes/environment`, // Chat / work / terminals - chatOverview: `${DOCS_BASE}/missions/overview`, // TODO: verify — chat lives under missions/cto docs - chatContext: `${DOCS_BASE}/missions/overview`, // TODO: verify - chatCapabilities: `${DOCS_BASE}/missions/overview`, // TODO: verify - terminals: `${DOCS_BASE}/tools/project-home`, // TODO: verify — no dedicated terminals page yet - filesEditor: `${DOCS_BASE}/tools/project-home`, // TODO: verify — file editor covered under project-home tools + chatOverview: `${DOCS_BASE}/chat/overview`, + chatContext: `${DOCS_BASE}/chat/context`, + chatCapabilities: `${DOCS_BASE}/chat/capabilities`, + terminals: `${DOCS_BASE}/tools/terminals`, + filesEditor: `${DOCS_BASE}/tools/files-editor`, // First-run / getting-started - gettingStartedFirstLane: `${DOCS_BASE}/welcome`, // TODO: verify — first-lane guide lives under welcome today - firstLane: `${DOCS_BASE}/welcome`, // TODO: verify — alias for gettingStartedFirstLane + gettingStartedFirstLane: `${DOCS_BASE}/getting-started/first-lane`, + firstLane: `${DOCS_BASE}/getting-started/first-lane`, // Higher-level product areas projectHome: `${DOCS_BASE}/tools/project-home`, @@ -44,7 +42,7 @@ export const docs = { historyOverview: `${DOCS_BASE}/tools/history`, // Guides - multiAgentSetup: `${DOCS_BASE}/missions/overview`, // TODO: verify — no dedicated guide yet + multiAgentSetup: `${DOCS_BASE}/guides/multi-agent-setup`, } as const; export type DocsKey = keyof typeof docs; diff --git a/apps/desktop/src/renderer/onboarding/stepBuilders/stepBuilders.test.ts b/apps/desktop/src/renderer/onboarding/stepBuilders/stepBuilders.test.ts index 1b1ebf9cb..9cf6eccba 100644 --- a/apps/desktop/src/renderer/onboarding/stepBuilders/stepBuilders.test.ts +++ b/apps/desktop/src/renderer/onboarding/stepBuilders/stepBuilders.test.ts @@ -62,11 +62,10 @@ describe("step builders", () => { } }); - it("every docUrl points at ade-app.dev without /docs/", () => { + it("every docUrl points at ade-app.dev", () => { for (const step of steps) { if (!step.docUrl) continue; expect(step.docUrl.startsWith(VALID_DOCS_PREFIX)).toBe(true); - expect(step.docUrl).not.toContain("/docs/"); } }); diff --git a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts index 72c4369fd..49d46cb7e 100644 --- a/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts +++ b/apps/desktop/src/renderer/onboarding/tours/firstJourneyTour.test.ts @@ -56,7 +56,6 @@ describe("firstJourneyTour", () => { step.docUrl.startsWith(VALID_DOCS_PREFIX), `docUrl for step ${step.id ?? step.target}: ${step.docUrl}`, ).toBe(true); - expect(step.docUrl).not.toContain("/docs/"); } }); diff --git a/apps/desktop/src/renderer/onboarding/tours/highlights.test.ts b/apps/desktop/src/renderer/onboarding/tours/highlights.test.ts index 7715557c0..28a9b0fb8 100644 --- a/apps/desktop/src/renderer/onboarding/tours/highlights.test.ts +++ b/apps/desktop/src/renderer/onboarding/tours/highlights.test.ts @@ -59,12 +59,11 @@ describe("highlights variants", () => { } }); - it("every docUrl on highlight steps points to ade-app.dev without /docs/", () => { + it("every docUrl on highlight steps points to ade-app.dev", () => { for (const tour of listTours("highlights")) { for (const step of tour.steps) { if (!step.docUrl) continue; expect(step.docUrl).toMatch(/^https:\/\/www\.ade-app\.dev/); - expect(step.docUrl).not.toContain("/docs/"); } } }); diff --git a/apps/desktop/src/renderer/onboarding/tours/lanesTour.test.ts b/apps/desktop/src/renderer/onboarding/tours/lanesTour.test.ts index 54cc12fde..1bd88c8a5 100644 --- a/apps/desktop/src/renderer/onboarding/tours/lanesTour.test.ts +++ b/apps/desktop/src/renderer/onboarding/tours/lanesTour.test.ts @@ -30,11 +30,10 @@ describe("lanesTour", () => { } }); - it("every step's docUrl points at ade-app.dev (no /docs/ prefix)", () => { + it("every step's docUrl points at ade-app.dev", () => { for (const step of lanesTour.steps) { expect(step.docUrl, `docUrl for ${step.target}`).toBeDefined(); expect(step.docUrl!.startsWith(VALID_DOCS_PREFIX), `docUrl for ${step.target}: ${step.docUrl}`).toBe(true); - expect(step.docUrl!, `docUrl must not contain /docs/: ${step.docUrl}`).not.toContain("/docs/"); } }); diff --git a/apps/desktop/src/renderer/state/onboardingStore.test.ts b/apps/desktop/src/renderer/state/onboardingStore.test.ts index 83339ec8c..4c1910f63 100644 --- a/apps/desktop/src/renderer/state/onboardingStore.test.ts +++ b/apps/desktop/src/renderer/state/onboardingStore.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OnboardingTourProgress } from "../../shared/types"; +import { _resetRegistryForTests, registerTour } from "../onboarding/registry"; function emptyProgress(): OnboardingTourProgress { return { @@ -57,9 +58,21 @@ import { useOnboardingStore } from "./onboardingStore"; function resetStore() { progress = emptyProgress(); + _resetRegistryForTests(); + registerTour({ + id: "lanes", + title: "Lanes", + route: "/lanes", + steps: [ + { id: "one", target: "", title: "One", body: "First step" }, + { id: "two", target: "", title: "Two", body: "Second step" }, + { id: "three", target: "", title: "Three", body: "Third step" }, + ], + }); useOnboardingStore.setState({ activeTourId: null, activeStepIndex: 0, + activeTourCtx: null, wizardOpen: false, hydrated: false, progress: null, @@ -102,6 +115,12 @@ describe("onboardingStore", () => { expect(mockOnboarding.updateTourStep).toHaveBeenCalledWith("lanes", 0); }); + it("startTour ignores unknown tour ids", async () => { + await useOnboardingStore.getState().startTour("missing-tour"); + expect(useOnboardingStore.getState().activeTourId).toBeNull(); + expect(mockOnboarding.updateTourStep).not.toHaveBeenCalled(); + }); + it("nextStep / prevStep advance and persist the index, never below 0", async () => { await useOnboardingStore.getState().startTour("lanes"); diff --git a/apps/desktop/src/renderer/state/onboardingStore.ts b/apps/desktop/src/renderer/state/onboardingStore.ts index d50f016b4..25af16741 100644 --- a/apps/desktop/src/renderer/state/onboardingStore.ts +++ b/apps/desktop/src/renderer/state/onboardingStore.ts @@ -1,5 +1,13 @@ import { create } from "zustand"; import type { OnboardingTourProgress } from "../../shared/types"; +import { dialogBus } from "../lib/dialogBus"; +import { + getTour, + type StepAction, + type TourCtx, + type TourStep, +} from "../onboarding/registry"; +import { waitForSelector } from "../onboarding/waitForTarget"; export type { OnboardingTourProgress as TourProgress }; @@ -13,6 +21,7 @@ const EMPTY_PROGRESS: OnboardingTourProgress = { type OnboardingState = { activeTourId: string | null; activeStepIndex: number; + activeTourCtx: TourCtx | null; wizardOpen: boolean; hydrated: boolean; progress: OnboardingTourProgress | null; @@ -23,7 +32,7 @@ type OnboardingState = { startTour: (tourId: string) => Promise; nextStep: () => Promise; prevStep: () => Promise; - completeCurrentTour: () => Promise; + completeCurrentTour: (skipAfterLeave?: boolean) => Promise; dismissCurrentTour: () => Promise; }; @@ -34,6 +43,104 @@ function api() { return maybe?.onboarding ?? null; } +function createTourCtx(initial: Record = {}): TourCtx { + const values: Record = { ...initial }; + return { + values, + set(k, v) { + values[k] = v; + }, + get(k: string): T | undefined { + return values[k] as T | undefined; + }, + }; +} + +let activeWaitAbortController: AbortController | null = null; + +function abortActiveWait(): void { + activeWaitAbortController?.abort(); + activeWaitAbortController = null; +} + +function navigateToRoute(route: string): void { + if (typeof window === "undefined") return; + const target = route.trim(); + if (!target) return; + if ((window as any).__adeBrowserMock) { + window.history.pushState(null, "", target); + window.dispatchEvent(new PopStateEvent("popstate")); + return; + } + window.location.hash = target.startsWith("#") ? target : `#${target}`; +} + +async function runActions(actions: StepAction[]): Promise { + for (const action of actions) { + switch (action.type) { + case "navigate": + navigateToRoute(action.to); + break; + case "openDialog": + dialogBus.open(action.id, action.props); + break; + case "closeDialog": + dialogBus.close(action.id); + break; + case "ipc": + try { + await action.call(); + } catch (error) { + console.error("[onboarding] IPC action failed", error); + } + break; + case "focus": { + if (typeof document === "undefined") break; + const el = document.querySelector(action.selector) as HTMLElement | null; + if (!el) break; + try { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } catch { + el.scrollIntoView(); + } + el.setAttribute("data-tour-focus", "true"); + break; + } + } + } +} + +async function runBeforeEnter(step: TourStep | undefined, ctx: TourCtx): Promise { + if (!step) return; + const result = await step.beforeEnter?.(ctx); + if (Array.isArray(result)) { + await runActions(result); + } + if (step.waitForSelector) { + abortActiveWait(); + const controller = new AbortController(); + activeWaitAbortController = controller; + try { + await waitForSelector(step.waitForSelector, { + timeoutMs: step.fallbackAfterMs, + signal: controller.signal, + }); + } catch (error) { + if (!controller.signal.aborted) { + throw error; + } + } finally { + if (activeWaitAbortController === controller) { + activeWaitAbortController = null; + } + } + } +} + +async function runAfterLeave(step: TourStep | undefined, ctx: TourCtx): Promise { + await step?.afterLeave?.(ctx); +} + async function refreshProgress(): Promise { const onboarding = api(); if (!onboarding) return { ...EMPTY_PROGRESS }; @@ -43,6 +150,7 @@ async function refreshProgress(): Promise { export const useOnboardingStore = create((set, get) => ({ activeTourId: null, activeStepIndex: 0, + activeTourCtx: null, wizardOpen: false, hydrated: false, progress: null, @@ -67,39 +175,74 @@ export const useOnboardingStore = create((set, get) => ({ startTour: async (tourId: string) => { const id = tourId.trim(); if (!id) return; - set({ activeTourId: id, activeStepIndex: 0 }); + const tour = getTour(id); + if (!tour) return; + abortActiveWait(); + const ctx = createTourCtx(tour.ctxInit?.() ?? {}); + set({ activeTourId: id, activeStepIndex: 0, activeTourCtx: ctx }); const onboarding = api(); - if (!onboarding) return; - const progress = await onboarding.updateTourStep(id, 0); - set({ progress }); + if (onboarding) { + const progress = await onboarding.updateTourStep(id, 0); + set({ progress }); + } + await runBeforeEnter(tour.steps[0], ctx); }, nextStep: async () => { const { activeTourId, activeStepIndex } = get(); if (!activeTourId) return; - const nextIndex = activeStepIndex + 1; - set({ activeStepIndex: nextIndex }); + abortActiveWait(); + const tour = getTour(activeTourId); + const ctx = get().activeTourCtx ?? createTourCtx(tour?.ctxInit?.() ?? {}); + const currentStep = tour?.steps[activeStepIndex]; + await runAfterLeave(currentStep, ctx); + const branchTarget = currentStep?.branches?.(ctx) ?? null; + const branchedIndex = + branchTarget && tour + ? tour.steps.findIndex((step) => step.id === branchTarget) + : -1; + const nextIndex = branchedIndex >= 0 ? branchedIndex : activeStepIndex + 1; + if (tour && nextIndex >= tour.steps.length) { + await get().completeCurrentTour(true); + return; + } + set({ activeStepIndex: nextIndex, activeTourCtx: ctx }); const onboarding = api(); - if (!onboarding) return; - const progress = await onboarding.updateTourStep(activeTourId, nextIndex); - set({ progress }); + if (onboarding) { + const progress = await onboarding.updateTourStep(activeTourId, nextIndex); + set({ progress }); + } + await runBeforeEnter(tour?.steps[nextIndex], ctx); }, prevStep: async () => { const { activeTourId, activeStepIndex } = get(); if (!activeTourId) return; - const nextIndex = Math.max(0, activeStepIndex - 1); - set({ activeStepIndex: nextIndex }); + if (activeStepIndex <= 0) return; + abortActiveWait(); + const tour = getTour(activeTourId); + const ctx = get().activeTourCtx ?? createTourCtx(tour?.ctxInit?.() ?? {}); + await runAfterLeave(tour?.steps[activeStepIndex], ctx); + const nextIndex = activeStepIndex - 1; + set({ activeStepIndex: nextIndex, activeTourCtx: ctx }); const onboarding = api(); - if (!onboarding) return; - const progress = await onboarding.updateTourStep(activeTourId, nextIndex); - set({ progress }); + if (onboarding) { + const progress = await onboarding.updateTourStep(activeTourId, nextIndex); + set({ progress }); + } + await runBeforeEnter(tour?.steps[nextIndex], ctx); }, - completeCurrentTour: async () => { - const { activeTourId } = get(); + completeCurrentTour: async (skipAfterLeave = false) => { + const { activeTourId, activeStepIndex } = get(); if (!activeTourId) return; - set({ activeTourId: null, activeStepIndex: 0 }); + abortActiveWait(); + const tour = getTour(activeTourId); + const ctx = get().activeTourCtx ?? createTourCtx(tour?.ctxInit?.() ?? {}); + if (!skipAfterLeave) { + await runAfterLeave(tour?.steps[activeStepIndex], ctx); + } + set({ activeTourId: null, activeStepIndex: 0, activeTourCtx: null }); const onboarding = api(); if (!onboarding) return; const progress = await onboarding.markTourCompleted(activeTourId); @@ -107,9 +250,13 @@ export const useOnboardingStore = create((set, get) => ({ }, dismissCurrentTour: async () => { - const { activeTourId } = get(); + const { activeTourId, activeStepIndex } = get(); if (!activeTourId) return; - set({ activeTourId: null, activeStepIndex: 0 }); + abortActiveWait(); + const tour = getTour(activeTourId); + const ctx = get().activeTourCtx ?? createTourCtx(tour?.ctxInit?.() ?? {}); + await runAfterLeave(tour?.steps[activeStepIndex], ctx); + set({ activeTourId: null, activeStepIndex: 0, activeTourCtx: null }); const onboarding = api(); if (!onboarding) return; const progress = await onboarding.markTourDismissed(activeTourId); diff --git a/apps/desktop/src/shared/types/computerUseArtifacts.ts b/apps/desktop/src/shared/types/computerUseArtifacts.ts index 908e64324..5d0edd6ba 100644 --- a/apps/desktop/src/shared/types/computerUseArtifacts.ts +++ b/apps/desktop/src/shared/types/computerUseArtifacts.ts @@ -136,6 +136,7 @@ export type ComputerUseBackendStatus = { backends: ComputerUseExternalBackendStatus[]; localFallback: { available: boolean; + state?: "present" | "missing" | "blocked_by_capability"; detail: string; supportedKinds: ComputerUseArtifactKind[]; }; diff --git a/apps/desktop/src/shared/types/missions.ts b/apps/desktop/src/shared/types/missions.ts index 9315779ef..416055292 100644 --- a/apps/desktop/src/shared/types/missions.ts +++ b/apps/desktop/src/shared/types/missions.ts @@ -737,6 +737,7 @@ export type MissionPreflightResult = { approvalSummary?: MissionPreflightApprovalSummary | null; computerUse?: { requiredKinds: ComputerUseArtifactKind[]; + /** Missing proof kinds that are configured as blocking. Warning-only gaps stay in the checklist. */ missingKinds: ComputerUseArtifactKind[]; availableExternalBackends: string[]; blocked: boolean; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 7371e34ef..0133f9f86 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -197,6 +197,9 @@ export type SyncFeatureFlags = { chatStreaming: { enabled: true; }; + projectCatalog: { + enabled: boolean; + }; bootstrapAuth: true; pairingAuth: { enabled: true; @@ -215,6 +218,41 @@ export type SyncHelloPayload = { auth?: SyncHelloAuth; }; +export type SyncMobileProjectSummary = { + id: string; + displayName: string; + rootPath: string | null; + defaultBaseRef: string | null; + lastOpenedAt: string | null; + laneCount: number; + isAvailable: boolean; + isCached: boolean; +}; + +export type SyncProjectCatalogPayload = { + projects: SyncMobileProjectSummary[]; +}; + +export type SyncProjectSwitchRequestPayload = { + projectId?: string | null; + rootPath?: string | null; +}; + +export type SyncProjectConnectionPayload = { + authKind: "bootstrap"; + token: string; + hostIdentity: SyncPairingQrPayload["hostIdentity"]; + port: number; + addressCandidates: SyncAddressCandidate[]; +}; + +export type SyncProjectSwitchResultPayload = { + ok: boolean; + message?: string | null; + project?: SyncMobileProjectSummary | null; + connection?: SyncProjectConnectionPayload | null; +}; + export type SyncHelloAuth = | { kind: "bootstrap"; token: string } | { kind: "paired"; deviceId: string; secret: string }; @@ -225,6 +263,7 @@ export type SyncHelloOkPayload = { serverDbVersion: number; heartbeatIntervalMs: number; pollIntervalMs: number; + projects?: SyncMobileProjectSummary[]; features: SyncFeatureFlags; }; @@ -788,6 +827,10 @@ type SyncEnvelopeWithPayload = export type SyncHelloEnvelope = SyncEnvelopeWithPayload<"hello", SyncHelloPayload>; export type SyncHelloOkEnvelope = SyncEnvelopeWithPayload<"hello_ok", SyncHelloOkPayload>; export type SyncHelloErrorEnvelope = SyncEnvelopeWithPayload<"hello_error", SyncHelloErrorPayload>; +export type SyncProjectCatalogRequestEnvelope = SyncEnvelopeWithPayload<"project_catalog_request", Record>; +export type SyncProjectCatalogEnvelope = SyncEnvelopeWithPayload<"project_catalog", SyncProjectCatalogPayload>; +export type SyncProjectSwitchRequestEnvelope = SyncEnvelopeWithPayload<"project_switch_request", SyncProjectSwitchRequestPayload>; +export type SyncProjectSwitchResultEnvelope = SyncEnvelopeWithPayload<"project_switch_result", SyncProjectSwitchResultPayload>; export type SyncPairingRequestEnvelope = SyncEnvelopeWithPayload<"pairing_request", SyncPairingRequestPayload>; export type SyncPairingResultEnvelope = SyncEnvelopeWithPayload<"pairing_result", SyncPairingResultPayload>; export type SyncChangesetBatchEnvelope = SyncEnvelopeWithPayload<"changeset_batch", SyncChangesetBatchPayload>; @@ -815,6 +858,10 @@ export type SyncEnvelope = | SyncHelloEnvelope | SyncHelloOkEnvelope | SyncHelloErrorEnvelope + | SyncProjectCatalogRequestEnvelope + | SyncProjectCatalogEnvelope + | SyncProjectSwitchRequestEnvelope + | SyncProjectSwitchResultEnvelope | SyncPairingRequestEnvelope | SyncPairingResultEnvelope | SyncChangesetBatchEnvelope diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 5d6ac9f76..aabf79b17 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -21,7 +21,13 @@ struct ContentView: View { } var body: some View { - rootTabs + Group { + if syncService.shouldShowProjectHome { + ProjectHomeView() + } else { + rootTabs + } + } .tint(adeAccent) .tabBarMinimizeBehavior(.onScrollDown) .adeScreenBackground() @@ -40,18 +46,21 @@ struct ContentView: View { } .onChange(of: syncService.requestedFilesNavigation?.id) { _, requestId in guard requestId != nil else { return } + syncService.closeProjectHome() if selectedTab != .files { selectedTab = .files } } .onChange(of: syncService.requestedLaneNavigation?.id) { _, requestId in guard requestId != nil else { return } + syncService.closeProjectHome() if selectedTab != .lanes { selectedTab = .lanes } } .onChange(of: syncService.requestedPrNavigation?.id) { _, requestId in guard requestId != nil else { return } + syncService.closeProjectHome() if selectedTab != .prs { selectedTab = .prs } @@ -109,3 +118,256 @@ struct ContentView: View { } } } + +private struct ProjectHomeView: View { + @EnvironmentObject private var syncService: SyncService + + private var connectionLabel: String { + switch syncService.connectionState { + case .connected: return "Connected" + case .syncing: return "Syncing" + case .connecting: return "Connecting" + case .error: return "Connection error" + case .disconnected: return "Connect to computer" + } + } + + private var connectionTint: Color { + switch syncService.connectionState { + case .connected: return ADEColor.success + case .syncing, .connecting: return ADEColor.warning + case .error, .disconnected: return ADEColor.danger + } + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + header + projectSection + } + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 32) + } + .scrollIndicators(.hidden) + .background(ADEColor.pageBackground.ignoresSafeArea()) + .navigationTitle("ADE") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + connectionButton + } + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(alignment: .center, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(ADEColor.raisedBackground) + .frame(width: 64, height: 48) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border, lineWidth: 1) + ) + Image("BrandMark") + .resizable() + .scaledToFit() + .frame(width: 44, height: 24) + .accessibilityHidden(true) + } + + VStack(alignment: .leading, spacing: 3) { + Text("Projects") + .font(.system(.largeTitle, design: .rounded).weight(.bold)) + .foregroundStyle(ADEColor.textPrimary) + Text(syncService.hostName ?? "Choose a desktop project to open on this phone.") + .font(.callout) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + } + } + + if let activeProject = syncService.activeProject { + Button { + syncService.selectProject(activeProject) + } label: { + Label("Return to \(activeProject.displayName)", systemImage: "arrow.forward.circle.fill") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderedProminent) + .tint(ADEColor.accent) + } + } + } + + private var connectionButton: some View { + Button { + syncService.settingsPresented = true + } label: { + ZStack(alignment: .topTrailing) { + Image(systemName: "desktopcomputer") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(connectionTint) + .frame(width: 36, height: 36) + .background(ADEColor.raisedBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border, lineWidth: 1) + ) + Circle() + .fill(connectionTint) + .frame(width: 8, height: 8) + .overlay( + Circle() + .stroke(ADEColor.pageBackground, lineWidth: 2) + ) + .offset(x: 1, y: -1) + } + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .accessibilityLabel("Computer connection: \(connectionLabel)") + .accessibilityHint("Opens computer connection settings.") + } + + private var projectSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Desktop projects") + .font(.system(.headline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Spacer() + Text("\(syncService.projects.count)") + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + + if syncService.projects.isEmpty { + emptyProjects + } else { + LazyVStack(spacing: 8) { + ForEach(syncService.projects) { project in + ProjectHomeRow( + project: project, + isActive: syncService.isActiveProject(project), + isSwitching: syncService.isSwitchingProject(project), + isDisabled: syncService.isProjectSwitching + ) { + syncService.selectProject(project) + } + } + } + } + } + } + + private var emptyProjects: some View { + VStack(alignment: .leading, spacing: 12) { + Image(systemName: "desktopcomputer") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + Text("No desktop projects cached yet.") + .font(.system(.headline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Connect to the ADE desktop app, then projects from that computer will appear here.") + .font(.callout) + .foregroundStyle(ADEColor.textSecondary) + Button { + syncService.settingsPresented = true + } label: { + Label("Connect to computer", systemImage: "desktopcomputer") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + } + .buttonStyle(.borderedProminent) + .tint(ADEColor.accent) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(ADEColor.cardBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(ADEColor.border, lineWidth: 1) + ) + } +} + +private struct ProjectHomeRow: View { + let project: MobileProjectSummary + let isActive: Bool + let isSwitching: Bool + let isDisabled: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(alignment: .center, spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(isActive ? ADEColor.accent.opacity(0.16) : ADEColor.recessedBackground) + .frame(width: 40, height: 40) + Image(systemName: "folder") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(isActive ? ADEColor.accent : ADEColor.textSecondary) + } + + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(project.displayName) + .font(.system(.headline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + if isActive { + Text("Selected") + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.accent.opacity(0.12), in: Capsule()) + } + } + + if let rootPath = project.rootPath, !rootPath.isEmpty { + Text(rootPath) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + + HStack(spacing: 8) { + Label("\(project.laneCount) lane\(project.laneCount == 1 ? "" : "s")", systemImage: "square.stack.3d.up") + if project.isCached { + Label(project.isAvailable ? "Cached" : "Unavailable", systemImage: project.isAvailable ? "checkmark.circle" : "exclamationmark.triangle") + } + } + .font(.system(.caption2, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + } + + Spacer(minLength: 8) + + if isSwitching { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.cardBackground, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(isActive ? ADEColor.accent.opacity(0.55) : ADEColor.border, lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isDisabled) + } +} diff --git a/apps/ios/ADE/Assets.xcassets/BrandMark.imageset/Contents.json b/apps/ios/ADE/Assets.xcassets/BrandMark.imageset/Contents.json index a19a54922..5f670ca87 100644 --- a/apps/ios/ADE/Assets.xcassets/BrandMark.imageset/Contents.json +++ b/apps/ios/ADE/Assets.xcassets/BrandMark.imageset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "logo.png", "idiom" : "universal", "scale" : "1x" }, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index fad9d1fb9..fbec52ef3 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -67,6 +67,36 @@ struct HostConnectionProfile: Codable, Equatable { } } +struct MobileProjectSummary: Codable, Equatable, Identifiable { + var id: String + var displayName: String + var rootPath: String? + var defaultBaseRef: String? + var lastOpenedAt: String? + var laneCount: Int + var isAvailable: Bool + var isCached: Bool +} + +struct MobileProjectCatalogPayload: Codable, Equatable { + var projects: [MobileProjectSummary] +} + +struct MobileProjectConnectionPayload: Codable, Equatable { + var authKind: String + var token: String + var hostIdentity: SyncPairingHostIdentity + var port: Int + var addressCandidates: [SyncAddressCandidate] +} + +struct MobileProjectSwitchResultPayload: Codable, Equatable { + var ok: Bool + var message: String? + var project: MobileProjectSummary? + var connection: MobileProjectConnectionPayload? +} + struct DiscoveredSyncHost: Codable, Equatable, Identifiable { var id: String var serviceName: String diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index d4dd4214f..dbc8dd640 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -233,6 +233,7 @@ final class DatabaseService { private var cachedSiteIdBlob = Data() private var shouldCaptureLocalChanges = true private var syncTableInfoCache: [String: SyncTableInfo] = [:] + private var activeProjectIdOverride: String? private(set) var initializationError: NSError? var isReady: Bool { @@ -392,6 +393,7 @@ final class DatabaseService { } func fetchLanes(includeArchived: Bool) -> [LaneSummary] { + guard let projectId = currentProjectId() else { return [] } let sql = """ select l.id, l.name, l.description, l.lane_type, l.base_ref, l.branch_ref, l.worktree_path, l.attached_root_path, l.parent_lane_id, l.is_edit_protected, l.color, l.icon, l.tags_json, l.folder, @@ -409,11 +411,13 @@ final class DatabaseService { from lanes l left join lane_state_snapshots s on s.lane_id = l.id left join lane_state_snapshots ps on ps.lane_id = l.parent_lane_id - where (? = 1 or l.archived_at is null) + where l.project_id = ? + and (? = 1 or l.archived_at is null) order by l.created_at asc """ - let rows = query(sql, bind: { statement in - sqlite3_bind_int(statement, 1, includeArchived ? 1 : 0) + let rows = query(sql, bind: { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + sqlite3_bind_int(statement, 2, includeArchived ? 1 : 0) }) { statement in LaneRow( id: stringValue(statement, index: 0) ?? "", @@ -537,11 +541,27 @@ final class DatabaseService { try exec("begin") do { try exec("pragma defer_foreign_keys = on") - try exec("delete from lane_state_snapshots") - try exec("delete from lane_list_snapshots") + _ = try execute(""" + delete from lane_state_snapshots + where lane_id in (select id from lanes where project_id = ?) + """) { statement in + try bindText(projectId, to: statement, index: 1) + } + _ = try execute(""" + delete from lane_list_snapshots + where lane_id in (select id from lanes where project_id = ?) + """) { statement in + try bindText(projectId, to: statement, index: 1) + } if orderedLaneIds.isEmpty { - _ = try execute("update lanes set status = 'archived', archived_at = coalesce(archived_at, ?)") { statement in + _ = try execute(""" + update lanes + set status = 'archived', + archived_at = coalesce(archived_at, ?) + where project_id = ? + """) { statement in try bindText(snapshotUpdatedAt, to: statement, index: 1) + try bindText(projectId, to: statement, index: 2) } } else { try prepareTemporaryIdTable(named: "temp_hydrated_lane_ids", ids: orderedLaneIds.sorted()) @@ -554,8 +574,10 @@ final class DatabaseService { from temp_hydrated_lane_ids hydrated where hydrated.id = lanes.id ) + and project_id = ? """) { statement in try bindText(snapshotUpdatedAt, to: statement, index: 1) + try bindText(projectId, to: statement, index: 2) } } @@ -696,15 +718,18 @@ final class DatabaseService { } func fetchLaneListSnapshots(includeArchived: Bool) -> [LaneListSnapshot] { + guard let projectId = currentProjectId() else { return [] } let sql = """ select s.lane_id, s.snapshot_json, s.updated_at from lane_list_snapshots s join lanes l on l.id = s.lane_id - where (? = 1 or l.archived_at is null) + where l.project_id = ? + and (? = 1 or l.archived_at is null) order by l.created_at desc """ - return query(sql, bind: { statement in - sqlite3_bind_int(statement, 1, includeArchived ? 1 : 0) + return query(sql, bind: { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + sqlite3_bind_int(statement, 2, includeArchived ? 1 : 0) }) { statement in LaneListSnapshotRow( laneId: stringValue(statement, index: 0) ?? "", @@ -758,11 +783,16 @@ final class DatabaseService { func replaceTerminalSessions(_ sessions: [TerminalSessionSummary]) throws { guard db != nil else { return } + guard let projectId = currentProjectId() else { + throw sqliteError(SyncHydrationMessaging.waitingForProjectData) + } shouldCaptureLocalChanges = false defer { shouldCaptureLocalChanges = true } - let laneIds = Set(query("select id from lanes") { statement in + let laneIds = Set(query("select id from lanes where project_id = ?", bind: { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + }) { statement in stringValue(statement, index: 0) ?? "" }) let hydratableSessions = sessions.filter { laneIds.contains($0.laneId) } @@ -770,20 +800,44 @@ final class DatabaseService { try exec("begin") do { + try prepareTemporaryIdTable(named: "temp_project_lane_ids", ids: laneIds.sorted()) if !sessionIds.isEmpty { try prepareTemporaryIdTable(named: "temp_hydrated_session_ids", ids: sessionIds) } if hasTable(named: "session_deltas") { - try exec("delete from session_deltas") + _ = try execute("delete from session_deltas where project_id = ?") { statement in + try bindText(projectId, to: statement, index: 1) + } } if hasTable(named: "checkpoints") { if sessionIds.isEmpty { - try exec("update checkpoints set session_id = null where session_id is not null") + try exec(""" + update checkpoints + set session_id = null + where session_id in ( + select terminal_sessions.id + from terminal_sessions + where exists ( + select 1 + from temp_project_lane_ids project_lanes + where project_lanes.id = terminal_sessions.lane_id + ) + ) + """) } else { try exec(""" update checkpoints set session_id = null where session_id is not null + and session_id in ( + select terminal_sessions.id + from terminal_sessions + where exists ( + select 1 + from temp_project_lane_ids project_lanes + where project_lanes.id = terminal_sessions.lane_id + ) + ) and not exists ( select 1 from temp_hydrated_session_ids hydrated @@ -897,11 +951,23 @@ final class DatabaseService { } if sessionIds.isEmpty { - try exec("delete from terminal_sessions") + try exec(""" + delete from terminal_sessions + where exists ( + select 1 + from temp_project_lane_ids project_lanes + where project_lanes.id = terminal_sessions.lane_id + ) + """) } else { try exec(""" delete from terminal_sessions - where not exists ( + where exists ( + select 1 + from temp_project_lane_ids project_lanes + where project_lanes.id = terminal_sessions.lane_id + ) + and not exists ( select 1 from temp_hydrated_session_ids hydrated where hydrated.id = terminal_sessions.id @@ -909,12 +975,14 @@ final class DatabaseService { """) } try exec("drop table if exists temp_hydrated_session_ids") + try exec("drop table if exists temp_project_lane_ids") try exec("commit") notifyDidChange() } catch { try? exec("rollback") try? exec("drop table if exists temp_hydrated_session_ids") + try? exec("drop table if exists temp_project_lane_ids") throw error } } @@ -1032,6 +1100,21 @@ final class DatabaseService { } func listWorkspaces() -> [FilesWorkspace] { + let projectId = currentProjectId() + let projectRoot = projectId.flatMap { id in + queryString("select root_path from projects where id = ? limit 1", bind: { [self] statement in + try self.bindText(id, to: statement, index: 1) + }) + } + let activeLaneIds = Set(query("select id from lanes where project_id = ?", bind: { [self] statement in + if let projectId { + try self.bindText(projectId, to: statement, index: 1) + } else { + sqlite3_bind_null(statement, 1) + } + }) { statement in + stringValue(statement, index: 0) ?? "" + }) if tableExists("files_workspaces") { let cached = query( """ @@ -1050,8 +1133,15 @@ final class DatabaseService { mobileReadOnly: sqlite3_column_int(statement, 6) != 0 ) } - if !cached.isEmpty { - return cached + let scoped = cached.filter { workspace in + if let laneId = workspace.laneId { + return activeLaneIds.contains(laneId) + } + guard workspace.kind == "primary", let projectRoot else { return false } + return workspace.rootPath == projectRoot + } + if !scoped.isEmpty { + return scoped } } @@ -1070,13 +1160,40 @@ final class DatabaseService { func replaceFilesWorkspaces(_ workspaces: [FilesWorkspace]) throws { guard tableExists("files_workspaces") else { return } + let projectId = currentProjectId() + let projectRoot = projectId.flatMap { id in + queryString("select root_path from projects where id = ? limit 1", bind: { [self] statement in + try self.bindText(id, to: statement, index: 1) + }) + } + let activeLaneIds = Set(query("select id from lanes where project_id = ?", bind: { [self] statement in + if let projectId { + try self.bindText(projectId, to: statement, index: 1) + } else { + sqlite3_bind_null(statement, 1) + } + }) { statement in + stringValue(statement, index: 0) ?? "" + }) try exec("begin immediate") do { let incomingIds = Set(workspaces.map(\.id)) - let existingIds = query("select id from files_workspaces") { statement in - stringValue(statement, index: 0) ?? "" + let existingIds = query("select id, kind, lane_id, root_path from files_workspaces") { statement in + ( + id: stringValue(statement, index: 0) ?? "", + kind: stringValue(statement, index: 1) ?? "", + laneId: stringValue(statement, index: 2), + rootPath: stringValue(statement, index: 3) ?? "" + ) } - let staleIds = existingIds.filter { !incomingIds.contains($0) } + let scopedExistingIds = existingIds.filter { row in + if let laneId = row.laneId { + return activeLaneIds.contains(laneId) + } + guard row.kind == "primary", let projectRoot else { return false } + return row.rootPath == projectRoot + }.map(\.id) + let staleIds = scopedExistingIds.filter { !incomingIds.contains($0) } let snapshotTables = [ "file_directory_snapshots", "file_content_snapshots", @@ -1286,6 +1403,7 @@ final class DatabaseService { } func fetchSessions() -> [TerminalSessionSummary] { + guard let projectId = currentProjectId() else { return [] } let sql = """ select s.id, s.lane_id, coalesce(nullif(s.lane_name, ''), l.name, s.lane_id), s.pty_id, s.tracked, s.pinned, s.manually_named, s.goal, s.tool_type, s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, @@ -1293,11 +1411,14 @@ final class DatabaseService { s.resume_command, s.resume_metadata_json, s.chat_idle_since_at from terminal_sessions s left join lanes l on l.id = s.lane_id + where l.project_id = ? order by s.started_at desc limit 200 """ - return query(sql) { statement in + return query(sql, bind: { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + }) { statement in SessionRow( id: stringValue(statement, index: 0) ?? "", laneId: stringValue(statement, index: 1) ?? "", @@ -1439,14 +1560,18 @@ final class DatabaseService { } func fetchPullRequests() -> [PrSummary] { + guard let projectId = currentProjectId() else { return [] } let sql = """ select id, lane_id, project_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, last_synced_at, created_at, updated_at from pull_requests + where project_id = ? order by updated_at desc """ - return query(sql) { statement in + return query(sql, bind: { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + }) { statement in PrSummary( id: stringValue(statement, index: 0) ?? "", laneId: stringValue(statement, index: 1) ?? "", @@ -1476,6 +1601,7 @@ final class DatabaseService { } func fetchPullRequestListItems(forLane laneId: String?) -> [PullRequestListItem] { + guard let projectId = currentProjectId() else { return [] } let hasPrGroupContext = hasTable(named: "pr_group_members") && hasTable(named: "pr_groups") && tableHasColumn(tableName: "pr_group_members", columnName: "group_id") @@ -1558,19 +1684,22 @@ final class DatabaseService { \(prGroupSelect) \(integrationSelect) from pull_requests pr - left join lanes l on l.id = pr.lane_id + left join lanes l on l.id = pr.lane_id and l.project_id = pr.project_id \(prGroupJoins) \(integrationJoin) """ let filteredSQL: String if laneId == nil { - filteredSQL = sql + " order by pr.updated_at desc" + filteredSQL = sql + " where pr.project_id = ? order by pr.updated_at desc" } else { - filteredSQL = sql + " where pr.lane_id = ? order by pr.updated_at desc" + filteredSQL = sql + " where pr.project_id = ? and pr.lane_id = ? order by pr.updated_at desc" } - let bindFn: ((OpaquePointer) throws -> Void)? = laneId.map { id in - { statement in try self.bindText(id, to: statement, index: 1) } + let bindFn: (OpaquePointer) throws -> Void = { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + if let laneId { + try self.bindText(laneId, to: statement, index: 2) + } } return query(filteredSQL, bind: bindFn) { statement in @@ -1648,6 +1777,7 @@ final class DatabaseService { } func fetchPullRequestGroupMembers(groupId: String) -> [PrGroupMemberSummary] { + guard let projectId = currentProjectId() else { return [] } let sql = """ select gm.group_id, g.group_type, @@ -1666,13 +1796,17 @@ final class DatabaseService { from pr_group_members gm join pr_groups g on g.id = gm.group_id join pull_requests pr on pr.id = gm.pr_id - left join lanes l on l.id = pr.lane_id + left join lanes l on l.id = pr.lane_id and l.project_id = pr.project_id where gm.group_id = ? + and g.project_id = ? + and pr.project_id = ? order by gm.position asc, pr.updated_at desc """ return query(sql, bind: { [self] statement in try self.bindText(groupId, to: statement, index: 1) + try self.bindText(projectId, to: statement, index: 2) + try self.bindText(projectId, to: statement, index: 3) }, map: { statement in PrGroupMemberSummary( groupId: stringValue(statement, index: 0) ?? "", @@ -1694,6 +1828,7 @@ final class DatabaseService { } func fetchIntegrationProposals() -> [IntegrationProposal] { + guard let projectId = currentProjectId() else { return [] } let sql = """ select id, source_lane_ids_json, @@ -1720,10 +1855,13 @@ final class DatabaseService { cleanup_completed_at, resolution_state_json from integration_proposals + where project_id = ? order by created_at desc """ - return query(sql) { statement in + return query(sql, bind: { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + }, map: { statement in IntegrationProposalRow( proposalId: stringValue(statement, index: 0) ?? "", sourceLaneIdsJson: stringValue(statement, index: 1) ?? "[]", @@ -1750,7 +1888,7 @@ final class DatabaseService { cleanupCompletedAt: stringValue(statement, index: 22), resolutionStateJson: stringValue(statement, index: 23) ) - }.map { row in + }).map { row in IntegrationProposal( proposalId: row.proposalId, sourceLaneIds: decodeJson(row.sourceLaneIdsJson, as: [String].self) ?? [], @@ -1856,14 +1994,18 @@ final class DatabaseService { } func fetchPullRequestSnapshot(prId: String) -> PullRequestSnapshot? { + guard let projectId = currentProjectId() else { return nil } let sql = """ - select detail_json, status_json, checks_json, reviews_json, comments_json, files_json, commits_json - from pull_request_snapshots - where pr_id = ? + select s.detail_json, s.status_json, s.checks_json, s.reviews_json, s.comments_json, s.files_json, s.commits_json + from pull_request_snapshots s + join pull_requests pr on pr.id = s.pr_id + where s.pr_id = ? + and pr.project_id = ? limit 1 """ guard let row = querySingle(sql, bind: { [self] statement in try self.bindText(prId, to: statement, index: 1) + try self.bindText(projectId, to: statement, index: 2) }, map: { statement in PullRequestSnapshotRow( detailJson: stringValue(statement, index: 0), @@ -1911,6 +2053,7 @@ final class DatabaseService { try ensureHydrationProjectionColumns() try ensureSyncMetadataTables() try ensureCrrTables() + try repairPullRequestProjectionIntegrity() let desiredSiteId = localSiteId() cachedSiteIdHex = desiredSiteId @@ -1928,6 +2071,33 @@ final class DatabaseService { localDbVersion = readMaxDbVersion() } + private func repairPullRequestProjectionIntegrity() throws { + guard hasTable(named: "pull_requests") else { return } + + let previousCaptureState = shouldCaptureLocalChanges + shouldCaptureLocalChanges = false + defer { shouldCaptureLocalChanges = previousCaptureState } + + for tableName in [ + "pull_request_snapshots", + "pull_request_ai_summaries", + "pr_group_members", + "pr_issue_inventory", + "pr_pipeline_settings", + "pr_convergence_state", + ] where hasTable(named: tableName) && tableHasColumn(tableName: tableName, columnName: "pr_id") { + try exec(""" + delete from \(tableName) + where pr_id is not null + and not exists ( + select 1 + from pull_requests + where pull_requests.id = \(tableName).pr_id + ) + """) + } + } + private func ensureHydrationProjectionColumns() throws { try ensureColumn( tableName: "lanes", @@ -2532,8 +2702,64 @@ final class DatabaseService { } } + func setActiveProjectId(_ projectId: String?) { + activeProjectIdOverride = projectId + } + + func hasProject(id: String) -> Bool { + guard hasTable(named: "projects") else { return false } + return querySingle( + "select 1 from projects where id = ? limit 1", + bind: { [self] statement in + try self.bindText(id, to: statement, index: 1) + }, + map: { _ in true } + ) ?? false + } + + func listMobileProjects() -> [MobileProjectSummary] { + guard hasTable(named: "projects") else { return [] } + + return query(""" + select + p.id, + p.display_name, + p.root_path, + p.default_base_ref, + p.last_opened_at, + coalesce(( + select count(*) + from lanes l + where l.project_id = p.id + and l.archived_at is null + ), 0) as lane_count + from projects p + order by p.last_opened_at desc, p.display_name collate nocase asc + """) { statement in + let id = stringValue(statement, index: 0) ?? "" + let rootPath = stringValue(statement, index: 2) + let fallbackName = rootPath? + .split(separator: "/") + .last + .map(String.init) + return MobileProjectSummary( + id: id, + displayName: stringValue(statement, index: 1) ?? fallbackName ?? "Project", + rootPath: rootPath, + defaultBaseRef: stringValue(statement, index: 3), + lastOpenedAt: stringValue(statement, index: 4), + laneCount: Int(sqlite3_column_int64(statement, 5)), + isAvailable: true, + isCached: true + ) + } + } + func currentProjectId() -> String? { - queryString("select id from projects order by created_at asc limit 1") + if let activeProjectIdOverride { + return activeProjectIdOverride + } + return queryString("select id from projects order by last_opened_at desc, created_at desc limit 1") } private func hasTable(named tableName: String) -> Bool { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index bc6a9db10..134594687 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -416,6 +416,10 @@ final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @Published private(set) var hostName: String? @Published private(set) var activeHostProfile: HostConnectionProfile? + @Published private(set) var projects: [MobileProjectSummary] = [] + @Published private(set) var activeProjectId: String? + @Published private(set) var activeProjectRootPath: String? + @Published private(set) var projectSwitchInFlightRootPath: String? @Published private(set) var discoveredHosts: [DiscoveredSyncHost] = [] @Published private(set) var domainStatuses: [SyncDomain: SyncDomainStatus] = Dictionary( uniqueKeysWithValues: SyncDomain.allCases.map { ($0, .disconnected) } @@ -429,6 +433,7 @@ final class SyncService: ObservableObject { @Published private(set) var pendingOperationCount = 0 @Published private(set) var localStateRevision = 0 @Published var settingsPresented = false + @Published var projectHomePresented = false @Published var attentionDrawerPresented = false @Published var requestedFilesNavigation: FilesNavigationRequest? @Published var requestedLaneNavigation: LaneNavigationRequest? @@ -442,6 +447,8 @@ final class SyncService: ObservableObject { private let profileKey = "ade.sync.hostProfile" private let legacyDeviceIdKey = "ade.sync.deviceId" private let autoReconnectPausedKey = "ade.sync.autoReconnectPausedByUser" + private let activeProjectIdKey = "ade.sync.activeProjectId" + private let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" private let pendingOperationsKey = "ade.sync.pendingOperations" private let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" private let keychain = KeychainService() @@ -495,7 +502,11 @@ final class SyncService: ObservableObject { private var preferTailnetReconnectUntil: Date? private(set) var deviceId: String private var remoteCommandDescriptors: [SyncRemoteCommandDescriptor] = [] + private var remoteProjectCatalog: [MobileProjectSummary] = [] + private var supportsProjectCatalog = false private var supportsChatStreaming = false + private var projectSelectionTask: Task? + private var projectSelectionGeneration: UInt64 = 0 /// Process-wide singleton populated by the first `init` and consumed by /// `AppDelegate`, Live Activity intents, and the `@EnvironmentObject` @@ -541,6 +552,427 @@ final class SyncService: ObservableObject { database.hasHydratedControllerData() } + var shouldShowProjectHome: Bool { + projectHomePresented || activeProjectId == nil + } + + var activeProject: MobileProjectSummary? { + guard activeProjectId != nil else { return nil } + return projects.first { isActiveProject($0) } + } + + func isActiveProject(_ project: MobileProjectSummary) -> Bool { + if let activeProjectId, project.id == activeProjectId { + return true + } + guard let activeProjectRootPath, + let projectRoot = normalizedProjectRoot(project.rootPath) + else { return false } + return projectRoot == activeProjectRootPath + } + + var isProjectSwitching: Bool { + projectSwitchInFlightRootPath != nil + } + + func isSwitchingProject(_ project: MobileProjectSummary) -> Bool { + guard let switchingRoot = projectSwitchInFlightRootPath else { return false } + return normalizedProjectRoot(project.rootPath) == switchingRoot + } + + func showProjectHome() { + refreshProjectCatalog() + projectHomePresented = true + if supportsProjectCatalog, canSendLiveRequests() { + Task { @MainActor [weak self] in + await self?.refreshRemoteProjectCatalog() + } + } + } + + func closeProjectHome() { + guard activeProjectId != nil else { return } + projectHomePresented = false + } + + func selectProject(_ project: MobileProjectSummary) { + let selectionGeneration = beginProjectSelection() + + if isActiveProject(project) { + projectHomePresented = false + return + } + + if supportsProjectCatalog, + canSendLiveRequests(), + let rootPath = project.rootPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !rootPath.isEmpty { + let normalizedSwitchRoot = normalizedProjectRoot(rootPath) ?? rootPath + projectSwitchInFlightRootPath = normalizedSwitchRoot + projectSelectionTask = Task { @MainActor [weak self] in + guard let self else { return } + do { + try await self.switchToDesktopProject(project, rootPath: rootPath, selectionGeneration: selectionGeneration) + } catch { + guard self.isCurrentProjectSelection(selectionGeneration) else { return } + self.lastError = SyncUserFacingError.message(for: error) + self.setDomainStatus(SyncDomain.allCases, phase: .failed, error: self.lastError) + } + guard self.isCurrentProjectSelection(selectionGeneration) else { return } + self.projectSwitchInFlightRootPath = nil + self.projectSelectionTask = nil + } + return + } + + guard project.isCached || database.hasProject(id: project.id) else { + lastError = "That project has not been cached on this phone yet. Connect to the ADE desktop app before opening it." + setDomainStatus(SyncDomain.allCases, phase: .failed, error: lastError) + return + } + + guard connectionState != .connected && connectionState != .syncing else { + lastError = "This computer connection does not support project switching. Reconnect to a current ADE desktop app before opening another project." + setDomainStatus(SyncDomain.allCases, phase: .failed, error: lastError) + return + } + + setActiveProjectId(project.id, rootPath: project.rootPath) + projectHomePresented = false + localStateRevision += 1 + refreshActiveSessionsAndSnapshot() + scheduleWorkspaceSnapshotWrite() + if connectionState == .connected || connectionState == .syncing { + startInitialHydrationTask(for: connectionGeneration) + } + } + + func refreshProjectCatalog(preferRemoteSelection: Bool = false) { + let cachedProjects = database.listMobileProjects() + var mergedById = Dictionary(uniqueKeysWithValues: deduplicatedRemoteProjectCatalog().map { ($0.id, $0) }) + for cachedProject in cachedProjects { + if var existing = mergedById[cachedProject.id] { + existing.displayName = cachedProject.displayName + existing.rootPath = cachedProject.rootPath ?? existing.rootPath + existing.defaultBaseRef = cachedProject.defaultBaseRef ?? existing.defaultBaseRef + existing.lastOpenedAt = cachedProject.lastOpenedAt ?? existing.lastOpenedAt + existing.laneCount = cachedProject.laneCount + existing.isCached = true + existing.isAvailable = existing.isAvailable || cachedProject.isAvailable + mergedById[cachedProject.id] = existing + } else if let match = mergedById.first(where: { entry in + let remote = entry.value + guard let left = remote.rootPath, let right = cachedProject.rootPath else { return false } + return normalizedProjectRoot(left) == normalizedProjectRoot(right) + }) { + var existing = match.value + mergedById.removeValue(forKey: match.key) + existing.id = cachedProject.id + existing.displayName = cachedProject.displayName + existing.defaultBaseRef = cachedProject.defaultBaseRef ?? existing.defaultBaseRef + existing.lastOpenedAt = cachedProject.lastOpenedAt ?? existing.lastOpenedAt + existing.laneCount = cachedProject.laneCount + existing.isCached = true + existing.isAvailable = existing.isAvailable || cachedProject.isAvailable + mergedById[cachedProject.id] = existing + } else { + mergedById[cachedProject.id] = cachedProject + } + } + if let activeProjectId, + mergedById[activeProjectId] == nil, + let activeProjectRootPath, + let match = mergedById.first(where: { entry in + normalizedProjectRoot(entry.value.rootPath) == activeProjectRootPath + }) { + if match.value.isCached { + setActiveProjectId(match.value.id, rootPath: match.value.rootPath) + } else { + var existing = match.value + mergedById.removeValue(forKey: match.key) + existing.id = activeProjectId + mergedById[activeProjectId] = existing + } + } + projects = mergedById.values.sorted { left, right in + if isActiveProject(left) { return true } + if isActiveProject(right) { return false } + return (left.lastOpenedAt ?? "") > (right.lastOpenedAt ?? "") + } + if preferRemoteSelection { + preferActiveProjectFromRemoteCatalogIfNeeded() + } + normalizeActiveProjectSelection(allowSingleProjectFallback: false) + } + + private func preferActiveProjectFromRemoteCatalogIfNeeded() { + let remoteProjects = deduplicatedRemoteProjectCatalog() + guard !remoteProjects.isEmpty else { return } + if let activeProjectId, + remoteProjects.contains(where: { $0.id == activeProjectId }) { + return + } + if let activeProjectRootPath, + let matchingProject = remoteProjects.first(where: { normalizedProjectRoot($0.rootPath) == activeProjectRootPath }) { + setActiveProjectId(matchingProject.id, rootPath: matchingProject.rootPath) + return + } + let preferred = remoteProjects.sorted { left, right in + if left.isAvailable != right.isAvailable { return left.isAvailable } + return (left.lastOpenedAt ?? "") > (right.lastOpenedAt ?? "") + }.first + if let preferred { + setActiveProjectId(preferred.id, rootPath: preferred.rootPath) + } + } + + private func deduplicatedRemoteProjectCatalog() -> [MobileProjectSummary] { + var byId: [String: MobileProjectSummary] = [:] + var idByRoot: [String: String] = [:] + + for project in remoteProjectCatalog { + let rootKey = normalizedProjectRoot(project.rootPath) + if let rootKey, let existingId = idByRoot[rootKey], let existing = byId[existingId] { + if shouldPreferProject(project, over: existing) { + byId.removeValue(forKey: existingId) + byId[project.id] = project + idByRoot[rootKey] = project.id + } + continue + } + + if let existing = byId[project.id] { + byId[project.id] = shouldPreferProject(project, over: existing) ? project : existing + } else { + byId[project.id] = project + } + + if let rootKey { + idByRoot[rootKey] = byId[project.id]?.id ?? project.id + } + } + + return Array(byId.values) + } + + private func shouldPreferProject(_ candidate: MobileProjectSummary, over existing: MobileProjectSummary) -> Bool { + if candidate.isAvailable != existing.isAvailable { + return candidate.isAvailable + } + if candidate.isCached != existing.isCached { + return candidate.isCached + } + return (candidate.lastOpenedAt ?? "") > (existing.lastOpenedAt ?? "") + } + + private func applyRemoteProjectCatalog(_ catalog: MobileProjectCatalogPayload) { + remoteProjectCatalog = catalog.projects + refreshProjectCatalog(preferRemoteSelection: true) + } + + private func refreshRemoteProjectCatalog() async { + guard supportsProjectCatalog, canSendLiveRequests() else { return } + let requestId = makeRequestId() + do { + let raw = try await awaitResponse( + requestId: requestId, + disconnectOnTimeout: false, + timeoutMessage: "Timed out waiting for the desktop project list." + ) { + self.sendEnvelope(type: "project_catalog_request", requestId: requestId, payload: [:]) + } + let catalog = try decode(raw, as: MobileProjectCatalogPayload.self) + applyRemoteProjectCatalog(catalog) + } catch { + syncConnectLog.info("project catalog refresh failed error=\(String(describing: error), privacy: .public)") + } + } + + private func switchToDesktopProject( + _ project: MobileProjectSummary, + rootPath: String, + selectionGeneration: UInt64 + ) async throws { + let requestId = makeRequestId() + let raw = try await awaitResponse(requestId: requestId) { + self.sendEnvelope(type: "project_switch_request", requestId: requestId, payload: [ + "projectId": project.id, + "rootPath": rootPath, + ]) + } + let result = try decode(raw, as: MobileProjectSwitchResultPayload.self) + guard result.ok, let connection = result.connection else { + throw NSError(domain: "ADE", code: 24, userInfo: [ + NSLocalizedDescriptionKey: result.message ?? "The desktop could not open that project for phone sync." + ]) + } + guard isCurrentProjectSelection(selectionGeneration) else { + throw CancellationError() + } + + let targetProject = result.project ?? project + let addressCandidates = deduplicatedAddresses( + connection.addressCandidates.map(\.host) + + (currentAddress.map { [$0] } ?? []) + + (activeHostProfile?.savedAddressCandidates ?? []) + ) + guard !addressCandidates.isEmpty else { + throw NSError(domain: "ADE", code: 25, userInfo: [ + NSLocalizedDescriptionKey: "The desktop did not provide an address for that project." + ]) + } + + let profile = HostConnectionProfile( + hostIdentity: connection.hostIdentity.deviceId, + hostName: connection.hostIdentity.name, + port: connection.port, + authKind: connection.authKind, + pairedDeviceId: nil, + lastRemoteDbVersion: 0, + lastHostDeviceId: connection.hostIdentity.deviceId, + lastSuccessfulAddress: addressCandidates.first, + savedAddressCandidates: addressCandidates, + discoveredLanAddresses: addressCandidates.filter { host in + guard !host.contains(":") else { return false } + guard host != "127.0.0.1" else { return false } + return !syncIsTailscaleIPv4Address(host) + }, + tailscaleAddress: addressCandidates.first(where: syncIsTailscaleIPv4Address) + ) + + let previousActiveProjectId = activeProjectId + let previousActiveProjectRootPath = activeProjectRootPath + let previousProfile = loadProfile() + let previousToken = keychain.loadToken() + let previousLatestRemoteDbVersion = latestRemoteDbVersion + let previousRemoteProjectCatalog = remoteProjectCatalog + remoteProjectCatalog.removeAll { existing in + existing.id == targetProject.id + || (normalizedProjectRoot(existing.rootPath) != nil + && normalizedProjectRoot(existing.rootPath) == normalizedProjectRoot(targetProject.rootPath)) + } + remoteProjectCatalog.append(targetProject) + setActiveProjectId(targetProject.id, rootPath: targetProject.rootPath ?? project.rootPath) + refreshProjectCatalog() + latestRemoteDbVersion = 0 + + let connectAttemptGeneration = beginConnectAttempt() + do { + keychain.saveToken(connection.token) + saveProfile(profile) + teardownSocket(reason: "Switching desktop project.") + let connectedAddress = try await connectUsingProfile( + profile, + token: connection.token, + connectAttemptGeneration: connectAttemptGeneration, + preferLiveCandidatesOnly: false, + publishConnecting: true + ) + guard isCurrentConnectAttempt(connectAttemptGeneration), isCurrentProjectSelection(selectionGeneration) else { return } + currentAddress = connectedAddress + projectHomePresented = false + localStateRevision += 1 + refreshActiveSessionsAndSnapshot() + scheduleWorkspaceSnapshotWrite() + } catch { + guard isCurrentProjectSelection(selectionGeneration) else { + throw error + } + setActiveProjectId(previousActiveProjectId, rootPath: previousActiveProjectRootPath) + latestRemoteDbVersion = previousLatestRemoteDbVersion + remoteProjectCatalog = previousRemoteProjectCatalog + if let previousToken { + keychain.saveToken(previousToken) + } else { + keychain.clearToken() + } + saveProfile(previousProfile) + refreshProjectCatalog() + localStateRevision += 1 + refreshActiveSessionsAndSnapshot() + scheduleWorkspaceSnapshotWrite() + connectionState = .disconnected + currentAddress = nil + if previousProfile != nil, previousToken != nil { + Task { @MainActor [weak self] in + await self?.reconnectIfPossible(userInitiated: true) + } + } + throw error + } + } + + private func beginProjectSelection() -> UInt64 { + projectSelectionTask?.cancel() + projectSelectionTask = nil + projectSwitchInFlightRootPath = nil + projectSelectionGeneration &+= 1 + return projectSelectionGeneration + } + + private func isCurrentProjectSelection(_ generation: UInt64) -> Bool { + !Task.isCancelled && projectSelectionGeneration == generation + } + + private func setActiveProjectId(_ projectId: String?, rootPath: String? = nil) { + activeProjectId = projectId + activeProjectRootPath = projectId == nil + ? nil + : normalizedProjectRoot(rootPath) + ?? projectId.flatMap { id in + projects.first { $0.id == id }.flatMap { normalizedProjectRoot($0.rootPath) } + } + database.setActiveProjectId(projectId) + if let projectId { + UserDefaults.standard.set(projectId, forKey: activeProjectIdKey) + } else { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + } + if let activeProjectRootPath { + UserDefaults.standard.set(activeProjectRootPath, forKey: activeProjectRootPathKey) + } else { + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + } + + private func normalizeActiveProjectSelection(allowSingleProjectFallback: Bool) { + let projectIds = Set(projects.map(\.id)) + if let activeProjectId, projectIds.contains(activeProjectId) { + database.setActiveProjectId(activeProjectId) + return + } + + if let activeProjectId, + let activeProjectRootPath, + let matchingProject = projects.first(where: { normalizedProjectRoot($0.rootPath) == activeProjectRootPath }) { + if matchingProject.isCached || database.hasProject(id: matchingProject.id) { + setActiveProjectId(matchingProject.id, rootPath: matchingProject.rootPath) + } else { + database.setActiveProjectId(activeProjectId) + } + return + } + + if activeProjectId != nil { + setActiveProjectId(nil) + } + + if allowSingleProjectFallback, projects.count == 1, let onlyProject = projects.first { + setActiveProjectId(onlyProject.id, rootPath: onlyProject.rootPath) + projectHomePresented = false + } + } + + private func normalizedProjectRoot(_ rootPath: String?) -> String? { + guard var root = rootPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !root.isEmpty + else { return nil } + while root.count > 1, root.hasSuffix("/") { + root.removeLast() + } + return root + } + private let queueableFileActions: Set = [ "writeText", "createFile", @@ -581,6 +1013,11 @@ final class SyncService: ObservableObject { keychain.saveDeviceId(fresh) deviceId = fresh } + activeProjectId = UserDefaults.standard.string(forKey: activeProjectIdKey) + activeProjectRootPath = normalizedProjectRoot(UserDefaults.standard.string(forKey: activeProjectRootPathKey)) + database.setActiveProjectId(activeProjectId) + projects = database.listMobileProjects() + normalizeActiveProjectSelection(allowSingleProjectFallback: true) pendingOperationCount = loadPendingOperations().count outboundLocalDbVersion = database.currentDbVersion() activeHostProfile = loadProfile() @@ -647,6 +1084,7 @@ final class SyncService: ObservableObject { databaseRevisionDebounceTask?.cancel() relayTask?.cancel() hydrationTask?.cancel() + projectSelectionTask?.cancel() reconnectTask?.cancel() networkPathReconnectTask?.cancel() lanePresenceHeartbeatTask?.cancel() @@ -669,6 +1107,7 @@ final class SyncService: ObservableObject { guard let self else { return } try? await Task.sleep(nanoseconds: 280_000_000) guard !Task.isCancelled else { return } + self.refreshProjectCatalog() localStateRevision += 1 self.refreshActiveSessionsAndSnapshot() } @@ -1104,6 +1543,8 @@ final class SyncService: ObservableObject { func forgetHost() { disconnect(clearCredentials: true) + remoteProjectCatalog = [] + refreshProjectCatalog() lastError = nil setDomainStatus(SyncDomain.allCases, phase: .disconnected) settingsPresented = true @@ -2559,6 +3000,8 @@ final class SyncService: ObservableObject { } else { UserDefaults.standard.removeObject(forKey: profileKey) UserDefaults.standard.removeObject(forKey: legacyDraftKey) + activeHostProfile = nil + hostName = nil } } @@ -3253,8 +3696,31 @@ final class SyncService: ObservableObject { connectAttemptGeneration: connectAttemptGeneration ) await restoreTrackedOpenLanesAfterReconnect() + await refreshRemoteProjectCatalog() + } + + #if DEBUG + func seedRemoteProjectCatalogForTesting(_ catalog: [MobileProjectSummary]) { + remoteProjectCatalog = catalog + refreshProjectCatalog() } + func applyHelloPayloadForTesting( + _ payload: [String: Any], + expectedHostIdentity: String? = nil + ) throws { + try applyHelloPayload( + payload, + connectedHost: "127.0.0.1", + port: 8787, + authKind: "paired", + pairedDeviceId: nil, + expectedHostIdentity: expectedHostIdentity, + connectAttemptGeneration: connectAttemptGeneration + ) + } + #endif + private func applyHelloPayload( _ payload: [String: Any], connectedHost: String, @@ -3270,6 +3736,17 @@ final class SyncService: ObservableObject { let brain = payload["brain"] as? [String: Any] let remoteHostIdentity = brain?["deviceId"] as? String let remoteHostName = brain?["deviceName"] as? String + if let expectedHostIdentity, let remoteHostIdentity, expectedHostIdentity != remoteHostIdentity { + forgetHost() + remoteProjectCatalog = [] + refreshProjectCatalog() + throw NSError( + domain: "ADE", + code: 20, + userInfo: [NSLocalizedDescriptionKey: "The saved pairing belongs to a different ADE host. Pair again with the current host."] + ) + } + let features = payload["features"] as? [String: Any] supportsChatStreaming = { if let chatStreaming = features?["chatStreaming"] as? [String: Any], @@ -3288,6 +3765,24 @@ final class SyncService: ObservableObject { } return false }() + supportsProjectCatalog = { + if let projectCatalog = features?["projectCatalog"] as? [String: Any], + let enabled = projectCatalog["enabled"] as? Bool { + return enabled + } + if let value = features?["projectCatalog"] as? Bool { + return value + } + if let projectCatalog = features?["project_catalog"] as? [String: Any], + let enabled = projectCatalog["enabled"] as? Bool { + return enabled + } + if let value = features?["project_catalog"] as? Bool { + return value + } + return false + }() + remoteProjectCatalog = [] let commandDescriptors: [SyncRemoteCommandDescriptor] = { guard let commandRouting = features?["commandRouting"], @@ -3297,14 +3792,12 @@ final class SyncService: ObservableObject { } return (try? decode(actions, as: [SyncRemoteCommandDescriptor].self)) ?? [] }() - - if let expectedHostIdentity, let remoteHostIdentity, expectedHostIdentity != remoteHostIdentity { - forgetHost() - throw NSError( - domain: "ADE", - code: 20, - userInfo: [NSLocalizedDescriptionKey: "The saved pairing belongs to a different ADE host. Pair again with the current host."] - ) + if supportsProjectCatalog, + let projects = payload["projects"], + let catalog = try? decode(["projects": projects], as: MobileProjectCatalogPayload.self) { + applyRemoteProjectCatalog(catalog) + } else { + refreshProjectCatalog() } reconnectState.reset() @@ -3454,6 +3947,12 @@ final class SyncService: ObservableObject { case "hello_ok": reconnectState.reset() resolve(requestId: requestId, result: .success(payload)) + case "project_catalog": + let catalog = try decode(payload, as: MobileProjectCatalogPayload.self) + applyRemoteProjectCatalog(catalog) + resolve(requestId: requestId, result: .success(payload)) + case "project_switch_result": + resolve(requestId: requestId, result: .success(payload)) case "hello_error": let code = ((payload as? [String: Any])?["code"] as? String) ?? "auth_failed" let message = ((payload as? [String: Any])?["message"] as? String) ?? "Authentication failed." @@ -4011,11 +4510,21 @@ final class SyncService: ObservableObject { connectionState == .connected || connectionState == .syncing else { return } + if activeProjectId == nil { + refreshProjectCatalog() + } + setDomainStatus(SyncDomain.allCases, phase: .syncingInitialData) do { try await InitialHydrationGate.waitForProjectRow( - currentProjectId: { self.database.currentProjectId() }, + currentProjectId: { + guard let activeProjectId = self.activeProjectId else { + let cachedProjects = self.database.listMobileProjects() + return cachedProjects.count == 1 ? cachedProjects[0].id : nil + } + return self.database.hasProject(id: activeProjectId) ? activeProjectId : nil + }, shouldContinue: { self.isCurrentConnectionGeneration(connectionGeneration) } ) } catch is CancellationError { @@ -4033,6 +4542,16 @@ final class SyncService: ObservableObject { } guard isCurrentConnectionGeneration(connectionGeneration) else { return } + if activeProjectId == nil { + let cachedProjects = database.listMobileProjects() + if cachedProjects.count == 1, let onlyProject = cachedProjects.first { + setActiveProjectId(onlyProject.id, rootPath: onlyProject.rootPath) + } else { + refreshProjectCatalog() + return + } + } + refreshProjectCatalog() do { try await refreshLaneSnapshots() } catch { diff --git a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift index 9714b71a8..9b6068b4d 100644 --- a/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift +++ b/apps/ios/ADE/Views/AttentionDrawer/AttentionDrawerButton.swift @@ -1,13 +1,13 @@ import SwiftUI -/// Bell affordance rendered next to `ADEConnectionDot` on every root screen. +/// Bell affordance rendered next to the root toolbar connection and project controls. /// /// Tapping flips `SyncService.attentionDrawerPresented` to `true`, which /// surfaces `AttentionDrawerSheet` (mounted once on the root `ContentView`). /// -/// Visual spec mirrors the existing `ADEConnectionDot` circle: 30pt tinted -/// disc + 1pt stroke + shadow. A red 16pt badge overlays the top-right -/// corner when `unreadCount > 0` (count-capped at `9+`). +/// Visual spec mirrors the root icon buttons: 30pt tinted disc + 1pt stroke + +/// shadow. A red 16pt badge overlays the top-right corner when +/// `unreadCount > 0` (count-capped at `9+`). @available(iOS 17.0, *) struct AttentionDrawerButton: View { @EnvironmentObject private var syncService: SyncService @@ -20,44 +20,49 @@ struct AttentionDrawerButton: View { private var hasUnread: Bool { drawer.unreadCount > 0 } var body: some View { - ZStack { - Circle() - .fill(tint.opacity(0.14)) - .frame(width: 30, height: 30) - .overlay( + Button(action: openDrawer) { + Label { + Text("Attention") + } icon: { + ZStack { Circle() - .stroke(tint.opacity(0.55), lineWidth: 1) - ) - .shadow( - color: tint.opacity(hasUnread ? 0.24 : 0.12), - radius: hasUnread ? 2 : 1 - ) + .fill(tint.opacity(0.14)) + .frame(width: 30, height: 30) + .overlay( + Circle() + .stroke(tint.opacity(0.55), lineWidth: 1) + ) + .shadow( + color: tint.opacity(hasUnread ? 0.24 : 0.12), + radius: hasUnread ? 2 : 1 + ) - Image(systemName: "bell.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) + Image(systemName: "bell.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) - if let label = drawer.badgeLabel { - badge(label: label) - .offset(x: 11, y: -11) - .transition(.scale.combined(with: .opacity)) + if let label = drawer.badgeLabel { + badge(label: label) + .offset(x: 11, y: -11) + .transition(.scale.combined(with: .opacity)) + } + } } + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) } - .frame(minWidth: 44, minHeight: 44) - .contentShape(Rectangle()) - .onTapGesture { - syncService.attentionDrawerPresented = true - } + .buttonStyle(.plain) .animation(.snappy(duration: 0.2), value: drawer.unreadCount) - .accessibilityAddTraits(.isButton) .accessibilityLabel("Attention items: \(drawer.unreadCount)") .accessibilityHint("Opens the attention drawer.") - .accessibilityAction { - syncService.attentionDrawerPresented = true - } .accessibilityShowsLargeContentViewer() } + private func openDrawer() { + syncService.attentionDrawerPresented = true + } + private func badge(label: String) -> some View { Text(label) .font(.system(size: 9, weight: .bold, design: .rounded).monospacedDigit()) diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 860d68d0d..46fa8cce2 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -424,7 +424,6 @@ struct ADEStatusPill: View { struct ADEConnectionDot: View { @EnvironmentObject private var syncService: SyncService - @Environment(\.accessibilityReduceMotion) private var reduceMotion private var tint: Color { switch syncService.connectionState { @@ -435,20 +434,6 @@ struct ADEConnectionDot: View { } } - private var statusText: String { - switch syncService.connectionState { - case .connected: return "Connected" - case .syncing: return "Syncing" - case .connecting: return "Connecting" - case .error: return "Error" - case .disconnected: return "Disconnected" - } - } - - private var showsHostSuffix: Bool { - syncService.connectionState == .connected - } - private var showsConnectedGlow: Bool { syncService.connectionState == .connected } @@ -494,78 +479,86 @@ struct ADEConnectionDot: View { } } - /// Subtle pill label shown only when the host is not connected. This is - /// the single source of truth for "why is the app empty?" — per-screen - /// "X failed to load" banners whose cause is disconnection are suppressed - /// in favor of this one glance-able affordance in the toolbar. - private var attachedLabel: String? { - switch syncService.connectionState { - case .connected, .syncing: - return nil - case .connecting: - return "Connecting" - case .error: - return "Offline" - case .disconnected: - return "Offline" + var body: some View { + Button(action: openSettings) { + Label { + Text("Computer connection") + } icon: { + ZStack { + Circle() + .fill(tint.opacity(0.14)) + .frame(width: 30, height: 30) + .overlay( + Circle() + .stroke(tint.opacity(0.55), lineWidth: 1) + ) + .shadow(color: tint.opacity(showsConnectedGlow ? 0.24 : 0.16), radius: showsConnectedGlow ? 2 : 1) + + Image(systemName: "desktopcomputer") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + } + } + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityLabel("Computer connection · \(accessibilityLabel)") + .accessibilityHint("Opens computer connection settings.") + .accessibilityShowsLargeContentViewer() } - var body: some View { - HStack(spacing: 6) { - ZStack { - Circle() - .fill(tint.opacity(0.14)) - .frame(width: 30, height: 30) - .overlay( - Circle() - .stroke(tint.opacity(0.55), lineWidth: 1) - ) - .shadow(color: tint.opacity(showsConnectedGlow ? 0.24 : 0.16), radius: showsConnectedGlow ? 2 : 1) + private func openSettings() { + syncService.settingsPresented = true + } +} - Image(systemName: "gearshape.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) - } +struct ADEProjectHomeButton: View { + @EnvironmentObject private var syncService: SyncService - if let attachedLabel { - Text(attachedLabel) - .font(.system(.caption2, design: .rounded).weight(.semibold)) - .foregroundStyle(tint) - .padding(.horizontal, 8) - .padding(.vertical, 3) - .background(tint.opacity(0.12), in: Capsule()) - .overlay( - Capsule() - .stroke(tint.opacity(0.35), lineWidth: 0.5) - ) - .transition(.opacity.combined(with: .scale(scale: 0.9))) - .accessibilityHidden(true) + var body: some View { + Button(action: openProjectHome) { + Label { + Text("Projects") + } icon: { + ZStack { + Circle() + .fill(ADEColor.accent.opacity(0.12)) + .frame(width: 30, height: 30) + .overlay( + Circle() + .stroke(ADEColor.accent.opacity(0.35), lineWidth: 1) + ) + + Image(systemName: "square.grid.2x2") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + } } + .labelStyle(.iconOnly) + .frame(minWidth: 44, minHeight: 44) + .contentShape(Rectangle()) } - .animation(ADEMotion.emphasis(reduceMotion: reduceMotion), value: attachedLabel) - .frame(minWidth: 44, minHeight: 44, alignment: .leading) - .contentShape(Rectangle()) - .onTapGesture { - syncService.settingsPresented = true - } - .accessibilityAddTraits(.isButton) - .accessibilityLabel("Settings · \(accessibilityLabel)") - .accessibilityHint("Opens settings to pair or reconnect.") - .accessibilityAction { - syncService.settingsPresented = true - } + .buttonStyle(.plain) + .accessibilityLabel("Projects") + .accessibilityHint("Opens the ADE project menu.") .accessibilityShowsLargeContentViewer() } + + private func openProjectHome() { + syncService.showProjectHome() + } } -/// Toolbar leading cluster shown on every root screen: settings stays -/// leftmost, with attention immediately after it as a separate control. +/// Toolbar leading cluster shown on every root screen: computer connection stays +/// leftmost, followed by project switching and attention as separate controls. @available(iOS 17.0, *) struct ADERootToolbarLeading: View { var body: some View { - HStack(spacing: 12) { + HStack(spacing: 10) { ADEConnectionDot() + ADEProjectHomeButton() AttentionDrawerButton() } .fixedSize(horizontal: true, vertical: false) @@ -583,6 +576,11 @@ struct ADERootToolbarLeadingItems: ToolbarContent { } .sharedBackgroundVisibility(.hidden) + ToolbarItem(placement: .topBarLeading) { + ADEProjectHomeButton() + } + .sharedBackgroundVisibility(.hidden) + ToolbarItem(placement: .topBarLeading) { AttentionDrawerButton() } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 064a3ed44..bbcd20f59 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -232,7 +232,7 @@ struct WorkRootScreen: View { } } else { // Per-screen hydration banners are suppressed when the host is - // unreachable — the red gear dot (ADEConnectionDot) is the single + // unreachable; the root toolbar connection button is the single // source of truth for connection state. Genuine mid-sync failures // while connected still show below via `errorMessage`. if !syncService.connectionState.isHostUnreachable, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 614eef20b..ebb7652d4 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -527,6 +527,410 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseScopesPullRequestReadsByActiveProject() throws { + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try database.executeSqlForTesting(""" + create table if not exists pr_groups ( + id text primary key, + project_id text not null, + group_type text not null, + name text, + target_branch text, + created_at text not null + ); + create table if not exists pr_group_members ( + id text primary key, + group_id text not null, + pr_id text not null, + lane_id text not null, + position integer not null, + role text not null + ); + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-1', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'), + ('project-2', '/tmp/project-two', 'Project Two', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T02:00:00.000Z'); + insert into lanes ( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, + status, created_at, archived_at + ) values + ('lane-one', 'project-1', 'One', null, 'worktree', 'main', 'feature/one', '/tmp/project-one/.ade/worktrees/one', + null, 0, null, null, null, null, null, 'active', '2026-04-22T00:10:00.000Z', null), + ('lane-two', 'project-2', 'Two', null, 'worktree', 'main', 'feature/two', '/tmp/project-two/.ade/worktrees/two', + null, 0, null, null, null, null, null, 'active', '2026-04-22T00:20:00.000Z', null); + insert into pull_requests ( + id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, + title, state, base_branch, head_branch, checks_status, review_status, additions, deletions, + last_synced_at, created_at, updated_at + ) values + ('pr-one', 'project-1', 'lane-one', 'ade', 'repo', 101, 'https://github.com/ade/repo/pull/101', + null, 'Project one PR', 'open', 'main', 'feature/one', 'success', 'approved', 10, 2, + '2026-04-22T00:30:00.000Z', '2026-04-22T00:00:00.000Z', '2026-04-22T00:30:00.000Z'), + ('pr-two', 'project-2', 'lane-two', 'ade', 'repo', 202, 'https://github.com/ade/repo/pull/202', + null, 'Project two PR', 'open', 'main', 'feature/two', 'pending', 'requested', 4, 1, + '2026-04-22T00:40:00.000Z', '2026-04-22T00:00:00.000Z', '2026-04-22T00:40:00.000Z'); + insert into pull_request_snapshots(pr_id, updated_at) values + ('pr-one', '2026-04-22T00:30:00.000Z'), + ('pr-two', '2026-04-22T00:40:00.000Z'); + insert into pr_groups(id, project_id, group_type, name, target_branch, created_at) values + ('group-one', 'project-1', 'queue', 'Project one queue', 'main', '2026-04-22T00:30:00.000Z'), + ('group-two', 'project-2', 'queue', 'Project two queue', 'main', '2026-04-22T00:40:00.000Z'); + insert into pr_group_members(id, group_id, pr_id, lane_id, position, role) values + ('member-one', 'group-one', 'pr-one', 'lane-one', 0, 'source'), + ('member-two', 'group-two', 'pr-two', 'lane-two', 0, 'source'); + insert into integration_proposals( + id, project_id, source_lane_ids_json, base_branch, steps_json, pairwise_results_json, + lane_summaries_json, overall_outcome, created_at, status, linked_group_id, linked_pr_id + ) values + ('proposal-one', 'project-1', '["lane-one"]', 'main', '[]', '[]', '[]', 'pending', + '2026-04-22T00:30:00.000Z', 'proposed', 'group-one', 'pr-one'), + ('proposal-two', 'project-2', '["lane-two"]', 'main', '[]', '[]', '[]', 'pending', + '2026-04-22T00:40:00.000Z', 'proposed', 'group-two', 'pr-two'); + """) + + database.setActiveProjectId("project-1") + XCTAssertEqual(database.fetchPullRequests().map(\.id), ["pr-one"]) + XCTAssertEqual(database.fetchPullRequestListItems().map(\.id), ["pr-one"]) + XCTAssertEqual(database.fetchPullRequestListItems(forLane: "lane-one").map(\.id), ["pr-one"]) + XCTAssertEqual(database.fetchPullRequestGroupMembers(groupId: "group-one").map(\.prId), ["pr-one"]) + XCTAssertNotNil(database.fetchPullRequestSnapshot(prId: "pr-one")) + XCTAssertNil(database.fetchPullRequestSnapshot(prId: "pr-two")) + XCTAssertEqual(database.fetchIntegrationProposals().map(\.proposalId), ["proposal-one"]) + + database.setActiveProjectId("project-2") + XCTAssertEqual(database.fetchPullRequests().map(\.id), ["pr-two"]) + XCTAssertEqual(database.fetchPullRequestListItems().map(\.id), ["pr-two"]) + XCTAssertEqual(database.fetchPullRequestListItems(forLane: "lane-two").map(\.id), ["pr-two"]) + XCTAssertEqual(database.fetchPullRequestGroupMembers(groupId: "group-two").map(\.prId), ["pr-two"]) + XCTAssertEqual(database.fetchPullRequestGroupMembers(groupId: "group-one").map(\.prId), []) + XCTAssertNil(database.fetchPullRequestSnapshot(prId: "pr-one")) + XCTAssertNotNil(database.fetchPullRequestSnapshot(prId: "pr-two")) + XCTAssertEqual(database.fetchIntegrationProposals().map(\.proposalId), ["proposal-two"]) + + database.close() + } + + func testDatabaseListsMobileProjectsAndScopesCachedRuntimeByActiveProject() throws { + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-1', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'), + ('project-2', '/tmp/project-two', 'Project Two', 'develop', '2026-04-22T00:00:00.000Z', '2026-04-22T02:00:00.000Z'); + insert into lanes ( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, + status, created_at, archived_at + ) values + ('lane-one', 'project-1', 'One', null, 'worktree', 'main', 'feature/one', '/tmp/project-one/.ade/worktrees/one', + null, 0, null, null, null, null, null, 'active', '2026-04-22T00:10:00.000Z', null), + ('lane-two', 'project-2', 'Two', null, 'worktree', 'develop', 'feature/two', '/tmp/project-two/.ade/worktrees/two', + null, 0, null, null, null, null, null, 'active', '2026-04-22T00:20:00.000Z', null); + create table if not exists files_workspaces ( + id text primary key, + kind text not null, + lane_id text, + name text not null, + root_path text not null, + is_read_only_by_default integer not null default 1, + mobile_read_only integer not null default 1, + updated_at text not null + ); + """) + + let projects = database.listMobileProjects() + XCTAssertEqual(projects.map(\.id), ["project-2", "project-1"]) + XCTAssertEqual(projects.first(where: { $0.id == "project-1" })?.laneCount, 1) + XCTAssertEqual(projects.first(where: { $0.id == "project-2" })?.defaultBaseRef, "develop") + XCTAssertTrue(projects.allSatisfy(\.isCached)) + + database.setActiveProjectId("project-1") + try database.replaceTerminalSessions([ + makeTerminalSessionSummary( + id: "session-one", + laneId: "lane-one", + laneName: "One", + toolType: "codex-chat", + title: "Project one chat" + ), + ]) + try database.replaceFilesWorkspaces([ + FilesWorkspace( + id: "workspace-one", + kind: "worktree", + laneId: "lane-one", + name: "One", + rootPath: "/tmp/project-one/.ade/worktrees/one", + isReadOnlyByDefault: false, + mobileReadOnly: true + ), + ]) + + database.setActiveProjectId("project-2") + try database.replaceTerminalSessions([ + makeTerminalSessionSummary( + id: "session-two", + laneId: "lane-two", + laneName: "Two", + toolType: "claude-chat", + title: "Project two chat" + ), + ]) + try database.replaceFilesWorkspaces([ + FilesWorkspace( + id: "workspace-two", + kind: "worktree", + laneId: "lane-two", + name: "Two", + rootPath: "/tmp/project-two/.ade/worktrees/two", + isReadOnlyByDefault: false, + mobileReadOnly: true + ), + ]) + + XCTAssertEqual(database.fetchLanes(includeArchived: true).map(\.id), ["lane-two"]) + XCTAssertEqual(database.fetchSessions().map(\.id), ["session-two"]) + XCTAssertEqual(database.listWorkspaces().map(\.id), ["workspace-two"]) + + database.setActiveProjectId("project-1") + XCTAssertEqual(database.fetchLanes(includeArchived: true).map(\.id), ["lane-one"]) + XCTAssertEqual(database.fetchSessions().map(\.id), ["session-one"]) + XCTAssertEqual(database.listWorkspaces().map(\.id), ["workspace-one"]) + + database.close() + } + + @MainActor + func testSyncServiceProjectHomeUsesCachedProjectsAndLocalSelection() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-1', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'), + ('project-2', '/tmp/project-two/', 'Project Two', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T02:00:00.000Z'); + """) + + let service = SyncService(database: database) + XCTAssertTrue(service.shouldShowProjectHome) + XCTAssertEqual(service.projects.map(\.id), ["project-2", "project-1"]) + + let projectTwo = try XCTUnwrap(service.projects.first(where: { $0.id == "project-2" })) + service.selectProject(projectTwo) + + XCTAssertEqual(service.activeProjectId, "project-2") + XCTAssertEqual(service.activeProjectRootPath, "/tmp/project-two") + XCTAssertEqual(database.currentProjectId(), "project-2") + XCTAssertFalse(service.shouldShowProjectHome) + XCTAssertTrue(service.isActiveProject(projectTwo)) + + service.showProjectHome() + XCTAssertTrue(service.shouldShowProjectHome) + service.closeProjectHome() + XCTAssertFalse(service.shouldShowProjectHome) + + database.close() + } + + @MainActor + func testSyncServiceRejectsUncachedProjectSelectionWithoutCatalogSwitch() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-1', '/tmp/project-one', 'Project One', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'); + """) + + let service = SyncService(database: database) + let projectOne = try XCTUnwrap(service.projects.first(where: { $0.id == "project-1" })) + service.selectProject(projectOne) + service.showProjectHome() + + let uncachedProject = MobileProjectSummary( + id: "project-2", + displayName: "Project Two", + rootPath: "/tmp/project-two", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T02:00:00.000Z", + laneCount: 0, + isAvailable: true, + isCached: false + ) + service.selectProject(uncachedProject) + + XCTAssertEqual(service.activeProjectId, "project-1") + XCTAssertEqual(database.currentProjectId(), "project-1") + XCTAssertTrue(service.shouldShowProjectHome) + XCTAssertEqual( + service.lastError, + "That project has not been cached on this phone yet. Connect to the ADE desktop app before opening it." + ) + + database.close() + } + + @MainActor + func testSyncServiceClearsRemoteProjectCatalogWhenHelloOmitsCatalog() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + service.seedRemoteProjectCatalogForTesting([ + MobileProjectSummary( + id: "remote-only", + displayName: "Remote Only", + rootPath: "/tmp/remote-only", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T02:00:00.000Z", + laneCount: 1, + isAvailable: true, + isCached: false + ), + ]) + XCTAssertEqual(service.projects.map(\.id), ["remote-only"]) + + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-1", + "deviceName": "Mac Studio", + ], + "features": [ + "projectCatalog": false, + ], + ]) + + XCTAssertFalse(service.projects.contains { $0.id == "remote-only" }) + } + + @MainActor + func testSyncServiceRejectsMismatchedHelloBeforeApplyingProjectCatalog() throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + service.seedRemoteProjectCatalogForTesting([ + MobileProjectSummary( + id: "old-host-project", + displayName: "Old Host", + rootPath: "/tmp/old-host", + defaultBaseRef: "main", + lastOpenedAt: "2026-04-22T01:00:00.000Z", + laneCount: 1, + isAvailable: true, + isCached: false + ), + ]) + XCTAssertThrowsError( + try service.applyHelloPayloadForTesting( + [ + "brain": [ + "deviceId": "host-b", + "deviceName": "Other Mac", + ], + "features": [ + "projectCatalog": true, + ], + "projects": [[ + "id": "wrong-host-project", + "displayName": "Wrong Host", + "rootPath": "/tmp/wrong-host", + "defaultBaseRef": "main", + "lastOpenedAt": "2026-04-22T02:00:00.000Z", + "laneCount": 1, + "isAvailable": true, + "isCached": false, + ]], + ], + expectedHostIdentity: "host-a" + ) + ) + XCTAssertFalse(service.projects.contains { $0.id == "wrong-host-project" }) + XCTAssertFalse(service.projects.contains { $0.id == "old-host-project" }) + } + + @MainActor + func testSyncServicePrefersRemoteCatalogProjectOverStaleCachedSelection() throws { + let activeProjectIdKey = "ade.sync.activeProjectId" + let activeProjectRootPathKey = "ade.sync.activeProjectRootPath" + UserDefaults.standard.set("old-project", forKey: activeProjectIdKey) + UserDefaults.standard.set("/tmp/old-project", forKey: activeProjectRootPathKey) + defer { + UserDefaults.standard.removeObject(forKey: activeProjectIdKey) + UserDefaults.standard.removeObject(forKey: activeProjectRootPathKey) + } + + let database = makeControllerHydrationDatabase(baseURL: makeTemporaryDirectory()) + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('old-project', '/tmp/old-project', 'Old Project', 'main', '2026-04-22T00:00:00.000Z', '2026-04-22T01:00:00.000Z'); + """) + let service = SyncService(database: database) + XCTAssertEqual(service.activeProjectId, "old-project") + + try service.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-new", + "deviceName": "New Mac", + ], + "features": [ + "projectCatalog": true, + ], + "projects": [[ + "id": "new-project", + "displayName": "New Project", + "rootPath": "/tmp/new-project", + "defaultBaseRef": "main", + "lastOpenedAt": "2026-04-22T02:00:00.000Z", + "laneCount": 2, + "isAvailable": true, + "isCached": false, + ]], + ]) + + XCTAssertEqual(service.activeProjectId, "new-project") + XCTAssertEqual(service.activeProjectRootPath, "/tmp/new-project") + XCTAssertEqual(database.currentProjectId(), "new-project") + + database.close() + } + @MainActor func testSyncPairingQrPayloadRoundTripFromDesktopLink() throws { let payload = """ @@ -1712,7 +2116,7 @@ final class ADETests: XCTestCase { database.close() } - func testDatabaseFetchSessionsFallsBackToStoredLaneNameWhenLaneRowIsMissing() throws { + func testDatabaseFetchSessionsHidesSessionsWhenLaneRowIsMissing() throws { let baseURL = makeTemporaryDirectory() let database = makeControllerHydrationDatabase(baseURL: baseURL) XCTAssertNil(database.initializationError) @@ -1745,8 +2149,7 @@ final class ADETests: XCTestCase { try database.executeSqlForTesting("delete from lanes where id = 'lane-primary';") let sessions = database.fetchSessions() - XCTAssertEqual(sessions.count, 1) - XCTAssertEqual(sessions.first?.laneName, "Primary") + XCTAssertEqual(sessions.count, 0) database.close() } diff --git a/apps/web/index.html b/apps/web/index.html index c6c617df0..d4fc1c23a 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -35,7 +35,7 @@ diff --git a/apps/web/public/images/features/git history.png b/apps/web/public/images/features/git-history.png similarity index 100% rename from apps/web/public/images/features/git history.png rename to apps/web/public/images/features/git-history.png diff --git a/apps/web/public/mockup.html b/apps/web/public/mockup.html new file mode 100644 index 000000000..f95664b77 --- /dev/null +++ b/apps/web/public/mockup.html @@ -0,0 +1,918 @@ + + + + + + ADE — Cover mockup + + + + + + + + + +
+
+ +
+
ADE.
+
Vol. 1 · Issue 0 · Apr 2026
+ +
+ + +
+
+
Claude logo
+
Claude
+
+ + +
+
Codex logo
+
Codex
+
+ + +
+
OpenCode logo
+
OpenC.
+
+ + +
+
T3 Code logo
+
T3
+
+ + +
+
Cursor logo
+
Cursor
+
+ + +
+
Superset logo
+
Superset
+
+ + +
+
Conductor logo
+
Cond.
+
+ + +
+
Factory logo
+
Factory
+
+ + +
+
Paperclip logo
+
Paperc.
+
+ + +
+
OpenClaw logo
+
OpenClaw
+
+ + +
+
GitHub logo
+
GitHub
+
+ = +
+
+
ADE
+
+
+ + +
+
+

A Demonstration

+

+ Every AI
coding tool.
One app. +

+

+ A single native workspace for Claude, + GPT, Gemini, and every agent on + your dock. macOS, iOS, + CLI — all synced in real time. +

+ + +
+ +
+
+ +
+
+
+ + + +
+ ADE on macOS +
+
+
+ +
+
+
+ ADE on iOS +
+
+
+
+
+ ADE, on desk and in hand. Photographed April 2026 — + placeholder screens pending final iOS captures. +
+
+
+ + +
+ Turn the page + + Chapter I · The Workspace +
+
+
+ + +
+ + +
+
+
+ Chapter I + The Workspace + Page 04 +
+ +

Lanes, files and git. In one window.

+

+ The workspace stopped being a window years ago. It became a tab + sprawl, a terminal pile, a pull-request tab cemetery. ADE puts it back. +

+ +
+
+

+ A developer's screen has been quietly fracturing for a decade. + First the browser, then the terminal, then a chat window, then + another chat window, then a merge tool, a diff tool, a + pull-request dashboard, a Linear page, a second Linear page. + Each tab is a small defeat of attention. +

+

+ ADE collapses the sprawl into a single workspace built for agents + as well as humans — lanes for parallel work, files for what you + touch, git for what you've shipped, and a chat that speaks to + whatever model you pay for. Claude, GPT, Gemini, your local + 3-billion-parameter friend. All of it, one window. +

+ +
+ “The workspace stopped being a window. ADE makes it a window + again.” +
+ +

+ You can keep your subscriptions. You can bring your own keys. + Nothing you paid for gets left behind — but you stop opening it. +

+
+ +
+ ADE lanes view on macOS +
+ The lanes workspace — three parallel tasks, one git history, + one inbox of review. +
+
+
+
+
+ + diff --git a/apps/web/src/app/layout/SiteLayout.tsx b/apps/web/src/app/layout/SiteLayout.tsx index dbf7a28a8..6a550173b 100644 --- a/apps/web/src/app/layout/SiteLayout.tsx +++ b/apps/web/src/app/layout/SiteLayout.tsx @@ -1,15 +1,21 @@ import type { ReactNode } from "react"; +import { useLocation } from "react-router-dom"; import { SiteHeader } from "../../components/SiteHeader"; import { SiteFooter } from "../../components/SiteFooter"; export function SiteLayout({ children }: { children: ReactNode }) { + const { pathname } = useLocation(); + // HomePage renders its own editorial Masthead + BackCover, so skip the + // global chrome there. + const isHome = pathname === "/"; + return ( -
- +
+ {!isHome && }
{children}
- + {!isHome && }
); } diff --git a/apps/web/src/app/pages/HomePage.tsx b/apps/web/src/app/pages/HomePage.tsx index 98b47c0a6..ece8b6fb3 100644 --- a/apps/web/src/app/pages/HomePage.tsx +++ b/apps/web/src/app/pages/HomePage.tsx @@ -1,460 +1,320 @@ -import { - ArrowUpRight, - BookOpen, - Download, - Github, - GitMerge, - Layers, - MonitorCheck, - Package, - Workflow, - Zap, -} from "lucide-react"; -import { Fragment } from "react"; -import { motion, useReducedMotion } from "framer-motion"; -import { Container } from "../../components/Container"; -import { Card } from "../../components/Card"; -import { LinkButton } from "../../components/LinkButton"; -import { Reveal } from "../../components/Reveal"; -import { Page } from "../../components/Page"; -import { LINKS } from "../../lib/links"; import { useDocumentTitle } from "../../lib/useDocumentTitle"; +import { Page } from "../../components/Page"; -import { ImageAutoSlider } from "../../components/ui/ImageAutoSlider"; -import { ProductShowcase } from "../../components/ProductShowcase"; -import { ProviderOrbit } from "../../components/ProviderOrbit"; -import { MultiDeviceShowcase } from "../../components/MultiDeviceShowcase"; - -/* ────────────────────────────────────────────── - Competitor apps that ADE replaces - ────────────────────────────────────────────── */ - -// Logos: apps/web/public/images/competitors/*.png (or change paths below). -const COMPETITORS = [ - { name: "Claude Code", logo: "/images/competitors/claude-code.png" }, - { name: "Codex", logo: "/images/competitors/codex.png" }, - { name: "OpenCode", logo: "/images/competitors/opencode.png" }, - { name: "T3 Code", logo: "/images/competitors/t3-code.png" }, - { name: "Cursor", logo: "/images/competitors/cursor.png" }, - { name: "Superset", logo: "/images/competitors/superset.png" }, - { name: "Conductor", logo: "/images/competitors/conductor.png" }, - { name: "Factory", logo: "/images/competitors/factory.png" }, - { name: "Paperclip", logo: "/images/competitors/paperclip.png" }, - { name: "OpenClaw", logo: "/images/competitors/openclaw.png" }, - { name: "GitHub", logo: "/images/competitors/github.png" }, -] as const; - -const ALSO_BUILT_IN = [ - { - icon: Workflow, - label: "Missions", - detail: "Coordinated multi-step runs with visibility across phases — planning, testing, and PRs.", - docsPath: "/missions/overview", - }, - { - icon: Package, - label: "Unified memory", - detail: "Vector-indexed memory across projects and agents so work compounds instead of resetting.", - docsPath: "/cto/memory", - }, - { - icon: Zap, - label: "Automations", - detail: "Event-driven agents on git events, PR activity, or schedules — with guardrails while you are away.", - docsPath: "/automations/overview", - }, - { - icon: GitMerge, - label: "Merge conflicts", - detail: "Resolve conflicts with side-by-side diffs and a focused flow so you can land merges in one place.", - docsPath: "/tools/conflicts", - }, - { - icon: MonitorCheck, - label: "Computer use", - detail: "Screenshot-based verification of agent output when you need proof, not just prose.", - docsPath: "/computer-use/overview", - }, - { - icon: Layers, - label: "35+ ADE actions", - detail: "Built-in server for file ops, git, search, and more — desktop and headless paths.", - docsPath: "/configuration/settings", - }, -] as const; - -/* ────────────────────────────────────────────── - Quickstart copy command - ────────────────────────────────────────────── */ - -function QuickstartBlock() { - return ( -
-
-
- - 1 - - Download the latest release -
- - Download DMG from GitHub{" "} - - -
- -
-
- - 2 - - Move ADE into Applications -
-

- Drag ADE into your Applications folder before first launch so macOS can - keep it on the normal update path. -

-
- -
-
- - 3 - - Open ADE and point it at your project -
-

- No account needed. Add your own API keys for Claude, Codex, or other providers in settings. -

-
-
- ); -} +import { Masthead } from "../../components/editorial/Masthead"; +import { CompetitorEquation } from "../../components/editorial/CompetitorEquation"; +import { Lede } from "../../components/editorial/Lede"; +import { DeviceComposition } from "../../components/editorial/DeviceComposition"; +import { FadeBand } from "../../components/editorial/FadeBand"; +import { + Chapter, + ChapterBody, + Byline, +} from "../../components/editorial/Chapter"; +import { ChapterHeadline } from "../../components/editorial/ChapterHeadline"; +import { Cutout } from "../../components/editorial/Cutout"; +import { PullQuote } from "../../components/editorial/PullQuote"; +import { IPhoneFrame } from "../../components/editorial/IPhoneFrame"; +import { FeatureGrid } from "../../components/editorial/FeatureGrid"; +import { IndexPage } from "../../components/editorial/IndexPage"; +import { BackCover } from "../../components/editorial/BackCover"; -/* ────────────────────────────────────────────── - Page - ────────────────────────────────────────────── */ +const MOBILE_BYLINE_DATE = "April 2026 · iOS 17+"; export function HomePage() { useDocumentTitle("ADE — Agentic Development Environment"); - const reduceMotion = useReducedMotion(); - - const equationContainer = { - hidden: {}, - show: { - transition: { - staggerChildren: reduceMotion ? 0 : 0.07, - delayChildren: reduceMotion ? 0 : 0.1, - }, - }, - }; - const equationItem = { - hidden: reduceMotion ? {} : { opacity: 0, y: 14, scale: 0.85 }, - show: { - opacity: 1, - y: 0, - scale: 1, - transition: { duration: 0.45, ease: [0.22, 1, 0.36, 1] as const }, - }, - }; - const equalsItem = { - hidden: reduceMotion ? {} : { opacity: 0, scale: 0.6 }, - show: { - opacity: 1, - scale: 1, - transition: { duration: 0.5, delay: 0.05, ease: [0.22, 1, 0.36, 1] as const }, - }, - }; - const adeItem = { - hidden: reduceMotion ? {} : { opacity: 0, scale: 0.7, filter: "blur(8px)" }, - show: { - opacity: 1, - scale: 1, - filter: "blur(0px)", - transition: { duration: 0.7, delay: 0.15, ease: [0.22, 1, 0.36, 1] as const }, - }, - }; return ( - {/* ── HERO — Logo Equation + ADE ─────────── */} -
- {/* Background: gradient mesh + dot texture */} + {/* ═══════ DARK COVER ═══════ */} +
- - {/* Logo Equation — single row, no wrap */} - - {COMPETITORS.map((app, i) => ( - - {i > 0 && ( - - + - - )} - -
- {app.name} -
- - {app.name} - -
-
- ))} -
+ - {/* = ADE app icon — lands last with a violet pulse */} - -
- - = - - - {!reduceMotion && ( - - )} - ADE - -
-
+
+
+ +
- {/* Headline */} - -

- Every AI coding tool.{" "} - - One app. - -

-
+
+ + +
- {/* Subtitle */} - -

- ADE replaces all your AI tools with a single Agentic Development Environment. - Use your existing subscriptions, API keys, or even local models. Fully Open Source! -

-
+
+ Turn the page + + ↓ + + Chapter I · Lanes + +
+
+
- {/* CTAs */} - -
- - Download from GitHub - - - View on GitHub - - - Docs - -
-
- - Open source · Local-first · macOS · No account required -
-
+ {/* ═══════ FADE dark → cream ═══════ */} + + + {/* ═══════ CHAPTER I — LANES ═══════ */} + +
+ - {/* Hero app visual */} - -
- +
+ +
+ + Each lane is a fresh branch you can flip between in a click. + Running tests in one doesn’t block code in another. When + a lane ships, commit it and move on. + + + “Three tasks at once. One git history. Zero + collisions.” + +
- - -
+
+
+ - + {/* ═══════ CHAPTER II — WORK ═══════ */} + +
+
+ +
+ + Work is where agents execute. You see every file change, every + test run, every tool call — live. Approve the diff before + it commits, or step in and steer. + + + “The diff is yours before it commits.” + + +
+
- + +
+
- + {/* ═══════ CHAPTER III — PULL REQUESTS ═══════ */} + +
+ - {/* ── MORE CAPABILITIES ─────────────────── */} -
- - -

Also built in

-

- More capabilities that ship in the same app — no plugins or extensions required. -

-
+
+ +
+ + No tab-hopping to GitHub. Read the diff, check CI, comment, + approve, merge — all inside ADE, connected to the lane, + the branch, and the model that wrote the code. + + +
+
+
+
-
- {ALSO_BUILT_IN.map((cap, idx) => ( - - -
-
- -
-
{cap.label}
-
-
{cap.detail}
-
- Read docs -
-
-
- ))} + {/* ═══════ CHAPTER IV — CTO ═══════ */} + +
+
+ +
+ + The CTO dispatches work to the right agent, tracks what’s + in flight, pulls issues from Linear, and posts results back. + It’s the conductor that turns a pile of prompts into a + shipping team. + + + “A team of agents, on payroll, on call.” + + +
- - - {/* ── QUICKSTART ────────────────────────── */} -
- -
-
- -

- Get started in 30 seconds -

-

- Download the latest macOS release, move ADE into Applications, and open. No account required. -

-
+ +
+ - -
- -
-
+ {/* ═══════ CHAPTER V — IN YOUR POCKET ═══════ */} + +
+
+ +
+ + The desktop is where code gets written. The pocket is where + decisions get made. ADE syncs both over cr-sqlite so a lane + you started on macOS continues on iOS without a refresh. + + + “None of the eleven apps we replace have a mobile client. + We built the one that does.” + +
+
- - -

Or build from source

-

- Clone the repo and run with Vite + Electron. Also the fastest way to contribute. -

-
-
- $ git clone https://github.com/ - {LINKS.repo}.git -
-
- $ cd ADE/apps/desktop -
-
- $ npm install -
-
- $ npm run dev -
-
- -
- - Docs{" "} - - - - Repo{" "} - - -
-
-
+
+
+
+ +
- -
+
+
- {/* ── CTA ───────────────────────────────── */} -
- - - - {/* Subtle radial gradient */} -
+ {/* ═══════ CATALOG — the rest of the IDE ═══════ */} + -
-

- The last AI coding app you'll download. -

-

- Free. Open source. Your code never leaves your machine. -

+ {/* ═══════ FADE cream → dark ═══════ */} + -
- - Download from GitHub - - - Star on GitHub - -
-
- - - -
+ {/* ═══════ BACK COVER ═══════ */} + + + {/* ═══════ FADE dark → cream ═══════ */} + + + {/* ═══════ INDEX ═══════ */} + ); } diff --git a/apps/web/src/components/FeatureGallery.tsx b/apps/web/src/components/FeatureGallery.tsx deleted file mode 100644 index 436472da8..000000000 --- a/apps/web/src/components/FeatureGallery.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { AnimatePresence, motion, useInView, useReducedMotion, useScroll, useTransform } from "framer-motion"; -import { cn } from "../lib/cn"; -import { ADE_EASE_OUT, revealTransition } from "../lib/motion"; -import { Card } from "./Card"; - -export type FeatureGalleryItem = { - eyebrow: string; - title: string; - description: string; - bullets: string[]; - imageSrc: string; - imageAlt?: string; -}; - -function DesktopFeatureRow({ - feature, - index, - active, - onActivate, - onJump -}: { - feature: FeatureGalleryItem; - index: number; - active: boolean; - onActivate: (index: number) => void; - onJump: (index: number) => void; -}) { - const ref = useRef(null); - const inView = useInView(ref, { amount: 0.55, margin: "-35% 0px -55% 0px" }); - - useEffect(() => { - if (inView) onActivate(index); - }, [inView, index, onActivate]); - - return ( - - ); -} - -export function FeatureGallery({ - features, - className, - initialIndex = 0 -}: { - features: FeatureGalleryItem[]; - className?: string; - initialIndex?: number; -}) { - const reduceMotion = useReducedMotion(); - const [activeIndex, setActiveIndex] = useState(() => Math.max(0, Math.min(initialIndex, features.length - 1))); - - const sectionRef = useRef(null); - const { scrollYProgress } = useScroll({ target: sectionRef, offset: ["start end", "end start"] }); - const parallaxY = useTransform(scrollYProgress, [0, 1], [14, -14]); - - const active = features[activeIndex]; - - const onActivate = useCallback((index: number) => { - setActiveIndex((cur) => (cur === index ? cur : index)); - }, []); - - const desktopJump = useCallback( - (index: number) => { - const id = `feature-${index}`; - const el = document.getElementById(id); - if (!el) { - onActivate(index); - return; - } - el.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); - }, - [onActivate, reduceMotion] - ); - - const mobilePills = useMemo( - () => - features.map((f, idx) => ( - - )), - [activeIndex, features, onActivate] - ); - - return ( -
-
-
{mobilePills}
- -
- - - -
{active.eyebrow}
-
{active.title}
-

{active.description}

- -
    - {active.bullets.map((b) => ( -
  • - - {b} -
  • - ))} -
- -
- {active.imageAlt -
-
-
-
-
-
- -
-
- {features.map((f, idx) => ( - - - - ))} -
- -
- - -
-
- -
- - - -
- - -
-
-
- ); -} diff --git a/apps/web/src/components/MultiDeviceShowcase.tsx b/apps/web/src/components/MultiDeviceShowcase.tsx deleted file mode 100644 index 908c9d16e..000000000 --- a/apps/web/src/components/MultiDeviceShowcase.tsx +++ /dev/null @@ -1,354 +0,0 @@ -import { motion, useReducedMotion } from "framer-motion"; -import { Container } from "./Container"; - -/** - * Flagship "One app. Every device." section. - * Device screens are placeholders — swap the `src` paths below when real - * desktop/iOS/CLI screenshots are ready. - */ -export function MultiDeviceShowcase() { - const reduceMotion = useReducedMotion(); - - return ( -
- {/* Violet wire-grid floor */} -
-
- - -
-
- - Desktop · Mobile · CLI -
-

- One app.{" "} - - Every device. - -

-

- The same missions, lanes, and agents — on your Mac, in your pocket, - and in your terminal. Synced in real time. -

-
- - {/* Device stage */} -
- {/* Animated sync beams (SVG overlay, hidden on mobile) */} - - - {/* MacBook (left, 7 cols) */} - - - - - - Mission “fix-login” running - - - - {/* iPhone (right, 5 cols) */} - -
- - - - - Synced · 1s ago - - - Approved PR #284 - -
-
- - {/* Terminal (bottom, full width) */} - - - - -
- - {/* "Alternative to" badges row */} -
- Alternative to - {[ - "Cursor", - "Claude Code", - "Codex", - "Factory", - "Conductor", - "Paperclip", - ].map((name) => ( - - {name} - - ))} - - + a mobile app none of them have - -
-
-
- ); -} - -/* ────────────────────────────────────────────── - Subcomponents - ────────────────────────────────────────────── */ - -function DeviceLabel({ - label, - sub, - inline = false, -}: { - label: string; - sub: string; - inline?: boolean; -}) { - return ( -
- {label} - - {sub} -
- ); -} - -function MacBookMock({ src }: { src: string }) { - return ( -
- {/* Screen */} -
- {/* Traffic lights */} -
- - - -
- ADE desktop app -
- {/* Bottom bezel */} -
-
-
-
- ); -} - -function IPhoneMock({ src }: { src: string }) { - return ( -
- {/* Dynamic Island */} -
- ADE iOS app - {/* Violet inner glow */} -
-
- ); -} - -function TerminalMock() { - const lines = [ - { prompt: "$", text: 'ade mission start "fix login redirect bug"', cls: "text-fg" }, - { prompt: "›", text: "Planning phase · 1 worker", cls: "text-muted-fg" }, - { prompt: "›", text: "Development · 3 workers running in parallel", cls: "text-muted-fg" }, - { prompt: "✓", text: "Tests passing · PR #284 opened", cls: "text-emerald-400" }, - { prompt: "✓", text: "Synced to iPhone · 1s ago", cls: "text-accent" }, - ]; - return ( -
-
- - - - - ade-cli — zsh — 100×24 - -
-
-        {lines.map((line, i) => (
-          
-            {line.prompt}
-            {line.text}
-          
-        ))}
-      
-
- ); -} - -function FloatingChip({ - children, - className = "", - delay = 0, - reduceMotion, -}: { - children: React.ReactNode; - className?: string; - delay?: number; - reduceMotion: boolean; -}) { - return ( - - {children} - - ); -} - -function SyncBeams({ reduceMotion }: { reduceMotion: boolean }) { - return ( - - - - - - - - - {/* Mac → iPhone beam */} - - {/* Mac → Terminal beam */} - - {/* iPhone → Terminal beam */} - - - ); -} diff --git a/apps/web/src/components/ProductShowcase.tsx b/apps/web/src/components/ProductShowcase.tsx deleted file mode 100644 index 671c03742..000000000 --- a/apps/web/src/components/ProductShowcase.tsx +++ /dev/null @@ -1,302 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { motion, AnimatePresence, useReducedMotion } from "framer-motion"; -import { ArrowUpRight, X } from "lucide-react"; -import { Container } from "./Container"; -import { cn } from "../lib/cn"; -import { ADE_EASE_OUT } from "../lib/motion"; - -const DOCS_BASE = "https://www.ade-app.dev/docs"; - -type Feature = { - image: string; - name: string; - tagline: string; - description: string; - docsPath: string; -}; - -const FEATURES: Feature[] = [ - { - image: "agent-chat.png", - name: "Agent chat", - tagline: "Native multi-provider chat", - description: - "First-class AI chat sessions with tools and context — Claude, GPT, Gemini, local models, and whatever you already subscribe to.", - docsPath: "/chat/overview", - }, - { - image: "cto.png", - name: "CTO agent", - tagline: "Persistent technical lead", - description: - "A long-lived agent that knows your codebase, remembers prior decisions, and delegates work across your team of agents.", - docsPath: "/cto/overview", - }, - { - image: "lanes.png", - name: "Lanes", - tagline: "Parallel git worktrees", - description: - "Each agent works in its own isolated worktree — run builds, tests, and installs in parallel without conflicts.", - docsPath: "/lanes/overview", - }, - { - image: "multi-tasking.png", - name: "Multi-tasking", - tagline: "Parallel agent sessions", - description: - "Run multiple agents side-by-side across lanes, each with their own chat, terminal, and file context.", - docsPath: "/lanes/overview", - }, - { - image: "terminals.png", - name: "Terminals", - tagline: "Live PTY output", - description: - "Real terminal shells with live streams so you see every command agents run and every line of output.", - docsPath: "/tools/terminals", - }, - { - image: "files.png", - name: "Files", - tagline: "Built-in editor", - description: - "Jump from chat or review into the file editor without losing context — syntax-highlighted and diff-aware.", - docsPath: "/tools/files-editor", - }, - { - image: "prs.png", - name: "Pull requests", - tagline: "Review in one place", - description: - "Open, review, and track pull requests from the same desktop shell where your agents work.", - docsPath: "/tools/pull-requests", - }, - { - image: "workspacegraph.png", - name: "Workspace graph", - tagline: "Visualize your work", - description: - "A visual map of your workspace showing how PRs, lanes, and branches relate to each other.", - docsPath: "/tools/workspace-graph", - }, - { - image: "git history.png", - name: "Git history", - tagline: "Timeline in context", - description: - "Inspect commits and history beside the lane and file you are working in — without leaving ADE.", - docsPath: "/tools/history", - }, - { - image: "modelconfig.png", - name: "Model configuration", - tagline: "Your keys and models", - description: - "Wire providers, models, and API keys in one settings surface — BYOK with subscriptions you already pay for.", - docsPath: "/configuration/ai-providers", - }, - { - image: "linear-sync.png", - name: "Linear sync", - tagline: "Issues to agents", - description: - "Connect Linear projects so the CTO agent can pick up issues, plan work, and drive them to completion.", - docsPath: "/cto/linear", - }, - { - image: "run.png", - name: "Process runner", - tagline: "Monitor every command", - description: - "Track every terminal process agents spawn — view output, status, and timing in one unified timeline.", - docsPath: "/tools/project-home", - }, -]; - -function featureSrc(image: string) { - return `/images/features/${encodeURIComponent(image)}`; -} - -/** Lightbox overlay for full-screen image view */ -function Lightbox({ - src, - alt, - onClose, -}: { - src: string; - alt: string; - onClose: () => void; -}) { - return ( - - - e.stopPropagation()} - /> - - ); -} - -function FeatureCard({ - feature, - delay, - onImageClick, -}: { - feature: Feature; - delay: number; - onImageClick: () => void; -}) { - const reduceMotion = useReducedMotion(); - - return ( - - {/* Image — zoomed in by default, zooms out on hover */} - - - {/* Text content */} -
-

{feature.name}

-

- {feature.tagline} -

-

- {feature.description} -

- - View in docs - -
-
- ); -} - -export function ProductShowcase() { - const reduceMotion = useReducedMotion(); - const [lightbox, setLightbox] = useState<{ - src: string; - alt: string; - } | null>(null); - - return ( - <> -
-
-
- - - -

- Product -

-

- The whole loop in{" "} - - one native window - -

-

- Chat, lanes, terminals, files, the workspace graph, pull requests, - git history, and model setup — all captured from the real app. - Click any screenshot to view full size. -

-
- -
- {FEATURES.map((feature, idx) => ( - - setLightbox({ - src: featureSrc(feature.image), - alt: feature.name, - }) - } - /> - ))} -
-
-
- - {/* Lightbox */} - - {lightbox && ( - setLightbox(null)} - /> - )} - - - ); -} diff --git a/apps/web/src/components/ProviderOrbit.tsx b/apps/web/src/components/ProviderOrbit.tsx deleted file mode 100644 index 9f449f74f..000000000 --- a/apps/web/src/components/ProviderOrbit.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import { useState, useEffect } from "react"; -import { motion, useReducedMotion } from "framer-motion"; -import { Container } from "./Container"; -import { Reveal } from "./Reveal"; -import { ADE_EASE_OUT } from "../lib/motion"; - -import anthropic from "@lobehub/icons-static-svg/icons/anthropic.svg"; -import deepseekColor from "@lobehub/icons-static-svg/icons/deepseek-color.svg"; -import googleColor from "@lobehub/icons-static-svg/icons/google-color.svg"; -import groq from "@lobehub/icons-static-svg/icons/groq.svg"; -import lmstudio from "@lobehub/icons-static-svg/icons/lmstudio.svg"; -import metaColor from "@lobehub/icons-static-svg/icons/meta-color.svg"; -import mistralColor from "@lobehub/icons-static-svg/icons/mistral-color.svg"; -import ollama from "@lobehub/icons-static-svg/icons/ollama.svg"; -import openai from "@lobehub/icons-static-svg/icons/openai.svg"; -import openrouter from "@lobehub/icons-static-svg/icons/openrouter.svg"; -import togetherColor from "@lobehub/icons-static-svg/icons/together-color.svg"; -import vllmColor from "@lobehub/icons-static-svg/icons/vllm-color.svg"; -import xai from "@lobehub/icons-static-svg/icons/xai.svg"; - -/** - * Each icon is hand-placed as a percentage (x%, y%) within the container. - * The positions are designed to look organically scattered within a - * rough semi-circle / cloud shape — denser toward the center-top, - * spreading outward toward the bottom-left and bottom-right. - */ -type Provider = { - name: string; - icon: string; - invert?: boolean; - /** [x%, y%] position within the container */ - pos: [number, number]; -}; - -const PROVIDERS: Provider[] = [ - // Top cluster — tight - { name: "Anthropic", icon: anthropic, invert: true, pos: [38, 6] }, - { name: "OpenAI", icon: openai, invert: true, pos: [56, 3] }, - { name: "Google", icon: googleColor, pos: [48, 22] }, - - // Mid band — a bit wider - { name: "DeepSeek", icon: deepseekColor, pos: [27, 18] }, - { name: "Mistral", icon: mistralColor, pos: [68, 14] }, - { name: "xAI", icon: xai, invert: true, pos: [33, 38] }, - { name: "Groq", icon: groq, invert: true, pos: [62, 34] }, - { name: "Meta", icon: metaColor, pos: [49, 44] }, - - // Outer spread — widest, lower - { name: "Together", icon: togetherColor, pos: [16, 52] }, - { name: "OpenRouter", icon: openrouter, invert: true, pos: [78, 48] }, - { name: "Ollama", icon: ollama, invert: true, pos: [8, 72] }, - { name: "LM Studio", icon: lmstudio, invert: true, pos: [88, 68] }, - { name: "vLLM", icon: vllmColor, pos: [30, 70] }, -]; - -export function ProviderOrbit() { - const [width, setWidth] = useState(0); - const reduceMotion = useReducedMotion(); - - useEffect(() => { - const update = () => setWidth(window.innerWidth); - update(); - window.addEventListener("resize", update); - return () => window.removeEventListener("resize", update); - }, []); - - if (width === 0) return null; - - const baseWidth = Math.min(width * 0.7, 520); - const aspectHeight = baseWidth * 0.52; - - const iconSize = - width < 480 - ? Math.max(30, baseWidth * 0.075) - : width < 768 - ? Math.max(34, baseWidth * 0.08) - : Math.max(38, baseWidth * 0.085); - - return ( -
- {/* Background glow */} -
- - - -
-

- Providers -

-

- Works with{" "} - - any AI model - -

-

- Bring your own API keys, use existing subscriptions, or run local models. - ADE unifies them all in one workspace. -

-
-
- -
-
- {/* Radial glow behind icons */} -
-
-
- - {PROVIDERS.map((p, i) => { - const left = (p.pos[0] / 100) * baseWidth - iconSize / 2; - const top = (p.pos[1] / 100) * aspectHeight - iconSize / 2; - const tooltipAbove = p.pos[1] < 40; - - return ( - - -
- {p.name} -
-
- - {/* Tooltip */} - -
- -
- ); -} diff --git a/apps/web/src/components/editorial/AnnotatedFigure.tsx b/apps/web/src/components/editorial/AnnotatedFigure.tsx new file mode 100644 index 000000000..4a26503b9 --- /dev/null +++ b/apps/web/src/components/editorial/AnnotatedFigure.tsx @@ -0,0 +1,140 @@ +import { motion, useReducedMotion } from "framer-motion"; + +type Callout = { + label: string; + /** anchor on the image, 0–100 (percent of image width/height) */ + x: number; + y: number; + /** direction the arrow points from the label toward the anchor */ + from: "top" | "bottom" | "left" | "right"; +}; + +/** + * Annotated figure — screenshot with hand-drawn-feel callout arrows + + * small caps labels pointing at UI regions. + * Callouts coordinate space is 0–100 percent of image box. + */ +export function AnnotatedFigure({ + src, + alt, + figNumber, + caption, + callouts, + className, +}: { + src: string; + alt: string; + figNumber: string; + caption: string; + callouts: Callout[]; + className?: string; +}) { + const reduceMotion = useReducedMotion() ?? true; + + return ( +
+ +
+ {alt} +
+ + {/* Callouts overlay */} + + {callouts.map((c, i) => { + // Place label anchor offset based on 'from' direction + const labelDx = c.from === "left" ? -14 : c.from === "right" ? 14 : 0; + const labelDy = c.from === "top" ? -12 : c.from === "bottom" ? 12 : 0; + const labelX = c.x + labelDx; + const labelY = c.y + labelDy; + // wavy path for hand-drawn feel + const midX = (c.x + labelX) / 2 + (i % 2 === 0 ? 1.5 : -1.5); + const midY = (c.y + labelY) / 2 + (i % 2 === 0 ? -1.5 : 1.5); + return ( + + ); + })} + + + {/* Callout labels — rendered outside SVG so text stays crisp */} + {callouts.map((c, i) => { + const labelDx = c.from === "left" ? -14 : c.from === "right" ? 14 : 0; + const labelDy = c.from === "top" ? -12 : c.from === "bottom" ? 12 : 0; + const labelX = c.x + labelDx; + const labelY = c.y + labelDy; + const alignX = c.from === "left" ? "flex-end" : "flex-start"; + return ( + + ); + })} +
+ +
+ + {figNumber} + + {caption} +
+
+ ); +} diff --git a/apps/web/src/components/editorial/BackCover.tsx b/apps/web/src/components/editorial/BackCover.tsx new file mode 100644 index 000000000..a0b814402 --- /dev/null +++ b/apps/web/src/components/editorial/BackCover.tsx @@ -0,0 +1,188 @@ +import { motion, useReducedMotion } from "framer-motion"; +import { Download, Github } from "lucide-react"; +import { Link } from "react-router-dom"; +import { LINKS } from "../../lib/links"; +import { IPhoneFrame } from "./IPhoneFrame"; + +/** + * Dark back cover — final CTA + colophon. + */ +export function BackCover() { + const reduceMotion = useReducedMotion() ?? true; + + return ( +
+
+ +
+
+ + + Back Cover + + + + The last AI coding app{" "} + + you’ll download. + + + + + Free. Open source. Local-first. Bring your own keys. + + + + + + + + + + {/* Colophon */} +
+

+ Colophon · Set in Instrument Serif & Inter Tight. + Printed to the web from a single git push. + © ADE, 2026. Free forever. Source on GitHub. +

+
+ + Privacy + + + Terms + +
+
+
+ + {/* Right column — MacBook + iPhone composition */} +
+
+
+ {/* small MacBook */} +
+
+
+ + + +
+ ADE on macOS +
+
+
+ {/* small iPhone */} +
+
+
+ ADE on iOS +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/src/components/editorial/Chapter.tsx b/apps/web/src/components/editorial/Chapter.tsx new file mode 100644 index 000000000..e9bea2041 --- /dev/null +++ b/apps/web/src/components/editorial/Chapter.tsx @@ -0,0 +1,137 @@ +import type { ReactNode } from "react"; +import { motion, useReducedMotion } from "framer-motion"; +import { cn } from "../../lib/cn"; + +/** + * Cream article chapter scaffold. Includes: + * - running head (issue/dateline/page) + * - folio (Chapter N / title / page number) + * - children (headline, body, images) + * + * Children fade in on viewport enter. + */ +export function Chapter({ + chapterNumber, + chapterTitle, + pageNumber, + children, + className, + id, +}: { + chapterNumber: string; + chapterTitle: string; + pageNumber: string; + children: ReactNode; + className?: string; + id?: string; +}) { + const reduceMotion = useReducedMotion() ?? true; + + return ( +
+
+ {/* running head */} +
+ ADE · April ’26 + The Agentic Development Environment + {`Vol. 1 · v1.1.0 · ${pageNumber}`} +
+ + {/* folio */} + + + {chapterNumber} + + + {chapterTitle} + + + Page {pageNumber} + + + + {children} +
+
+ ); +} + +/** Paragraph with drop cap on the first letter. */ +export function ChapterBody({ + children, + dropCap = false, + className, + tone = "ink", +}: { + children: ReactNode; + dropCap?: boolean; + className?: string; + tone?: "ink" | "cream"; +}) { + const reduceMotion = useReducedMotion() ?? true; + return ( + + {children} + + ); +} + +/** Italic byline with short rule. */ +export function Byline({ + author = "By ADE Staff", + date = "April 2026", + tone = "ink", +}: { + author?: string; + date?: string; + tone?: "ink" | "cream"; +}) { + return ( +
+
+ ); +} diff --git a/apps/web/src/components/editorial/ChapterHeadline.tsx b/apps/web/src/components/editorial/ChapterHeadline.tsx new file mode 100644 index 000000000..4f2cbc065 --- /dev/null +++ b/apps/web/src/components/editorial/ChapterHeadline.tsx @@ -0,0 +1,98 @@ +import { motion, useReducedMotion } from "framer-motion"; +import { cn } from "../../lib/cn"; + +/** + * Chapter headline — Instrument Serif display. + * Line 1: plain. Line 2: italic + violet accent. + * Animates word-by-word rise on viewport enter. + */ +export function ChapterHeadline({ + line1, + line2, + deck, + tone = "ink", + className, +}: { + line1: string; + line2: string; + deck?: string; + tone?: "ink" | "cream"; + className?: string; +}) { + const reduceMotion = useReducedMotion() ?? true; + + const container = { + hidden: {}, + show: { + transition: { + staggerChildren: reduceMotion ? 0 : 0.06, + delayChildren: reduceMotion ? 0 : 0.05, + }, + }, + }; + const word = { + hidden: reduceMotion ? {} : { opacity: 0, y: 14 }, + show: { + opacity: 1, + y: 0, + transition: { duration: 0.55, ease: [0.22, 1, 0.36, 1] as const }, + }, + }; + + const headingColor = + tone === "ink" ? "text-[color:var(--color-ink)]" : "text-[color:var(--color-cream)]"; + const accentColor = + tone === "ink" + ? "text-[color:var(--color-accent)]" + : "text-[color:var(--color-violet-bright)]"; + const deckColor = + tone === "ink" ? "text-[color:var(--color-ink-muted)]" : "text-[color:var(--color-cream-muted)]"; + + return ( + +

+ + {line1.split(" ").map((w, i) => ( + + {w} + + ))} + + + {line2.split(" ").map((w, i) => ( + + {w} + + ))} + +

+ + {deck && ( + + {deck} + + )} +
+ ); +} diff --git a/apps/web/src/components/editorial/CompetitorEquation.tsx b/apps/web/src/components/editorial/CompetitorEquation.tsx new file mode 100644 index 000000000..5ef731697 --- /dev/null +++ b/apps/web/src/components/editorial/CompetitorEquation.tsx @@ -0,0 +1,134 @@ +import { Fragment } from "react"; +import { motion, useReducedMotion } from "framer-motion"; + +const COMPETITORS = [ + { name: "Claude Code", short: "Claude", logo: "/images/competitors/claude-code.png" }, + { name: "Codex", short: "Codex", logo: "/images/competitors/codex.png" }, + { name: "OpenCode", short: "OpenC.", logo: "/images/competitors/opencode.png" }, + { name: "T3 Code", short: "T3", logo: "/images/competitors/t3-code.png" }, + { name: "Cursor", short: "Cursor", logo: "/images/competitors/cursor.png" }, + { name: "Superset", short: "Superset", logo: "/images/competitors/superset.png" }, + { name: "Conductor", short: "Cond.", logo: "/images/competitors/conductor.png" }, + { name: "Factory", short: "Factory", logo: "/images/competitors/factory.png" }, + { name: "Paperclip", short: "Paperc.", logo: "/images/competitors/paperclip.png" }, + { name: "OpenClaw", short: "OpenClaw", logo: "/images/competitors/openclaw.png" }, + { name: "GitHub", short: "GitHub", logo: "/images/competitors/github.png" }, +] as const; + +/** + * Two-row competitor equation. + * Row 1: 11 competitor chips + `+` separators, staggered in left→right. + * Row 2: italic serif "equals" + ADE dock icon with violet halo pulse. + */ +export function CompetitorEquation() { + const reduceMotion = useReducedMotion() ?? true; + + const stagger = 0.065; + const rowChildCount = COMPETITORS.length * 2 - 1; + const rowEnd = 0.1 + rowChildCount * stagger; + + const container = { + hidden: {}, + show: { + transition: { + staggerChildren: reduceMotion ? 0 : stagger, + delayChildren: reduceMotion ? 0 : 0.1, + }, + }, + }; + const item = { + hidden: reduceMotion ? {} : { opacity: 0, y: 14, scale: 0.85 }, + show: { + opacity: 1, + y: 0, + scale: 1, + transition: { duration: 0.45, ease: [0.22, 1, 0.36, 1] as const }, + }, + }; + + return ( +
+ {/* Row 1 — competitor chips */} + + {COMPETITORS.map((app, i) => ( + + {i > 0 && ( + + )} + +
+ {app.name} +
+ +
+
+ ))} +
+ + {/* Row 2 — equals + giant ADE side by side */} + + + equals + + +
+ {!reduceMotion && ( + + )} + ADE +
+
+
+ ); +} diff --git a/apps/web/src/components/editorial/Cutout.tsx b/apps/web/src/components/editorial/Cutout.tsx new file mode 100644 index 000000000..9e5e2b71c --- /dev/null +++ b/apps/web/src/components/editorial/Cutout.tsx @@ -0,0 +1,81 @@ +import { motion, useReducedMotion } from "framer-motion"; +import { cn } from "../../lib/cn"; + +/** + * Magazine cutout image — rotated, warm border, soft shadow, italic figure caption. + * "Develops" on scroll (blur → sharp, opacity 0.4 → 1). + */ +export function Cutout({ + src, + alt, + figNumber, + caption, + rotate = 1.4, + className, + tone = "ink", +}: { + src: string; + alt: string; + figNumber: string; + caption: string; + rotate?: number; + className?: string; + tone?: "ink" | "cream"; +}) { + const reduceMotion = useReducedMotion() ?? true; + + return ( +
+ + {alt} + +
+ + {figNumber} + + {caption} +
+
+ ); +} diff --git a/apps/web/src/components/editorial/DeviceComposition.tsx b/apps/web/src/components/editorial/DeviceComposition.tsx new file mode 100644 index 000000000..55688ec8d --- /dev/null +++ b/apps/web/src/components/editorial/DeviceComposition.tsx @@ -0,0 +1,100 @@ +import { motion, useReducedMotion } from "framer-motion"; + +/** + * Right column of the fold — MacBook + iPhone on violet halo + Fig. 1 caption. + */ +export function DeviceComposition() { + const reduceMotion = useReducedMotion() ?? true; + + return ( + + {/* Violet halo */} +
+ +
+ {/* MacBook */} +
+
+
+ + + +
+ ADE on macOS +
+
+ +
+
+ + {/* iPhone */} +
+
+ +
+ +
+ + Fig. 1 + + ADE, on desk and in hand. Photographed April 2026. +
+ + ); +} diff --git a/apps/web/src/components/editorial/FadeBand.tsx b/apps/web/src/components/editorial/FadeBand.tsx new file mode 100644 index 000000000..f23d66797 --- /dev/null +++ b/apps/web/src/components/editorial/FadeBand.tsx @@ -0,0 +1,26 @@ +import { cn } from "../../lib/cn"; + +/** + * 40vh gradient band for dark↔cream transitions. + * Pure CSS — no JS, no motion. The band sits between a dark section above + * and a cream section below (or vice versa). + */ +export function FadeBand({ + direction, + className, +}: { + direction: "to-cream" | "to-dark"; + className?: string; +}) { + const bg = + direction === "to-cream" + ? "linear-gradient(to bottom, var(--color-bg), var(--color-paper))" + : "linear-gradient(to bottom, var(--color-paper), var(--color-bg))"; + return ( +
+ ); +} diff --git a/apps/web/src/components/editorial/FeatureGrid.tsx b/apps/web/src/components/editorial/FeatureGrid.tsx new file mode 100644 index 000000000..6c81f8488 --- /dev/null +++ b/apps/web/src/components/editorial/FeatureGrid.tsx @@ -0,0 +1,170 @@ +import { motion, useReducedMotion } from "framer-motion"; +import { ArrowUpRight } from "lucide-react"; +import { LINKS } from "../../lib/links"; + +type Feature = { + label: string; + blurb: string; + src: string; + docs: string; +}; + +const FEATURES: Feature[] = [ + { + label: "Files", + blurb: "Every file in your project, in context with the lane you're working on.", + src: "/images/features/files.png", + docs: "/tools/files-editor", + }, + { + label: "Git history", + blurb: "A timeline of every commit, right beside the code it touched.", + src: "/images/features/git-history.png", + docs: "/tools/history", + }, + { + label: "Terminals", + blurb: "Built-in shells, one per lane. Keep a REPL alongside the code.", + src: "/images/features/terminals.png", + docs: "/tools/terminals", + }, + { + label: "Workspace graph", + blurb: "See the shape of your repo — files, imports, and open lanes.", + src: "/images/features/workspacegraph.png", + docs: "/tools/workspace-graph", + }, + { + label: "Model config", + blurb: "Providers, keys, and per-task model routing in a single pane.", + src: "/images/features/modelconfig.png", + docs: "/configuration/ai-providers", + }, + { + label: "Multi-tasking", + blurb: "Switch between lanes and tasks without losing an ounce of state.", + src: "/images/features/multi-tasking.png", + docs: "/lanes/overview", + }, + { + label: "Linear sync", + blurb: "Pull issues from Linear into the CTO. Post results back automatically.", + src: "/images/features/linear-sync.png", + docs: "/cto/linear", + }, +]; + +/** + * "Catalog" — a grid of remaining IDE functions with thumbnail screenshots. + * Cream background, same editorial paper feel. Each card opens a doc page. + */ +export function FeatureGrid() { + const reduceMotion = useReducedMotion() ?? true; + + return ( +
+
+ ); +} diff --git a/apps/web/src/components/editorial/IPhoneFrame.tsx b/apps/web/src/components/editorial/IPhoneFrame.tsx new file mode 100644 index 000000000..28758bd83 --- /dev/null +++ b/apps/web/src/components/editorial/IPhoneFrame.tsx @@ -0,0 +1,87 @@ +import { motion, useReducedMotion } from "framer-motion"; +import { cn } from "../../lib/cn"; + +/** + * Reusable iPhone mockup — bezel + Dynamic Island + inner violet glow. + * Used for the fold's DeviceComposition and the mobile chapter's cutout. + */ +export function IPhoneFrame({ + src, + alt, + rotate = 0, + className, + width = "w-[220px] sm:w-[240px]", + /** when true, wraps with an editorial-style caption */ + figCaption, +}: { + src: string; + alt: string; + rotate?: number; + className?: string; + width?: string; + figCaption?: { figNumber: string; caption: string; tone?: "ink" | "cream" }; +}) { + const reduceMotion = useReducedMotion() ?? true; + const frameShadow = "drop-shadow(0 30px 60px rgba(124,58,237,0.45))"; + + const frame = ( + +
+ {/* Dynamic Island */} +