mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user