diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 0308b61..fd905dc 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -506,12 +506,18 @@ export async function createApp( // Security headers app.use("*", async (c, next) => { await next(); - c.res.headers.set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"); + c.res.headers.set( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ws:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + ); c.res.headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); c.res.headers.set("X-Frame-Options", "DENY"); c.res.headers.set("X-Content-Type-Options", "nosniff"); c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - c.res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"); + c.res.headers.set( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()", + ); }); app.get("/api/auth/capabilities", (c) => c.json({ diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index bee109e..595a116 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -76,19 +76,69 @@ interface ScanRecord { const scanRegistry = new Map(); -export async function startOnboardingScan(botId: string, prisma: any): Promise<{ scanId: string }> { +const emptyScanResult = (): ScanResult => ({ + localRepos: [], + projectFolders: [], + fileSummary: { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }, + profileSummary: { + interests: [], + skills: [], + workStyle: "structured", + communication: "direct", + languages: [], + techStack: [], + }, + agentBriefing: "Profile ready", + createdProjects: [], + subAgentRoles: [], +}); + +async function runSimulatedScan(record: ScanRecord) { + const phases: Array<{ status: ScanRecord["status"]; progress: number; message: string }> = [ + { status: "scanning", progress: 25, message: "Scanning local files and repositories..." }, + { status: "connecting", progress: 40, message: "Connecting to your services..." }, + { status: "building", progress: 60, message: "Building your profile..." }, + { status: "briefing", progress: 80, message: "Briefing your agent team..." }, + ]; + for (const phase of phases) { + record.status = phase.status; + record.progress = phase.progress; + record.message = phase.message; + await new Promise((resolve) => setTimeout(resolve, 400)); + } + record.status = "complete"; + record.progress = 100; + record.message = "Scan complete — profile ready"; + record.result = JSON.stringify(emptyScanResult()); + record.completedAt = new Date(); +} + +export async function startOnboardingScan( + botId: string | undefined, + prisma: any, +): Promise<{ scanId: string }> { const scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const record: ScanRecord = { - id: scanId, botId, status: "scanning", progress: 0, message: "Scanning your computer...", - result: "[]", startedAt: new Date(), completedAt: null, + id: scanId, + botId: botId ?? "", + status: "scanning", + progress: 0, + message: "Scanning your computer...", + result: "[]", + startedAt: new Date(), + completedAt: null, }; scanRegistry.set(scanId, record); // Run async - (async () => { + void (async () => { try { + if (process.env.AGENT_RUNTIME === "scripted" || process.env.SANDBOX_PROVIDER === "fake") { + await runSimulatedScan(record); + return; + } const homeDir = process.env.HOME ?? "/home/cynar"; - const result = await executeFullScan(homeDir, botId, prisma); + const result = await executeFullScan(homeDir, botId ?? "", prisma); record.status = "complete"; record.progress = 100; record.message = "Scan complete — profile ready"; @@ -105,7 +155,10 @@ export async function startOnboardingScan(botId: string, prisma: any): Promise<{ } export async function getScanStatus(scanId: string): Promise<{ - status: ScanRecord["status"]; progress: number; message: string; result?: unknown; + status: ScanRecord["status"]; + progress: number; + message: string; + result?: unknown; }> { const record = scanRegistry.get(scanId); if (!record) throw new Error("Scan not found"); @@ -137,7 +190,15 @@ async function executeFullScan(homeDir: string, _botId: string, prisma: any): Pr // Phase 5: Agent briefing (includes sub-agent roles) const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles); - return { localRepos, projectFolders, fileSummary, profileSummary, agentBriefing, createdProjects, subAgentRoles }; + return { + localRepos, + projectFolders, + fileSummary, + profileSummary, + agentBriefing, + createdProjects, + subAgentRoles, + }; } // --------------------------------------------------------------------------- @@ -150,8 +211,19 @@ async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[] // Scan common directories const scanDirs = [ - "Projects", "Documents", "Pictures", "Music", "Downloads", "Desktop", - "src", "code", "work", "data", "repos", "dev", "workspace", + "Projects", + "Documents", + "Pictures", + "Music", + "Downloads", + "Desktop", + "src", + "code", + "work", + "data", + "repos", + "dev", + "workspace", ]; for (const subDir of scanDirs) { @@ -173,7 +245,9 @@ async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[] modifiedAt: new Date().toISOString(), }); } - } catch { /* directory doesn't exist, skip */ } + } catch { + /* directory doesn't exist, skip */ + } } return [summary, folders]; @@ -186,7 +260,11 @@ interface DirStats { byExtension: Record; } -async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: string): Promise { +async function scanDirectory( + dirPath: string, + _homeDir: string, + _relativePath: string, +): Promise { const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} }; try { @@ -197,7 +275,8 @@ async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: s if (entry.isDirectory()) { // Skip node_modules, .git, etc. - if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") continue; + if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".venv") + continue; const subStats = await scanDirectory(fullPath, _homeDir, _relativePath); stats.totalFiles += subStats.totalFiles; stats.totalSizeBytes += subStats.totalSizeBytes; @@ -216,17 +295,37 @@ async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: s // Classify by extension const ext = entry.name.split(".").pop()?.toLowerCase() || "none"; stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1; - } catch { /* permission denied */ } + } catch { + /* permission denied */ + } } } - } catch { /* permission denied */ } + } catch { + /* permission denied */ + } return stats; } function classifyFileType(filename: string): string { const ext = filename.split(".").pop()?.toLowerCase() || ""; - const codeExts = ["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "c", "cpp", "h", "rb", "php", "swift", "kt"]; + const codeExts = [ + "ts", + "tsx", + "js", + "jsx", + "py", + "go", + "rs", + "java", + "c", + "cpp", + "h", + "rb", + "php", + "swift", + "kt", + ]; const docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"]; const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"]; const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"]; @@ -235,16 +334,26 @@ function classifyFileType(filename: string): string { if (docExts.includes(ext)) return "documents"; if (imageExts.includes(ext)) return "images"; if (musicExts.includes(ext)) return "music"; - if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) return "config"; + if (["json", "yaml", "yml", "toml", "cfg", "ini", "env", "sh", "bash", "zsh"].includes(ext)) + return "config"; return "other"; } function classifyFolderType(name: string): ProjectFolder["type"] { const lower = name.toLowerCase(); if (lower.includes("music")) return "music"; - if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) return "media"; + if (lower.includes("picture") || lower.includes("image") || lower.includes("photo")) + return "media"; if (lower.includes("document") || lower.includes("doc")) return "documents"; - if (lower.includes("project") || lower.includes("code") || lower.includes("src") || lower.includes("dev") || lower.includes("workspace") || lower.includes("repo")) return "code"; + if ( + lower.includes("project") || + lower.includes("code") || + lower.includes("src") || + lower.includes("dev") || + lower.includes("workspace") || + lower.includes("repo") + ) + return "code"; return "workspace"; } @@ -256,18 +365,19 @@ async function discoverGitRepos(homeDir: string): Promise { const repos: RepoInfo[] = []; try { - // Use git to find all repositories under home directory - const { stdout } = await spawnPromise("git", ["root"], { cwd: homeDir }); - // This only finds the current repo. Instead, search for .git directories const gitDirs = await findGitDirectories(homeDir); for (const repoDir of gitDirs) { try { const repo = await analyzeGitRepo(repoDir, homeDir); if (repo) repos.push(repo); - } catch { /* skip broken repos */ } + } catch { + /* skip broken repos */ + } } - } catch { /* git not available */ } + } catch { + /* git not available */ + } return repos; } @@ -283,31 +393,43 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise { +async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise { try { const name = repoDir.split("/").pop() || ""; - const relPath = repoDir.replace(homeDir + "/", ""); // Get remote URL - const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { cwd: repoDir }).catch(() => ({ stdout: "" })); + const remoteResult = await spawnPromise("git", ["remote", "get-url", "origin"], { + cwd: repoDir, + }).catch(() => ({ stdout: "" })); const remote = remoteResult.stdout.trim() || null; // Get current branch - const branchResult = await spawnPromise("git", ["branch", "--show-current"], { cwd: repoDir }).catch(() => ({ stdout: "" })); + const branchResult = await spawnPromise("git", ["branch", "--show-current"], { + cwd: repoDir, + }).catch(() => ({ stdout: "" })); const branch = branchResult.stdout.trim() || "unknown"; // Get last commit - const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { cwd: repoDir }).catch(() => ({ stdout: "" })); + const commitResult = await spawnPromise("git", ["log", "-1", "--format=%H"], { + cwd: repoDir, + }).catch(() => ({ stdout: "" })); const lastCommit = commitResult.stdout.trim() || null; // Get language stats via git ls-files + wc @@ -326,19 +448,34 @@ async function analyzeGitRepo(repoDir: string, homeDir: string): Promise> { const langs: Record = {}; try { - const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ stdout: "" })); + const { stdout } = await spawnPromise("git", ["ls-files"], { cwd: repoDir }).catch(() => ({ + stdout: "", + })); const files = stdout.trim().split("\n").filter(Boolean); for (const file of files) { const ext = file.split(".").pop()?.toLowerCase() || ""; const langMap: Record = { - ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript", - py: "Python", go: "Go", rs: "Rust", java: "Java", c: "C", cpp: "C++", - rb: "Ruby", php: "PHP", swift: "Swift", kt: "Kotlin", + ts: "TypeScript", + tsx: "TypeScript", + js: "JavaScript", + jsx: "JavaScript", + py: "Python", + go: "Go", + rs: "Rust", + java: "Java", + c: "C", + cpp: "C++", + rb: "Ruby", + php: "PHP", + swift: "Swift", + kt: "Kotlin", }; const lang = langMap[ext] || ext || "unknown"; langs[lang] = (langs[lang] || 0) + 1; } - } catch { /* no languages */ } + } catch { + /* no languages */ + } return langs; } @@ -346,7 +483,11 @@ async function getRepoLanguages(repoDir: string): Promise // Phase 3: Profile compilation // --------------------------------------------------------------------------- -function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: ProjectFolder[]): ProfileSummary { +function compileProfile( + repos: RepoInfo[], + fileSummary: FileSummary, + _folders: ProjectFolder[], +): ProfileSummary { const interests: string[] = []; const skills: string[] = []; const techStack: string[] = []; @@ -369,9 +510,16 @@ function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: Pr // Derive skills from languages const langToSkill: Record = { - "TypeScript": "TypeScript/JavaScript", Python: "Python", Go: "Go", - "Rust": "Rust", Java: "Java", C: "C/C++", "C++": "C/C++", Ruby: "Ruby", - "Swift": "Swift/Kotlin", PHP: "PHP", + TypeScript: "TypeScript/JavaScript", + Python: "Python", + Go: "Go", + Rust: "Rust", + Java: "Java", + C: "C/C++", + "C++": "C/C++", + Ruby: "Ruby", + Swift: "Swift/Kotlin", + PHP: "PHP", }; for (const lang of techStack) { const skill = langToSkill[lang]; @@ -386,7 +534,12 @@ function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: Pr return { interests: uniqueInterests, skills: uniqueSkills, - workStyle: uniqueTech.length > 3 ? "multi-language" : uniqueTech[0] ? `${uniqueTech[0]}-focused` : "general", + workStyle: + uniqueTech.length > 3 + ? "multi-language" + : uniqueTech[0] + ? `${uniqueTech[0]}-focused` + : "general", communication: "structured", languages: Array.from(languageSet), techStack: uniqueTech, @@ -413,13 +566,19 @@ function generateBriefing( lines.push(``); lines.push(`## Local Repositories (${repos.length} found)`); for (const repo of repos) { - lines.push(`- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`); + lines.push( + `- **${repo.name}** — ${repo.branch}, ${Object.values(repo.languages).reduce((a, b) => a + b, 0)} files, ${repo.remote || "no remote"}`, + ); } lines.push(``); lines.push(`## File System Summary`); lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`); lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`); - lines.push(`- **Categories:** ${Object.entries(fileSummary.byType).map(([t, c]) => `${t}(${c})`).join(", ")}`); + lines.push( + `- **Categories:** ${Object.entries(fileSummary.byType) + .map(([t, c]) => `${t}(${c})`) + .join(", ")}`, + ); lines.push(``); lines.push(`## User Interests`); lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`); @@ -437,7 +596,9 @@ function generateBriefing( lines.push(""); } lines.push(`---`); - lines.push(`This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`); + lines.push( + `This profile should be used to tailor all future interactions, project recommendations, and agent assignments.`, + ); return lines.join("\n"); } @@ -453,14 +614,24 @@ async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise 0; - const hasDesign = profile.interests?.some(i => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui")); - const hasData = profile.interests?.some(i => i.toLowerCase().includes("data") || i.toLowerCase().includes("analytics") || i.toLowerCase().includes("database")); - const hasDevOps = profile.interests?.some(i => i.toLowerCase().includes("infra") || i.toLowerCase().includes("ops") || i.toLowerCase().includes("deploy")); - const hasMusic = profile.interests?.some(i => i.toLowerCase().includes("music")); - const hasWriting = profile.interests?.some(i => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog")); + const hasDesign = profile.interests?.some( + (i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"), + ); + const hasData = profile.interests?.some( + (i) => + i.toLowerCase().includes("data") || + i.toLowerCase().includes("analytics") || + i.toLowerCase().includes("database"), + ); + const hasDevOps = profile.interests?.some( + (i) => + i.toLowerCase().includes("infra") || + i.toLowerCase().includes("ops") || + i.toLowerCase().includes("deploy"), + ); + const hasMusic = profile.interests?.some((i) => i.toLowerCase().includes("music")); + const hasWriting = profile.interests?.some( + (i) => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"), + ); if (hasCode) { const primaryLang = profile.languages?.[0] || "TypeScript"; @@ -497,7 +682,9 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA role: "software-engineer", description: `Specializes in ${primaryLang} development and code review`, instructions: `You are the Code Engineer specialist. Your expertise is in ${profile.languages?.join(", ") || "software development"}. Review code, write tests, debug issues, and implement features. You have access to the user's local repositories and can run commands to build and test code.`, - skills: profile.skills.filter(s => s.toLowerCase().includes("code") || s.toLowerCase().includes("program")), + skills: profile.skills.filter( + (s) => s.toLowerCase().includes("code") || s.toLowerCase().includes("program"), + ), }); } @@ -506,7 +693,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA name: "UI/UX Designer", role: "ui-designer", description: "Creates polished interfaces and design systems", - instructions: "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.", + instructions: + "You are the UI/UX Designer specialist. Focus on creating beautiful, accessible interfaces. Review designs, suggest improvements, and ensure consistency across the user's projects.", skills: ["design", "UI/UX"], }); } @@ -516,7 +704,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA name: "Data Analyst", role: "data-analyst", description: "Analyzes data, creates reports, and extracts insights", - instructions: "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.", + instructions: + "You are the Data Analyst specialist. Process data from the user's projects, create visualizations, generate reports, and provide actionable insights.", skills: ["data analysis", "statistics"], }); } @@ -526,7 +715,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA name: "DevOps Engineer", role: "devops-engineer", description: "Manages infrastructure, deployments, and CI/CD", - instructions: "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.", + instructions: + "You are the DevOps Engineer specialist. Handle deployments, manage infrastructure, configure CI/CD pipelines, and ensure system reliability.", skills: ["DevOps", "infrastructure"], }); } @@ -536,7 +726,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA name: "Music Producer", role: "music-producer", description: "Assists with music production and audio engineering", - instructions: "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.", + instructions: + "You are the Music Producer specialist. Help with music composition, audio engineering, mixing, and mastering. You understand the user's music projects and can provide expert advice.", skills: ["music production", "audio engineering"], }); } @@ -546,7 +737,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA name: "Content Writer", role: "content-writer", description: "Creates and edits written content", - instructions: "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.", + instructions: + "You are the Content Writer specialist. Write blog posts, documentation, articles, and other content for the user's projects and personal use.", skills: ["writing", "content creation"], }); } @@ -556,7 +748,12 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA name: "General Assistant", role: "general-assistant", description: "Handles general tasks and coordinates other specialists", - instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${roles.slice(1).map(r => r.name).join(", ") || "none yet"}), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`, + instructions: `You are the General Assistant and team coordinator. Your role is to handle general queries, coordinate with other specialists (${ + roles + .slice(1) + .map((r) => r.name) + .join(", ") || "none yet" + }), and manage the user's daily workflow. You have access to all tools and can delegate tasks to specialists when needed.`, skills: ["task management", "coordination"], }); @@ -567,13 +764,21 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA // Utility // --------------------------------------------------------------------------- -function spawnPromise(cmd: string, args: string[], options?: { cwd?: string }): Promise<{ stdout: string; stderr: string }> { +function spawnPromise( + cmd: string, + args: string[], + options?: { cwd?: string }, +): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { ...options, timeout: 15000 }); let stdout = ""; let stderr = ""; - child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); }); - child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); }); + child.stdout?.on("data", (d: Buffer) => { + stdout += d.toString(); + }); + child.stderr?.on("data", (d: Buffer) => { + stderr += d.toString(); + }); child.on("error", () => reject(new Error("spawn failed"))); child.on("close", (code) => { if (code === 0 || code === null) resolve({ stdout, stderr }); diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index 899267c..2d07569 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -8,7 +8,6 @@ import { type Prisma, type PrismaClient, type ThreadEvents, - createPool, } from "@rakazo/db"; /** diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 6352ff2..a696374 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -39,7 +39,7 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) { if (page.url().includes("/onboarding")) { await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute( "data-onboarding-step", - /^(emoji|preferences|language|voice|integrations)$/, + /^(emoji|preferences|language|voice|integrations|scan|bot)$/, { timeout: 30_000 }, ); } @@ -71,8 +71,10 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) { await page.getByRole("button", { name: "Skip", exact: true }).click(); } - await page.waitForURL(/\/app\/[^/]+/, { timeout: 20_000 }); + // Scan step auto-advances to first bot creation before landing in /app. + await page.waitForURL(/\/app\/[^/]+/, { timeout: 30_000 }); await expect(chief).toBeVisible({ timeout: 15_000 }); + await waitForChatComposer(page); if (testInfo) { await captureScreenshot(page, testInfo, "03-create-first-bot"); @@ -181,6 +183,14 @@ export async function openUserSettings( return settings; } +/** Wait until the shell thread is loaded and the message composer is interactive. */ +export async function waitForChatComposer(page: Page) { + await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", { + timeout: 30_000, + }); + await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); +} + /** Create a named bot via RPC for test setup (skips the + picker). */ export async function createNamedBot( page: Page, @@ -194,7 +204,8 @@ export async function createNamedBot( notifyOnFinish: true, computerMode: options.computerMode ?? "team", }); - await page.goto(`/app/${bot.id}`); - await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); + await page.goto(`/app/${bot.id}`, { waitUntil: "domcontentloaded" }); + await page.waitForURL(/\/app\/[^/]+$/, { timeout: 20_000 }); + await waitForChatComposer(page); return bot.id; } diff --git a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx b/apps/web/src/components/onboarding/OnboardingScanProgress.tsx index 1dc1650..16c41b4 100644 --- a/apps/web/src/components/onboarding/OnboardingScanProgress.tsx +++ b/apps/web/src/components/onboarding/OnboardingScanProgress.tsx @@ -9,24 +9,38 @@ interface ScanStatus { } const PHASES = [ - { key: "scanning" as const, label: "Scanning your computer...", sublabel: "Files, folders, git repositories" }, - { key: "connecting" as const, label: "Connecting to your services...", sublabel: "GitHub, email, calendar" }, - { key: "building" as const, label: "Building your profile...", sublabel: "Interests, skills, preferences" }, - { key: "briefing" as const, label: "Setting up your agent team...", sublabel: "Project folders, role assignments" }, + { + key: "scanning" as const, + label: "Scanning your computer...", + sublabel: "Files, folders, git repositories", + }, + { + key: "connecting" as const, + label: "Connecting to your services...", + sublabel: "GitHub, email, calendar", + }, + { + key: "building" as const, + label: "Building your profile...", + sublabel: "Interests, skills, preferences", + }, + { + key: "briefing" as const, + label: "Setting up your agent team...", + sublabel: "Project folders, role assignments", + }, ] as const; export function OnboardingScanProgress({ onComplete, botId, - onScanComplete, }: { onComplete: () => void; botId?: string; - onScanComplete?: () => void; }) { const [status, setStatus] = useState(null); const [error, setError] = useState(null); - const [scanId, setScanId] = useState(null); + const [, setScanId] = useState(null); useEffect(() => { let cancelled = false; @@ -34,7 +48,7 @@ export function OnboardingScanProgress({ const run = async () => { try { - const scanResult = await rpc.onboarding.scan({ botId: botId ?? "" }); + const scanResult = await rpc.onboarding.scan(botId ? { botId } : {}); if (cancelled) return; setScanId(scanResult.scanId); @@ -45,7 +59,6 @@ export function OnboardingScanProgress({ if (cancelled) return; setStatus(s); if (s.status === "complete") { - onScanComplete?.(); onComplete(); } else if (s.status === "failed") { setError(s.message); @@ -68,13 +81,13 @@ export function OnboardingScanProgress({ cancelled = true; if (pollingInterval) clearInterval(pollingInterval); }; - }, [botId, onComplete, onScanComplete]); + }, [botId, onComplete]); if (error) { return (

{error}

-
@@ -90,7 +103,7 @@ export function OnboardingScanProgress({ ); } - const currentPhaseIndex = PHASES.findIndex(p => p.key === status.status); + const currentPhaseIndex = PHASES.findIndex((p) => p.key === status.status); const isComplete = status.status === "complete"; return ( @@ -121,15 +134,23 @@ export function OnboardingScanProgress({ >
{isDone ? ( - ✓ + + ✓ + ) : isActive ? ( - ● + + ● + ) : ( - ○ + + ○ + )}
-

+

{phase.label}

{phase.sublabel}

@@ -152,6 +173,7 @@ export function OnboardingScanProgress({ {isComplete && (