feat(onboarding): suggest projects from agent history

The setup's Projects step lists the repos the user already ran agents in,
with busy recent ones preselected, so a new user adds their projects in one
click instead of browsing for folders. With rows checked, the footer's main
button adds them and only moves on once every one was added; a checked row
always means "this will be added".

The daemon resolves each session folder to its project root (main checkout
for worktrees, repo root for subfolders) because only it can read the git
state; the app asks once per provider so a slow or broken provider never
holds up the others and can be retried on its own. Hosts without the
projectSuggestions feature show only the Add project button.

Onboarding e2e hosts read an empty Claude history with other providers
off, so suggestions never depend on the machine running the tests.
This commit is contained in:
Mohamed Boudra
2026-10-31 10:37:51 +01:00
parent 76ba3fdbe0
commit 1284ff355f
29 changed files with 1562 additions and 46 deletions
@@ -380,7 +380,7 @@ function toProviderSessionHandleKey(provider: string, providerHandleId: string):
return `${provider}\0${providerHandleId}`;
}
function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
export function isMetadataGenerationSession(input: { firstPromptPreview: string | null }): boolean {
return (
input.firstPromptPreview?.trimStart().startsWith(METADATA_GENERATION_PROMPT_PREFIX) ?? false
);
@@ -153,6 +153,7 @@ const INBOUND_PERMISSION = {
"project.icon.get.request": "workspace.read",
"project.icon.set.request": "workspace.manage",
"project.list.request": "workspace.read",
"project.suggestions.request": "workspace.read",
"project.remove.request": "workspace.manage",
"project.rename.request": "workspace.manage",
project_icon_request: "workspace.read",
@@ -390,6 +391,7 @@ const OUTBOUND_PERMISSION = {
"project.icon.get.response": "workspace.read",
"project.icon.set.response": "workspace.manage",
"project.list.response": "workspace.read",
"project.suggestions.response": "workspace.read",
"project.remove.response": "workspace.manage",
"project.rename.response": "workspace.manage",
"project.update": "workspace.read",
@@ -0,0 +1,187 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { createTestLogger } from "../test-utils/test-logger.js";
import type {
AgentManager,
ImportableSessionProviderError,
ManagedImportableProviderSession,
} from "./agent/agent-manager.js";
import { listProjectSuggestions } from "./project-suggestions.js";
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
import type { PersistedProjectRecord } from "./workspace-registry.js";
const NOW = new Date("2026-10-31T12:00:00.000Z");
function daysAgo(days: number): Date {
return new Date(NOW.getTime() - days * 24 * 60 * 60 * 1000);
}
function git(cwd: string, ...args: string[]): void {
execFileSync("git", args, { cwd, stdio: "ignore" });
}
function createRepo(root: string, name: string): string {
const repo = path.join(root, name);
mkdirSync(repo, { recursive: true });
git(repo, "init", "-b", "main");
git(repo, "config", "user.email", "test@example.com");
git(repo, "config", "user.name", "Test");
writeFileSync(path.join(repo, "README.md"), "hello\n");
git(repo, "add", ".");
git(repo, "commit", "-m", "init");
return repo;
}
function session(
provider: string,
cwd: string,
lastActivityAt: Date,
): ManagedImportableProviderSession {
return {
provider,
providerHandleId: `${provider}-${cwd}-${lastActivityAt.toISOString()}`,
cwd,
title: null,
firstPromptPreview: "Fix the build",
lastPromptPreview: null,
lastActivityAt,
};
}
/** Agent history as the providers report it. */
function agentHistory(
sessions: ManagedImportableProviderSession[],
providerErrors: ImportableSessionProviderError[] = [],
): Pick<AgentManager, "listImportableSessions"> {
return {
listImportableSessions: async (options) => ({
sessions: sessions.filter(
(entry) => !options?.providerFilter || options.providerFilter.has(entry.provider),
),
providerErrors,
}),
};
}
function projects(...rootPaths: Array<{ rootPath: string; archived?: boolean }>) {
const records = rootPaths.map(
({ rootPath, archived }, index): PersistedProjectRecord => ({
projectId: `project-${index}`,
rootPath,
kind: "git",
displayName: path.basename(rootPath),
projectKey: null,
customName: null,
createdAt: NOW.toISOString(),
updatedAt: NOW.toISOString(),
archivedAt: archived ? NOW.toISOString() : null,
}),
);
return { list: async () => records };
}
describe("listProjectSuggestions", () => {
let root: string;
let gitService: WorkspaceGitServiceImpl;
beforeEach(() => {
root = realpathSync(mkdtempSync(path.join(tmpdir(), "project-suggestions-")));
gitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: path.join(root, ".paseo"),
});
});
afterEach(async () => {
await gitService.dispose();
rmSync(root, { recursive: true, force: true });
});
test("groups sessions in a repo, its subdirectories and its worktrees under the main checkout", async () => {
const app = createRepo(root, "app");
mkdirSync(path.join(app, "packages", "web"), { recursive: true });
const worktree = path.join(root, "codex-worktrees", "app-feature");
git(app, "worktree", "add", "-b", "feature", worktree);
const result = await listProjectSuggestions({
request: {},
agentManager: agentHistory([
session("claude", app, daysAgo(1)),
session("claude", path.join(app, "packages", "web"), daysAgo(2)),
session("codex", worktree, daysAgo(3)),
]),
projectRegistry: projects(),
workspaceGitService: gitService,
now: NOW,
});
expect(result).toEqual({
suggestions: [
{
rootPath: app,
displayName: "app",
sessionCount: 3,
providerIds: ["claude", "codex"],
lastActivityAt: daysAgo(1).toISOString(),
},
],
providerErrors: [],
});
});
test("leaves out active projects, deleted folders, old sessions and the home directory", async () => {
const added = createRepo(root, "already-added");
const removed = createRepo(root, "removed-project");
const notes = path.join(root, "notes");
mkdirSync(notes);
const result = await listProjectSuggestions({
request: {},
agentManager: agentHistory([
session("claude", added, daysAgo(1)),
session("claude", removed, daysAgo(2)),
session("claude", notes, daysAgo(4)),
session("claude", path.join(root, "deleted"), daysAgo(1)),
session("claude", createRepo(root, "stale"), daysAgo(45)),
session("claude", homedir(), daysAgo(1)),
]),
projectRegistry: projects({ rootPath: added }, { rootPath: removed, archived: true }),
workspaceGitService: gitService,
now: NOW,
});
expect(result.suggestions.map((suggestion) => suggestion.rootPath)).toEqual([removed, notes]);
});
test("reads only the requested providers and reports the ones that failed", async () => {
const app = createRepo(root, "app");
const api = createRepo(root, "api");
const result = await listProjectSuggestions({
request: { providers: ["codex"] },
agentManager: agentHistory(
[session("claude", app, daysAgo(1)), session("codex", api, daysAgo(1))],
[{ provider: "codex", message: "Timed out listing sessions" }],
),
projectRegistry: projects(),
workspaceGitService: gitService,
now: NOW,
});
expect(result).toEqual({
suggestions: [
{
rootPath: api,
displayName: "api",
sessionCount: 1,
providerIds: ["codex"],
lastActivityAt: daysAgo(1).toISOString(),
},
],
providerErrors: [{ provider: "codex", message: "Timed out listing sessions" }],
});
});
});
@@ -0,0 +1,176 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, parse, resolve } from "node:path";
import pLimit from "p-limit";
import type { ProjectSuggestion, ProjectSuggestionsRequest } from "@getpaseo/protocol/messages";
import type { AgentManager, ManagedImportableProviderSession } from "./agent/agent-manager.js";
import { isMetadataGenerationSession } from "./agent/import-sessions.js";
import type { WorkspaceGitService } from "./workspace-git-service.js";
import type { ProjectRegistry } from "./workspace-registry.js";
import { normalizePathForIdentity } from "../utils/path.js";
const DEFAULT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
const DEFAULT_LIMIT = 20;
// Sessions read per provider. Enough history to find every repo used in the window.
const SESSION_SCAN_LIMIT = 500;
const CHECKOUT_READ_CONCURRENCY = 8;
export class ProjectSuggestionsRequestError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "ProjectSuggestionsRequestError";
}
}
export interface ListProjectSuggestionsInput {
request: Pick<ProjectSuggestionsRequest, "providers" | "since" | "limit">;
agentManager: Pick<AgentManager, "listImportableSessions">;
projectRegistry: Pick<ProjectRegistry, "list">;
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">;
now?: Date;
}
export interface ListProjectSuggestionsResult {
suggestions: ProjectSuggestion[];
providerErrors: Array<{ provider: string; message: string }>;
}
/**
* Turns the agent sessions a user already ran into projects they could add. Sessions are grouped
* by repository: a session in a subdirectory or in any worktree counts toward the main checkout,
* because that is the path a project is keyed by.
*/
export async function listProjectSuggestions(
input: ListProjectSuggestionsInput,
): Promise<ListProjectSuggestionsResult> {
const now = input.now ?? new Date();
const since = parseSince(input.request.since, now);
const listing = await input.agentManager.listImportableSessions({
limit: SESSION_SCAN_LIMIT,
scanLimit: SESSION_SCAN_LIMIT,
providerFilter: input.request.providers ? new Set(input.request.providers) : undefined,
});
const sessions = listing.sessions.filter(
(session) => session.lastActivityAt.getTime() >= since && !isMetadataGenerationSession(session),
);
const rootByCwd = await resolveProjectRoots(
new Set(sessions.map((session) => resolve(session.cwd))),
input.workspaceGitService,
);
const excludedRoots = await collectExcludedRoots(input.projectRegistry);
const groups = new Map<string, SuggestionGroup>();
for (const session of sessions) {
const rootPath = rootByCwd.get(resolve(session.cwd));
if (!rootPath || excludedRoots.has(normalizePathForIdentity(rootPath))) {
continue;
}
addSession(groups, rootPath, session);
}
const suggestions = Array.from(groups.values())
.sort((a, b) => b.lastActivityAt - a.lastActivityAt)
.slice(0, input.request.limit ?? DEFAULT_LIMIT)
.map(toSuggestion);
return { suggestions, providerErrors: listing.providerErrors };
}
interface SuggestionGroup {
rootPath: string;
sessionCount: number;
providerIds: Set<string>;
lastActivityAt: number;
}
function parseSince(since: string | undefined, now: Date): number {
if (!since) {
return now.getTime() - DEFAULT_WINDOW_MS;
}
const timestamp = Date.parse(since);
if (Number.isNaN(timestamp)) {
throw new ProjectSuggestionsRequestError("invalid_since", `Invalid since timestamp: ${since}`);
}
return timestamp;
}
/** Maps each session cwd to the folder a project would be added at, or null when it is gone. */
async function resolveProjectRoots(
cwds: Set<string>,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<Map<string, string | null>> {
const limit = pLimit(CHECKOUT_READ_CONCURRENCY);
const entries = await Promise.all(
Array.from(cwds, (cwd) =>
limit(async () => [cwd, await resolveProjectRoot(cwd, workspaceGitService)] as const),
),
);
return new Map(entries);
}
async function resolveProjectRoot(
cwd: string,
workspaceGitService: Pick<WorkspaceGitService, "getCheckout">,
): Promise<string | null> {
if (!(await isDirectory(cwd))) {
return null;
}
const checkout = await workspaceGitService.getCheckout(cwd);
const rootPath = checkout.mainRepoRoot ?? checkout.worktreeRoot ?? cwd;
if (rootPath !== cwd && !(await isDirectory(rootPath))) {
return null;
}
return rootPath;
}
/** Active projects are already added; the home directory and filesystem root are not projects. */
async function collectExcludedRoots(
projectRegistry: Pick<ProjectRegistry, "list">,
): Promise<Set<string>> {
const home = homedir();
const excluded = new Set([home, parse(home).root].map(normalizePathForIdentity));
for (const project of await projectRegistry.list()) {
if (!project.archivedAt) {
excluded.add(normalizePathForIdentity(project.rootPath));
}
}
return excluded;
}
function addSession(
groups: Map<string, SuggestionGroup>,
rootPath: string,
session: ManagedImportableProviderSession,
): void {
const key = normalizePathForIdentity(rootPath);
const group = groups.get(key) ?? {
rootPath,
sessionCount: 0,
providerIds: new Set<string>(),
lastActivityAt: 0,
};
group.sessionCount += 1;
group.providerIds.add(session.provider);
group.lastActivityAt = Math.max(group.lastActivityAt, session.lastActivityAt.getTime());
groups.set(key, group);
}
function toSuggestion(group: SuggestionGroup): ProjectSuggestion {
return {
rootPath: group.rootPath,
displayName: basename(group.rootPath) || group.rootPath,
sessionCount: group.sessionCount,
providerIds: Array.from(group.providerIds).sort(),
lastActivityAt: new Date(group.lastActivityAt).toISOString(),
};
}
async function isDirectory(path: string): Promise<boolean> {
try {
return (await stat(path)).isDirectory();
} catch {
return false;
}
}
+34
View File
@@ -144,6 +144,7 @@ import {
listImportableProviderSessions,
normalizeImportAgentRequest,
} from "./agent/import-sessions.js";
import { listProjectSuggestions, ProjectSuggestionsRequestError } from "./project-suggestions.js";
import {
checkoutLiteFromGitSnapshot,
checkoutFromPersistedWorkspacePlacement,
@@ -3062,6 +3063,8 @@ export class Session {
return this.handleFetchWorkspacesRequest(msg);
case "project.list.request":
return this.handleProjectListRequest(msg);
case "project.suggestions.request":
return this.handleProjectSuggestionsRequest(msg);
case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request":
@@ -6550,6 +6553,37 @@ export class Session {
}
}
private async handleProjectSuggestionsRequest(
request: Extract<SessionInboundMessage, { type: "project.suggestions.request" }>,
): Promise<void> {
try {
const result = await listProjectSuggestions({
request,
agentManager: this.agentManager,
projectRegistry: this.projectRegistry,
workspaceGitService: this.workspaceGitService,
});
this.emit({
type: "project.suggestions.response",
payload: { requestId: request.requestId, ...result },
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle project.suggestions.request");
this.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: error instanceof Error ? error.message : "Failed to list project suggestions",
code:
error instanceof ProjectSuggestionsRequestError
? error.code
: "project_suggestions_failed",
},
});
}
}
private requireWorkspaceLabels(): WorkspaceLabelService {
if (!this.workspaceLabelService) {
throw new SessionRequestError("workspace_labels_unavailable", "Workspace labels unavailable");
@@ -2094,6 +2094,8 @@ export class VoiceAssistantWebSocketServer {
projectAdd: true,
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
projectList: true,
// COMPAT(projectSuggestions): added in v0.14.1, remove gate after 2027-05-01.
projectSuggestions: true,
// COMPAT(worktreeRestore): keep through 2027-01-11 for clients older than v0.1.105.
worktreeRestore: true,
// COMPAT(workspaceRecovery): added in v0.1.105, remove after 2027-01-11 once daemon floor >= v0.1.105.