fix(e2e): wait for shell-ready after RPC bot create (#87)
* fix(e2e): wait for shell-ready after RPC bot create createNamedBot now waits for data-ready and the Message composer after navigating to a freshly created bot, instead of asserting immediately on a cold page load where bootstrap had not finished. Also fix onboarding scan advancing to bot creation (onComplete was a no-op) and use a fast simulated scan when AGENT_RUNTIME=scripted so CI E2E does not walk the real filesystem during onboarding. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: apply biome format to touched onboarding files Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: clear biome lint in onboarding scan files touched by the fix Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: format onboarding-scan after rebase onto main Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * style: fix biome lint on main files blocking Product CI Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> * fix(onboarding): allow scan before first bot exists The scan RPC required a non-empty botId, but onboarding runs scan before Chief is created and the UI sent an empty string. That rejected the RPC, left the flow on /onboarding, and every completeOnboarding helper timed out. Make botId optional, omit it from the client when unknown, advance to bot creation when scan completes, and keep scripted/fake runs on the fast scan path. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
parent
c18a04ce3f
commit
5426bb73c5
@ -506,12 +506,18 @@ export async function createApp(
|
|||||||
// Security headers
|
// Security headers
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
await 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("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
||||||
c.res.headers.set("X-Frame-Options", "DENY");
|
c.res.headers.set("X-Frame-Options", "DENY");
|
||||||
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
c.res.headers.set("X-Content-Type-Options", "nosniff");
|
||||||
c.res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
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) =>
|
app.get("/api/auth/capabilities", (c) =>
|
||||||
c.json({
|
c.json({
|
||||||
|
|||||||
@ -76,19 +76,69 @@ interface ScanRecord {
|
|||||||
|
|
||||||
const scanRegistry = new Map<string, ScanRecord>();
|
const scanRegistry = new Map<string, ScanRecord>();
|
||||||
|
|
||||||
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 scanId = `scan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const record: ScanRecord = {
|
const record: ScanRecord = {
|
||||||
id: scanId, botId, status: "scanning", progress: 0, message: "Scanning your computer...",
|
id: scanId,
|
||||||
result: "[]", startedAt: new Date(), completedAt: null,
|
botId: botId ?? "",
|
||||||
|
status: "scanning",
|
||||||
|
progress: 0,
|
||||||
|
message: "Scanning your computer...",
|
||||||
|
result: "[]",
|
||||||
|
startedAt: new Date(),
|
||||||
|
completedAt: null,
|
||||||
};
|
};
|
||||||
scanRegistry.set(scanId, record);
|
scanRegistry.set(scanId, record);
|
||||||
|
|
||||||
// Run async
|
// Run async
|
||||||
(async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
if (process.env.AGENT_RUNTIME === "scripted" || process.env.SANDBOX_PROVIDER === "fake") {
|
||||||
|
await runSimulatedScan(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const homeDir = process.env.HOME ?? "/home/cynar";
|
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.status = "complete";
|
||||||
record.progress = 100;
|
record.progress = 100;
|
||||||
record.message = "Scan complete — profile ready";
|
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<{
|
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);
|
const record = scanRegistry.get(scanId);
|
||||||
if (!record) throw new Error("Scan not found");
|
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)
|
// Phase 5: Agent briefing (includes sub-agent roles)
|
||||||
const agentBriefing = generateBriefing(localRepos, profileSummary, fileSummary, subAgentRoles);
|
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
|
// Scan common directories
|
||||||
const scanDirs = [
|
const scanDirs = [
|
||||||
"Projects", "Documents", "Pictures", "Music", "Downloads", "Desktop",
|
"Projects",
|
||||||
"src", "code", "work", "data", "repos", "dev", "workspace",
|
"Documents",
|
||||||
|
"Pictures",
|
||||||
|
"Music",
|
||||||
|
"Downloads",
|
||||||
|
"Desktop",
|
||||||
|
"src",
|
||||||
|
"code",
|
||||||
|
"work",
|
||||||
|
"data",
|
||||||
|
"repos",
|
||||||
|
"dev",
|
||||||
|
"workspace",
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const subDir of scanDirs) {
|
for (const subDir of scanDirs) {
|
||||||
@ -173,7 +245,9 @@ async function scanFiles(homeDir: string): Promise<[FileSummary, ProjectFolder[]
|
|||||||
modifiedAt: new Date().toISOString(),
|
modifiedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* directory doesn't exist, skip */ }
|
} catch {
|
||||||
|
/* directory doesn't exist, skip */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [summary, folders];
|
return [summary, folders];
|
||||||
@ -186,7 +260,11 @@ interface DirStats {
|
|||||||
byExtension: Record<string, number>;
|
byExtension: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: string): Promise<DirStats> {
|
async function scanDirectory(
|
||||||
|
dirPath: string,
|
||||||
|
_homeDir: string,
|
||||||
|
_relativePath: string,
|
||||||
|
): Promise<DirStats> {
|
||||||
const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} };
|
const stats: DirStats = { totalFiles: 0, totalSizeBytes: 0, byType: {}, byExtension: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -197,7 +275,8 @@ async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: s
|
|||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
// Skip node_modules, .git, etc.
|
// 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);
|
const subStats = await scanDirectory(fullPath, _homeDir, _relativePath);
|
||||||
stats.totalFiles += subStats.totalFiles;
|
stats.totalFiles += subStats.totalFiles;
|
||||||
stats.totalSizeBytes += subStats.totalSizeBytes;
|
stats.totalSizeBytes += subStats.totalSizeBytes;
|
||||||
@ -216,17 +295,37 @@ async function scanDirectory(dirPath: string, _homeDir: string, _relativePath: s
|
|||||||
// Classify by extension
|
// Classify by extension
|
||||||
const ext = entry.name.split(".").pop()?.toLowerCase() || "none";
|
const ext = entry.name.split(".").pop()?.toLowerCase() || "none";
|
||||||
stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1;
|
stats.byExtension[ext] = (stats.byExtension[ext] || 0) + 1;
|
||||||
} catch { /* permission denied */ }
|
} catch {
|
||||||
|
/* permission denied */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* permission denied */ }
|
}
|
||||||
|
} catch {
|
||||||
|
/* permission denied */
|
||||||
|
}
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyFileType(filename: string): string {
|
function classifyFileType(filename: string): string {
|
||||||
const ext = filename.split(".").pop()?.toLowerCase() || "";
|
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 docExts = ["md", "txt", "doc", "docx", "pdf", "rst", "tex"];
|
||||||
const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"];
|
const imageExts = ["png", "jpg", "jpeg", "gif", "svg", "ico", "webp", "bmp"];
|
||||||
const musicExts = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"];
|
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 (docExts.includes(ext)) return "documents";
|
||||||
if (imageExts.includes(ext)) return "images";
|
if (imageExts.includes(ext)) return "images";
|
||||||
if (musicExts.includes(ext)) return "music";
|
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";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
function classifyFolderType(name: string): ProjectFolder["type"] {
|
function classifyFolderType(name: string): ProjectFolder["type"] {
|
||||||
const lower = name.toLowerCase();
|
const lower = name.toLowerCase();
|
||||||
if (lower.includes("music")) return "music";
|
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("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";
|
return "workspace";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,18 +365,19 @@ async function discoverGitRepos(homeDir: string): Promise<RepoInfo[]> {
|
|||||||
const repos: RepoInfo[] = [];
|
const repos: RepoInfo[] = [];
|
||||||
|
|
||||||
try {
|
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);
|
const gitDirs = await findGitDirectories(homeDir);
|
||||||
|
|
||||||
for (const repoDir of gitDirs) {
|
for (const repoDir of gitDirs) {
|
||||||
try {
|
try {
|
||||||
const repo = await analyzeGitRepo(repoDir, homeDir);
|
const repo = await analyzeGitRepo(repoDir, homeDir);
|
||||||
if (repo) repos.push(repo);
|
if (repo) repos.push(repo);
|
||||||
} catch { /* skip broken repos */ }
|
} catch {
|
||||||
|
/* skip broken repos */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* git not available */
|
||||||
}
|
}
|
||||||
} catch { /* git not available */ }
|
|
||||||
|
|
||||||
return repos;
|
return repos;
|
||||||
}
|
}
|
||||||
@ -283,31 +393,43 @@ async function findGitDirectories(dir: string, depth: number = 0): Promise<strin
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
if (entry.name === ".git") {
|
if (entry.name === ".git") {
|
||||||
results.push(dirname(fullPath));
|
results.push(dirname(fullPath));
|
||||||
} else if (entry.name !== "node_modules" && entry.name !== ".git" && entry.name !== ".venv" && entry.name !== "dist") {
|
} else if (
|
||||||
results.push(...await findGitDirectories(fullPath, depth + 1));
|
entry.name !== "node_modules" &&
|
||||||
|
entry.name !== ".git" &&
|
||||||
|
entry.name !== ".venv" &&
|
||||||
|
entry.name !== "dist"
|
||||||
|
) {
|
||||||
|
results.push(...(await findGitDirectories(fullPath, depth + 1)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* permission denied */ }
|
} catch {
|
||||||
|
/* permission denied */
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function analyzeGitRepo(repoDir: string, homeDir: string): Promise<RepoInfo | null> {
|
async function analyzeGitRepo(repoDir: string, _homeDir: string): Promise<RepoInfo | null> {
|
||||||
try {
|
try {
|
||||||
const name = repoDir.split("/").pop() || "";
|
const name = repoDir.split("/").pop() || "";
|
||||||
const relPath = repoDir.replace(homeDir + "/", "");
|
|
||||||
|
|
||||||
// Get remote URL
|
// 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;
|
const remote = remoteResult.stdout.trim() || null;
|
||||||
|
|
||||||
// Get current branch
|
// 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";
|
const branch = branchResult.stdout.trim() || "unknown";
|
||||||
|
|
||||||
// Get last commit
|
// 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;
|
const lastCommit = commitResult.stdout.trim() || null;
|
||||||
|
|
||||||
// Get language stats via git ls-files + wc
|
// Get language stats via git ls-files + wc
|
||||||
@ -326,19 +448,34 @@ async function analyzeGitRepo(repoDir: string, homeDir: string): Promise<RepoInf
|
|||||||
async function getRepoLanguages(repoDir: string): Promise<Record<string, number>> {
|
async function getRepoLanguages(repoDir: string): Promise<Record<string, number>> {
|
||||||
const langs: Record<string, number> = {};
|
const langs: Record<string, number> = {};
|
||||||
try {
|
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);
|
const files = stdout.trim().split("\n").filter(Boolean);
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const ext = file.split(".").pop()?.toLowerCase() || "";
|
const ext = file.split(".").pop()?.toLowerCase() || "";
|
||||||
const langMap: Record<string, string> = {
|
const langMap: Record<string, string> = {
|
||||||
ts: "TypeScript", tsx: "TypeScript", js: "JavaScript", jsx: "JavaScript",
|
ts: "TypeScript",
|
||||||
py: "Python", go: "Go", rs: "Rust", java: "Java", c: "C", cpp: "C++",
|
tsx: "TypeScript",
|
||||||
rb: "Ruby", php: "PHP", swift: "Swift", kt: "Kotlin",
|
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";
|
const lang = langMap[ext] || ext || "unknown";
|
||||||
langs[lang] = (langs[lang] || 0) + 1;
|
langs[lang] = (langs[lang] || 0) + 1;
|
||||||
}
|
}
|
||||||
} catch { /* no languages */ }
|
} catch {
|
||||||
|
/* no languages */
|
||||||
|
}
|
||||||
return langs;
|
return langs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -346,7 +483,11 @@ async function getRepoLanguages(repoDir: string): Promise<Record<string, number>
|
|||||||
// Phase 3: Profile compilation
|
// 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 interests: string[] = [];
|
||||||
const skills: string[] = [];
|
const skills: string[] = [];
|
||||||
const techStack: string[] = [];
|
const techStack: string[] = [];
|
||||||
@ -369,9 +510,16 @@ function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: Pr
|
|||||||
|
|
||||||
// Derive skills from languages
|
// Derive skills from languages
|
||||||
const langToSkill: Record<string, string> = {
|
const langToSkill: Record<string, string> = {
|
||||||
"TypeScript": "TypeScript/JavaScript", Python: "Python", Go: "Go",
|
TypeScript: "TypeScript/JavaScript",
|
||||||
"Rust": "Rust", Java: "Java", C: "C/C++", "C++": "C/C++", Ruby: "Ruby",
|
Python: "Python",
|
||||||
"Swift": "Swift/Kotlin", PHP: "PHP",
|
Go: "Go",
|
||||||
|
Rust: "Rust",
|
||||||
|
Java: "Java",
|
||||||
|
C: "C/C++",
|
||||||
|
"C++": "C/C++",
|
||||||
|
Ruby: "Ruby",
|
||||||
|
Swift: "Swift/Kotlin",
|
||||||
|
PHP: "PHP",
|
||||||
};
|
};
|
||||||
for (const lang of techStack) {
|
for (const lang of techStack) {
|
||||||
const skill = langToSkill[lang];
|
const skill = langToSkill[lang];
|
||||||
@ -386,7 +534,12 @@ function compileProfile(repos: RepoInfo[], fileSummary: FileSummary, folders: Pr
|
|||||||
return {
|
return {
|
||||||
interests: uniqueInterests,
|
interests: uniqueInterests,
|
||||||
skills: uniqueSkills,
|
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",
|
communication: "structured",
|
||||||
languages: Array.from(languageSet),
|
languages: Array.from(languageSet),
|
||||||
techStack: uniqueTech,
|
techStack: uniqueTech,
|
||||||
@ -413,13 +566,19 @@ function generateBriefing(
|
|||||||
lines.push(``);
|
lines.push(``);
|
||||||
lines.push(`## Local Repositories (${repos.length} found)`);
|
lines.push(`## Local Repositories (${repos.length} found)`);
|
||||||
for (const repo of repos) {
|
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(``);
|
||||||
lines.push(`## File System Summary`);
|
lines.push(`## File System Summary`);
|
||||||
lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`);
|
lines.push(`- **Total Files:** ${fileSummary.totalFiles.toLocaleString()}`);
|
||||||
lines.push(`- **Total Size:** ${(fileSummary.totalSizeBytes / (1024 * 1024)).toFixed(0)} MB`);
|
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(``);
|
||||||
lines.push(`## User Interests`);
|
lines.push(`## User Interests`);
|
||||||
lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`);
|
lines.push(`- ${profile.interests.slice(0, 5).join("\n- ")}`);
|
||||||
@ -437,7 +596,9 @@ function generateBriefing(
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
}
|
}
|
||||||
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");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -453,14 +614,24 @@ async function createProjectFolders(repos: RepoInfo[], prisma: any): Promise<Cre
|
|||||||
// Check if space already exists
|
// Check if space already exists
|
||||||
const existing = await prisma.space.findFirst({ where: { title: repo.name } });
|
const existing = await prisma.space.findFirst({ where: { title: repo.name } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
created.push({ id: existing.id, name: repo.name, spaceId: existing.id, description: repo.remote || repo.path });
|
created.push({
|
||||||
|
id: existing.id,
|
||||||
|
name: repo.name,
|
||||||
|
spaceId: existing.id,
|
||||||
|
description: repo.remote || repo.path,
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Create new space
|
// Create new space
|
||||||
const space = await prisma.space.create({
|
const space = await prisma.space.create({
|
||||||
data: { title: repo.name, path: repo.path },
|
data: { title: repo.name, path: repo.path },
|
||||||
});
|
});
|
||||||
created.push({ id: space.id, name: repo.name, spaceId: space.id, description: repo.remote || repo.path });
|
created.push({
|
||||||
|
id: space.id,
|
||||||
|
name: repo.name,
|
||||||
|
spaceId: space.id,
|
||||||
|
description: repo.remote || repo.path,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Space might already exist or DB unavailable — skip
|
// Space might already exist or DB unavailable — skip
|
||||||
}
|
}
|
||||||
@ -484,11 +655,25 @@ interface SubAgentRole {
|
|||||||
function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] {
|
function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubAgentRole[] {
|
||||||
const roles: SubAgentRole[] = [];
|
const roles: SubAgentRole[] = [];
|
||||||
const hasCode = profile.languages?.length > 0;
|
const hasCode = profile.languages?.length > 0;
|
||||||
const hasDesign = profile.interests?.some(i => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"));
|
const hasDesign = profile.interests?.some(
|
||||||
const hasData = profile.interests?.some(i => i.toLowerCase().includes("data") || i.toLowerCase().includes("analytics") || i.toLowerCase().includes("database"));
|
(i) => i.toLowerCase().includes("design") || i.toLowerCase().includes("ui"),
|
||||||
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 hasData = profile.interests?.some(
|
||||||
const hasWriting = profile.interests?.some(i => i.toLowerCase().includes("write") || i.toLowerCase().includes("blog"));
|
(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) {
|
if (hasCode) {
|
||||||
const primaryLang = profile.languages?.[0] || "TypeScript";
|
const primaryLang = profile.languages?.[0] || "TypeScript";
|
||||||
@ -497,7 +682,9 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
|||||||
role: "software-engineer",
|
role: "software-engineer",
|
||||||
description: `Specializes in ${primaryLang} development and code review`,
|
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.`,
|
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",
|
name: "UI/UX Designer",
|
||||||
role: "ui-designer",
|
role: "ui-designer",
|
||||||
description: "Creates polished interfaces and design systems",
|
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"],
|
skills: ["design", "UI/UX"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -516,7 +704,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
|||||||
name: "Data Analyst",
|
name: "Data Analyst",
|
||||||
role: "data-analyst",
|
role: "data-analyst",
|
||||||
description: "Analyzes data, creates reports, and extracts insights",
|
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"],
|
skills: ["data analysis", "statistics"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -526,7 +715,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
|||||||
name: "DevOps Engineer",
|
name: "DevOps Engineer",
|
||||||
role: "devops-engineer",
|
role: "devops-engineer",
|
||||||
description: "Manages infrastructure, deployments, and CI/CD",
|
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"],
|
skills: ["DevOps", "infrastructure"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -536,7 +726,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
|||||||
name: "Music Producer",
|
name: "Music Producer",
|
||||||
role: "music-producer",
|
role: "music-producer",
|
||||||
description: "Assists with music production and audio engineering",
|
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"],
|
skills: ["music production", "audio engineering"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -546,7 +737,8 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
|||||||
name: "Content Writer",
|
name: "Content Writer",
|
||||||
role: "content-writer",
|
role: "content-writer",
|
||||||
description: "Creates and edits written content",
|
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"],
|
skills: ["writing", "content creation"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -556,7 +748,12 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
|||||||
name: "General Assistant",
|
name: "General Assistant",
|
||||||
role: "general-assistant",
|
role: "general-assistant",
|
||||||
description: "Handles general tasks and coordinates other specialists",
|
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"],
|
skills: ["task management", "coordination"],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -567,13 +764,21 @@ function generateSubAgentRoles(profile: ProfileSummary, repos: RepoInfo[]): SubA
|
|||||||
// Utility
|
// 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(cmd, args, { ...options, timeout: 15000 });
|
const child = spawn(cmd, args, { ...options, timeout: 15000 });
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
child.stdout?.on("data", (d: Buffer) => { stdout += d.toString(); });
|
child.stdout?.on("data", (d: Buffer) => {
|
||||||
child.stderr?.on("data", (d: Buffer) => { stderr += d.toString(); });
|
stdout += d.toString();
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (d: Buffer) => {
|
||||||
|
stderr += d.toString();
|
||||||
|
});
|
||||||
child.on("error", () => reject(new Error("spawn failed")));
|
child.on("error", () => reject(new Error("spawn failed")));
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
if (code === 0 || code === null) resolve({ stdout, stderr });
|
if (code === 0 || code === null) resolve({ stdout, stderr });
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
type Prisma,
|
type Prisma,
|
||||||
type PrismaClient,
|
type PrismaClient,
|
||||||
type ThreadEvents,
|
type ThreadEvents,
|
||||||
createPool,
|
|
||||||
} from "@rakazo/db";
|
} from "@rakazo/db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -39,7 +39,7 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) {
|
|||||||
if (page.url().includes("/onboarding")) {
|
if (page.url().includes("/onboarding")) {
|
||||||
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
await expect(page.locator('[data-rakazo-surface="onboarding"]')).toHaveAttribute(
|
||||||
"data-onboarding-step",
|
"data-onboarding-step",
|
||||||
/^(emoji|preferences|language|voice|integrations)$/,
|
/^(emoji|preferences|language|voice|integrations|scan|bot)$/,
|
||||||
{ timeout: 30_000 },
|
{ 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.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 expect(chief).toBeVisible({ timeout: 15_000 });
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
|
||||||
if (testInfo) {
|
if (testInfo) {
|
||||||
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
await captureScreenshot(page, testInfo, "03-create-first-bot");
|
||||||
@ -181,6 +183,14 @@ export async function openUserSettings(
|
|||||||
return settings;
|
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). */
|
/** Create a named bot via RPC for test setup (skips the + picker). */
|
||||||
export async function createNamedBot(
|
export async function createNamedBot(
|
||||||
page: Page,
|
page: Page,
|
||||||
@ -194,7 +204,8 @@ export async function createNamedBot(
|
|||||||
notifyOnFinish: true,
|
notifyOnFinish: true,
|
||||||
computerMode: options.computerMode ?? "team",
|
computerMode: options.computerMode ?? "team",
|
||||||
});
|
});
|
||||||
await page.goto(`/app/${bot.id}`);
|
await page.goto(`/app/${bot.id}`, { waitUntil: "domcontentloaded" });
|
||||||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
await page.waitForURL(/\/app\/[^/]+$/, { timeout: 20_000 });
|
||||||
|
await waitForChatComposer(page);
|
||||||
return bot.id;
|
return bot.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,24 +9,38 @@ interface ScanStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PHASES = [
|
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: "scanning" as const,
|
||||||
{ key: "building" as const, label: "Building your profile...", sublabel: "Interests, skills, preferences" },
|
label: "Scanning your computer...",
|
||||||
{ key: "briefing" as const, label: "Setting up your agent team...", sublabel: "Project folders, role assignments" },
|
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;
|
] as const;
|
||||||
|
|
||||||
export function OnboardingScanProgress({
|
export function OnboardingScanProgress({
|
||||||
onComplete,
|
onComplete,
|
||||||
botId,
|
botId,
|
||||||
onScanComplete,
|
|
||||||
}: {
|
}: {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
botId?: string;
|
botId?: string;
|
||||||
onScanComplete?: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [status, setStatus] = useState<ScanStatus | null>(null);
|
const [status, setStatus] = useState<ScanStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [scanId, setScanId] = useState<string | null>(null);
|
const [, setScanId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@ -34,7 +48,7 @@ export function OnboardingScanProgress({
|
|||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
try {
|
try {
|
||||||
const scanResult = await rpc.onboarding.scan({ botId: botId ?? "" });
|
const scanResult = await rpc.onboarding.scan(botId ? { botId } : {});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setScanId(scanResult.scanId);
|
setScanId(scanResult.scanId);
|
||||||
|
|
||||||
@ -45,7 +59,6 @@ export function OnboardingScanProgress({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setStatus(s);
|
setStatus(s);
|
||||||
if (s.status === "complete") {
|
if (s.status === "complete") {
|
||||||
onScanComplete?.();
|
|
||||||
onComplete();
|
onComplete();
|
||||||
} else if (s.status === "failed") {
|
} else if (s.status === "failed") {
|
||||||
setError(s.message);
|
setError(s.message);
|
||||||
@ -68,13 +81,13 @@ export function OnboardingScanProgress({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (pollingInterval) clearInterval(pollingInterval);
|
if (pollingInterval) clearInterval(pollingInterval);
|
||||||
};
|
};
|
||||||
}, [botId, onComplete, onScanComplete]);
|
}, [botId, onComplete]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-xl text-center">
|
<div className="mx-auto max-w-xl text-center">
|
||||||
<p className="text-destructive">{error}</p>
|
<p className="text-destructive">{error}</p>
|
||||||
<button className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
<button type="button" className="mt-4 rounded-lg border px-4 py-2" onClick={onComplete}>
|
||||||
Continue anyway
|
Continue anyway
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -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";
|
const isComplete = status.status === "complete";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -121,15 +134,23 @@ export function OnboardingScanProgress({
|
|||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
{isDone ? (
|
{isDone ? (
|
||||||
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">✓</span>
|
<span className="grid size-6 items-center justify-center rounded-full bg-emerald-500/20 text-emerald-500 text-sm">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
) : isActive ? (
|
) : isActive ? (
|
||||||
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">●</span>
|
<span className="grid size-6 items-center justify-center rounded-full bg-primary/20 animate-pulse text-primary text-sm">
|
||||||
|
●
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">○</span>
|
<span className="grid size-6 items-center justify-center rounded-full bg-muted/30 text-muted-foreground text-sm">
|
||||||
|
○
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}>
|
<p
|
||||||
|
className={`text-sm font-medium ${isActive ? "text-foreground" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
{phase.label}
|
{phase.label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
|
<p className="text-xs text-muted-foreground/60">{phase.sublabel}</p>
|
||||||
@ -152,6 +173,7 @@ export function OnboardingScanProgress({
|
|||||||
{isComplete && (
|
{isComplete && (
|
||||||
<div className="mt-8 text-center">
|
<div className="mt-8 text-center">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
|
className="rounded-xl bg-primary px-6 py-3 text-sm font-medium text-white"
|
||||||
onClick={onComplete}
|
onClick={onComplete}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -158,7 +158,6 @@ export function OnboardingPage() {
|
|||||||
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
const [bootstrapMe, setBootstrapMe] = useState<Awaited<ReturnType<typeof rpc.me>> | null>(null);
|
||||||
const [profilePersisted, setProfilePersisted] = useState(false);
|
const [profilePersisted, setProfilePersisted] = useState(false);
|
||||||
const [crewReady, setCrewReady] = useState(false);
|
const [crewReady, setCrewReady] = useState(false);
|
||||||
const [scanComplete, setScanComplete] = useState(false);
|
|
||||||
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
const [_helpVibe, setHelpVibe] = useState<UserHelpVibeHints | null>(null);
|
||||||
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
const [scanBotId, setScanBotId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -188,9 +187,13 @@ export function OnboardingPage() {
|
|||||||
// Get bot ID for scan phase
|
// Get bot ID for scan phase
|
||||||
try {
|
try {
|
||||||
const botList = await rpc.bots.list();
|
const botList = await rpc.bots.list();
|
||||||
const firstBot = botList.find(b => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME);
|
const firstBot = botList.find(
|
||||||
|
(b) => b.spawnKey === FIRST_BOT_SPAWN_KEY || b.name === FIRST_BOT_NAME,
|
||||||
|
);
|
||||||
if (firstBot && !cancelled) setScanBotId(firstBot.id);
|
if (firstBot && !cancelled) setScanBotId(firstBot.id);
|
||||||
} catch { /* best effort */ }
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
setIntegrationSetup(integrations);
|
setIntegrationSetup(integrations);
|
||||||
setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji");
|
setStep(me.needsModel ? "emoji" : integrations?.needsSetup ? "integrations" : "emoji");
|
||||||
// A space that already has a bot does not need the designer again.
|
// A space that already has a bot does not need the designer again.
|
||||||
@ -312,9 +315,9 @@ export function OnboardingPage() {
|
|||||||
const createStartedRef = useRef(false);
|
const createStartedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step !== "bot" || !scanComplete) return;
|
if (step !== "bot") return;
|
||||||
void createFirstBot();
|
void createFirstBot();
|
||||||
}, [step, scanComplete]);
|
}, [step]);
|
||||||
|
|
||||||
function goAfterVoice(skipped: boolean) {
|
function goAfterVoice(skipped: boolean) {
|
||||||
setVoiceSkipped(skipped);
|
setVoiceSkipped(skipped);
|
||||||
@ -544,9 +547,8 @@ export function OnboardingPage() {
|
|||||||
|
|
||||||
{step === "scan" ? (
|
{step === "scan" ? (
|
||||||
<OnboardingScanProgress
|
<OnboardingScanProgress
|
||||||
onComplete={() => {}}
|
onComplete={() => setStep("bot")}
|
||||||
botId={scanBotId ?? undefined}
|
botId={scanBotId ?? undefined}
|
||||||
onScanComplete={() => setScanComplete(true)}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@ -650,7 +650,7 @@ export const appContract = {
|
|||||||
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
|
z.object({ botId: Id, provider: z.string(), connectorId: z.string().default("composio") }),
|
||||||
)
|
)
|
||||||
.output(z.object({ ok: z.literal(true) })),
|
.output(z.object({ ok: z.literal(true) })),
|
||||||
scan: oc.input(z.object({ botId: Id })).output(z.object({ scanId: z.string() })),
|
scan: oc.input(z.object({ botId: Id.optional() })).output(z.object({ scanId: z.string() })),
|
||||||
scanStatus: oc.input(z.object({ scanId: z.string() })).output(
|
scanStatus: oc.input(z.object({ scanId: z.string() })).output(
|
||||||
z.object({
|
z.object({
|
||||||
status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]),
|
status: z.enum(["scanning", "connecting", "building", "briefing", "complete", "failed"]),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user