mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +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:
@@ -1,7 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { expect, metroTest as test } from "../support/fixtures";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import {
|
||||
addProjectFromSetup,
|
||||
continueSetup,
|
||||
@@ -12,29 +12,27 @@ import {
|
||||
openTailscaleDocsFromSetup,
|
||||
paseoToolsSwitch,
|
||||
pickTheme,
|
||||
projectSuggestion,
|
||||
setupModal,
|
||||
setupPrimaryButton,
|
||||
showSetupAgainFromSettings,
|
||||
skipSetup,
|
||||
startSetupHost,
|
||||
} from "../support/helpers/onboarding";
|
||||
import { enableRelayAndExpectOffer } from "../support/helpers/pair-device";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
|
||||
|
||||
// Midnight's muted foreground (#9a9db0), which the step description is drawn in.
|
||||
const MIDNIGHT_MUTED_FOREGROUND = "rgb(154, 157, 176)";
|
||||
|
||||
function isolatedServerId(label: string): string {
|
||||
return `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
}
|
||||
|
||||
test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
const serverId = isolatedServerId("fresh");
|
||||
const daemon = await startIsolatedHostDaemon(serverId);
|
||||
const host = await startSetupHost("fresh");
|
||||
const repo = await createTempGitRepo("onboarding-folder-");
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, { serverId, port: daemon.port });
|
||||
await openAppAsNewUser(page, host);
|
||||
|
||||
await test.step("setup opens on Appearance and a theme applies at once", async () => {
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
@@ -52,9 +50,12 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
await page.screenshot({ path: testInfo.outputPath("2-providers.png") });
|
||||
});
|
||||
|
||||
await test.step("Add project opens the regular flow and the folder shows as added", async () => {
|
||||
await test.step("with no agent history, Add project opens the regular flow and the folder shows as added", async () => {
|
||||
await continueSetup(page);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(
|
||||
setupModal(page).getByText("No recent projects found", { exact: true }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await addProjectFromSetup(page, repo.path);
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(setupModal(page).getByText(repo.path, { exact: true })).toBeVisible();
|
||||
@@ -82,20 +83,97 @@ test("a new user is walked through setup once", async ({ page }, testInfo) => {
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await daemon.close();
|
||||
await host.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an existing user only sees setup when they ask for it", async ({ page }) => {
|
||||
const serverId = isolatedServerId("existing");
|
||||
const daemon = await startIsolatedHostDaemon(serverId);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: daemon.port });
|
||||
test("setup suggests the repos the user already ran agents in", async ({ page }, testInfo) => {
|
||||
const repo = await createTempGitRepo("onboarding-history-");
|
||||
const repoName = path.basename(repo.path);
|
||||
const subdirectory = path.join(repo.path, "packages", "web");
|
||||
await mkdir(subdirectory, { recursive: true });
|
||||
const worktree = `${repo.path}-fix`;
|
||||
execFileSync("git", ["worktree", "add", "-b", "fix", worktree], { cwd: repo.path });
|
||||
const scratch = await createTempDirectory("onboarding-scratch-");
|
||||
const scratchName = path.basename(scratch.path);
|
||||
const host = await startSetupHost("history", {
|
||||
claudeSessionsIn: [repo.path, subdirectory, worktree, scratch.path, scratch.path, scratch.path],
|
||||
withBrokenProvider: true,
|
||||
});
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, { serverId, port: daemon.port });
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
await goToSetupStep(page, "Projects");
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
|
||||
await test.step("the repo, its subfolder and its worktree become one checked row", async () => {
|
||||
await expect(projectSuggestion(page, repoName)).toBeChecked({ timeout: 60_000 });
|
||||
await expect(projectSuggestion(page, repoName)).toContainText("3 sessions");
|
||||
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||
await expect(setupModal(page).getByRole("checkbox")).toHaveCount(2);
|
||||
});
|
||||
|
||||
await test.step("a provider that cannot read its history offers Retry", async () => {
|
||||
await expect(
|
||||
setupModal(page).getByText("Couldn't read Broken ACP history", { exact: true }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(setupModal(page).getByRole("button", { name: "Retry" })).toBeVisible();
|
||||
});
|
||||
|
||||
await test.step("the footer adds what is checked; the Add project flow is separate", async () => {
|
||||
await expect(setupPrimaryButton(page, "Add 2 projects")).toBeVisible();
|
||||
await expect(setupModal(page).getByRole("button", { name: "Continue" })).toHaveCount(0);
|
||||
await expect(
|
||||
setupModal(page).getByRole("button", { name: "Add another project…", exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: testInfo.outputPath("8-project-suggestions.png") });
|
||||
await projectSuggestion(page, repoName).click();
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
await expect(setupPrimaryButton(page, "Continue")).toBeVisible();
|
||||
await projectSuggestion(page, repoName).click();
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
});
|
||||
|
||||
await test.step("a folder that cannot be added keeps the user on the step", async () => {
|
||||
await scratch.cleanup();
|
||||
await setupPrimaryButton(page, "Add 2 projects").click();
|
||||
await expect(setupPrimaryButton(page, "Add 1 project")).toBeVisible({ timeout: 30_000 });
|
||||
await expectSetupStep(page, "Add your projects");
|
||||
await expect(projectSuggestion(page, scratchName)).toBeChecked();
|
||||
await expect(projectSuggestion(page, scratchName)).toContainText("Directory not found");
|
||||
await expect(projectSuggestion(page, repoName)).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath("9-project-suggestion-failed.png") });
|
||||
});
|
||||
|
||||
await test.step("with nothing checked, Continue moves on without adding", async () => {
|
||||
await projectSuggestion(page, scratchName).click();
|
||||
await setupPrimaryButton(page, "Continue").click();
|
||||
await expectSetupStep(page, "Let agents use Paseo");
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: repoName }),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(
|
||||
page.locator('[data-testid^="sidebar-project-row-"]').filter({ hasText: scratchName }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
} finally {
|
||||
await host.close();
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
await repo.cleanup();
|
||||
await scratch.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("an existing user only sees setup when they ask for it", async ({ page }) => {
|
||||
const host = await startSetupHost("existing");
|
||||
const workspace = await seedWorkspace({ repoPrefix: "onboarding-existing-", port: host.port });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, host);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${workspace.workspaceId}`),
|
||||
page.getByTestId(`sidebar-workspace-row-${host.serverId}:${workspace.workspaceId}`),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
await expect(setupModal(page)).toHaveCount(0);
|
||||
|
||||
@@ -107,18 +185,17 @@ test("an existing user only sees setup when they ask for it", async ({ page }) =
|
||||
);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
await daemon.close();
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("setup choices carry over to Settings and the phone step pairs over relay", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const serverId = isolatedServerId("phone");
|
||||
const daemon = await startIsolatedHostDaemon(serverId, { mutableRelay: { enabled: false } });
|
||||
const host = await startSetupHost("phone", { mutableRelay: { enabled: false } });
|
||||
|
||||
try {
|
||||
await openAppAsNewUser(page, { serverId, port: daemon.port });
|
||||
await openAppAsNewUser(page, host);
|
||||
await expectSetupStep(page, "Welcome to Paseo");
|
||||
|
||||
await test.step("Paseo tools turned on in setup", async () => {
|
||||
@@ -144,11 +221,11 @@ test("setup choices carry over to Settings and the phone step pairs over relay",
|
||||
await test.step("Finish closes setup and Settings shows the same Paseo tools value", async () => {
|
||||
await finishSetup(page);
|
||||
await page.locator('[data-testid="sidebar-settings"]:visible').first().click();
|
||||
await openSettingsHostSection(page, serverId, "agents");
|
||||
await openSettingsHostSection(page, host.serverId, "agents");
|
||||
await expect(paseoToolsSwitch(page)).toBeChecked();
|
||||
await page.screenshot({ path: testInfo.outputPath("7-settings-agents.png") });
|
||||
});
|
||||
} finally {
|
||||
await daemon.close();
|
||||
await host.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface IsolatedHostDaemon {
|
||||
}
|
||||
|
||||
export interface IsolatedHostDaemonOptions {
|
||||
/** Written to the daemon's config.json; `mutableRelay` adds `daemon.relay` to it. */
|
||||
config?: { daemon?: Record<string, unknown>; [key: string]: unknown };
|
||||
environment?: NodeJS.ProcessEnv;
|
||||
mutableRelay?: {
|
||||
enabled: boolean;
|
||||
@@ -93,6 +95,28 @@ async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
function withMutableRelay(
|
||||
config: NonNullable<IsolatedHostDaemonOptions["config"]>,
|
||||
relay: NonNullable<IsolatedHostDaemonOptions["mutableRelay"]>,
|
||||
): NonNullable<IsolatedHostDaemonOptions["config"]> {
|
||||
const endpoint =
|
||||
relay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
return {
|
||||
...config,
|
||||
daemon: {
|
||||
...config.daemon,
|
||||
relay: {
|
||||
enabled: relay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function startIsolatedHostDaemon(
|
||||
serverId: string,
|
||||
options: IsolatedHostDaemonOptions = {},
|
||||
@@ -138,24 +162,13 @@ export async function startIsolatedHostDaemon(
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (options.mutableRelay) {
|
||||
const endpoint =
|
||||
options.mutableRelay.endpoint ??
|
||||
(process.env.E2E_RELAY_PORT ? `127.0.0.1:${process.env.E2E_RELAY_PORT}` : "127.0.0.1:9");
|
||||
const config = options.mutableRelay
|
||||
? withMutableRelay(options.config ?? {}, options.mutableRelay)
|
||||
: options.config;
|
||||
if (config) {
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
`${JSON.stringify({
|
||||
version: 1,
|
||||
daemon: {
|
||||
relay: {
|
||||
enabled: options.mutableRelay.enabled,
|
||||
endpoint,
|
||||
publicEndpoint: endpoint,
|
||||
useTls: false,
|
||||
publicUseTls: false,
|
||||
},
|
||||
},
|
||||
})}\n`,
|
||||
`${JSON.stringify({ version: 1, ...config })}\n`,
|
||||
);
|
||||
}
|
||||
const serverDir = publishedPackageRoot
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { addProjectFlow, addProjectFlowInput, chooseAddProjectMethod } from "./add-project-flow";
|
||||
import { buildCreateAgentPreferences, buildSeededHost } from "./daemon-registry";
|
||||
import { startIsolatedHostDaemon, type IsolatedHostDaemonOptions } from "./isolated-host-daemon";
|
||||
|
||||
/** Points a fresh browser profile at one host, with no first-run record and no app settings. */
|
||||
export async function openAppAsNewUser(
|
||||
@@ -95,3 +100,93 @@ export async function openTailscaleDocsFromSetup(page: Page): Promise<string> {
|
||||
await docs.close();
|
||||
return url;
|
||||
}
|
||||
|
||||
export interface SetupHost {
|
||||
serverId: string;
|
||||
port: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* An isolated host whose only agent history is the Claude sessions given here, so suggestions
|
||||
* never depend on the machine running the test. `withBrokenProvider` adds a provider that cannot
|
||||
* start.
|
||||
*/
|
||||
export async function startSetupHost(
|
||||
label: string,
|
||||
options: {
|
||||
claudeSessionsIn?: readonly string[];
|
||||
withBrokenProvider?: boolean;
|
||||
mutableRelay?: IsolatedHostDaemonOptions["mutableRelay"];
|
||||
} = {},
|
||||
): Promise<SetupHost> {
|
||||
const serverId = `srv_onboarding_${label}_${randomUUID().replaceAll("-", "").slice(0, 8)}`;
|
||||
const claudeConfigDir = await seedClaudeHistory(options.claudeSessionsIn ?? []);
|
||||
const daemon = await startIsolatedHostDaemon(serverId, {
|
||||
config: claudeOnlyConfig({ withBrokenProvider: options.withBrokenProvider ?? false }),
|
||||
environment: { ...process.env, CLAUDE_CONFIG_DIR: claudeConfigDir },
|
||||
mutableRelay: options.mutableRelay,
|
||||
});
|
||||
return {
|
||||
serverId,
|
||||
port: daemon.port,
|
||||
close: async () => {
|
||||
await daemon.close();
|
||||
await rm(claudeConfigDir, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes one recent Claude session per folder into a fresh CLAUDE_CONFIG_DIR. */
|
||||
async function seedClaudeHistory(cwds: readonly string[]): Promise<string> {
|
||||
const configDir = await mkdtemp(path.join(tmpdir(), "paseo-onboarding-claude-"));
|
||||
const newest = Date.now() - 60_000;
|
||||
for (const [index, cwd] of cwds.entries()) {
|
||||
const sessionId = `onboarding-session-${index + 1}`;
|
||||
const projectDir = path.join(configDir, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"));
|
||||
await mkdir(projectDir, { recursive: true });
|
||||
const sessionPath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const record = {
|
||||
type: "user",
|
||||
uuid: `${sessionId}-user`,
|
||||
message: { role: "user", content: `Work on session ${index + 1}` },
|
||||
cwd,
|
||||
sessionId,
|
||||
};
|
||||
await writeFile(sessionPath, `${JSON.stringify(record)}\n`);
|
||||
const timestamp = new Date(newest - index * 60_000);
|
||||
await utimes(sessionPath, timestamp, timestamp);
|
||||
}
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function claudeOnlyConfig(options: { withBrokenProvider: boolean }) {
|
||||
const brokenProvider = {
|
||||
"broken-acp": {
|
||||
extends: "acp",
|
||||
label: "Broken ACP",
|
||||
command: ["missing-agent-command", "acp"],
|
||||
},
|
||||
};
|
||||
return {
|
||||
agents: {
|
||||
providers: {
|
||||
codex: { enabled: false },
|
||||
copilot: { enabled: false },
|
||||
omp: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
...(options.withBrokenProvider ? brokenProvider : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function projectSuggestion(page: Page, name: string): Locator {
|
||||
return setupModal(page).getByRole("checkbox", { name, exact: true });
|
||||
}
|
||||
|
||||
/** The footer's main button, which adds the checked suggestions when there are any. */
|
||||
export function setupPrimaryButton(page: Page, label: string): Locator {
|
||||
return setupModal(page).getByRole("button", { name: label, exact: true });
|
||||
}
|
||||
|
||||
@@ -1667,7 +1667,20 @@ export const ar: TranslationResources = {
|
||||
description:
|
||||
"أضف المجلدات التي تريد أن يعمل فيها الوكلاء. يمكنك إضافة المزيد لاحقًا من الشريط الجانبي.",
|
||||
addProject: "إضافة مشروع",
|
||||
addAnotherProject: "إضافة مشروع آخر…",
|
||||
added: "تمت الإضافة",
|
||||
suggestions: {
|
||||
loading: "جارٍ البحث عن المجلدات التي عملت فيها",
|
||||
stillReading: "ما زلنا نقرأ سجل {{providers}}…",
|
||||
readFailed: "تعذّرت قراءة سجل {{provider}}",
|
||||
empty: "لم يتم العثور على مشاريع حديثة",
|
||||
sessions_one: "جلسة واحدة ({{count}})",
|
||||
sessions_other: "{{count}} جلسات",
|
||||
addSelected_one: "إضافة مشروع واحد ({{count}})",
|
||||
addSelected_other: "إضافة {{count}} مشاريع",
|
||||
adding: "جارٍ الإضافة…",
|
||||
addFailed: "تعذّرت إضافة هذا المجلد",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "التنسيق",
|
||||
|
||||
@@ -1692,7 +1692,20 @@ export const en = {
|
||||
description:
|
||||
"Add the folders you want agents to work in. You can add more later from the sidebar.",
|
||||
addProject: "Add project",
|
||||
addAnotherProject: "Add another project…",
|
||||
added: "Added",
|
||||
suggestions: {
|
||||
loading: "Looking for folders you've worked in",
|
||||
stillReading: "Still reading {{providers}} history…",
|
||||
readFailed: "Couldn't read {{provider}} history",
|
||||
empty: "No recent projects found",
|
||||
sessions_one: "{{count}} session",
|
||||
sessions_other: "{{count}} sessions",
|
||||
addSelected_one: "Add {{count}} project",
|
||||
addSelected_other: "Add {{count}} projects",
|
||||
adding: "Adding…",
|
||||
addFailed: "Couldn't add this folder",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orchestration",
|
||||
|
||||
@@ -1713,7 +1713,20 @@ export const es: TranslationResources = {
|
||||
description:
|
||||
"Añade las carpetas en las que quieres que trabajen los agentes. Puedes añadir más después desde la barra lateral.",
|
||||
addProject: "Añadir proyecto",
|
||||
addAnotherProject: "Añadir otro proyecto…",
|
||||
added: "Añadido",
|
||||
suggestions: {
|
||||
loading: "Buscando carpetas en las que has trabajado",
|
||||
stillReading: "Todavía leyendo el historial de {{providers}}…",
|
||||
readFailed: "No se pudo leer el historial de {{provider}}",
|
||||
empty: "No se encontraron proyectos recientes",
|
||||
sessions_one: "{{count}} sesión",
|
||||
sessions_other: "{{count}} sesiones",
|
||||
addSelected_one: "Añadir {{count}} proyecto",
|
||||
addSelected_other: "Añadir {{count}} proyectos",
|
||||
adding: "Añadiendo…",
|
||||
addFailed: "No se pudo añadir esta carpeta",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orquestación",
|
||||
|
||||
@@ -1711,7 +1711,20 @@ export const fr: TranslationResources = {
|
||||
description:
|
||||
"Ajoutez les dossiers dans lesquels les agents doivent travailler. Vous pourrez en ajouter d'autres depuis la barre latérale.",
|
||||
addProject: "Ajouter un projet",
|
||||
addAnotherProject: "Ajouter un autre projet…",
|
||||
added: "Ajouté",
|
||||
suggestions: {
|
||||
loading: "Recherche des dossiers dans lesquels vous avez travaillé",
|
||||
stillReading: "Lecture de l'historique {{providers}} en cours…",
|
||||
readFailed: "Impossible de lire l'historique {{provider}}",
|
||||
empty: "Aucun projet récent trouvé",
|
||||
sessions_one: "{{count}} session",
|
||||
sessions_other: "{{count}} sessions",
|
||||
addSelected_one: "Ajouter {{count}} projet",
|
||||
addSelected_other: "Ajouter {{count}} projets",
|
||||
adding: "Ajout…",
|
||||
addFailed: "Impossible d'ajouter ce dossier",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orchestration",
|
||||
|
||||
@@ -1684,7 +1684,20 @@ export const ja: TranslationResources = {
|
||||
description:
|
||||
"エージェントに作業させるフォルダーを追加します。後からサイドバーで追加することもできます。",
|
||||
addProject: "プロジェクトを追加",
|
||||
addAnotherProject: "別のプロジェクトを追加…",
|
||||
added: "追加済み",
|
||||
suggestions: {
|
||||
loading: "作業したフォルダーを探しています",
|
||||
stillReading: "{{providers}} の履歴を読み込んでいます…",
|
||||
readFailed: "{{provider}} の履歴を読み込めませんでした",
|
||||
empty: "最近のプロジェクトは見つかりませんでした",
|
||||
sessions_one: "{{count}} セッション",
|
||||
sessions_other: "{{count}} セッション",
|
||||
addSelected_one: "{{count}} 件のプロジェクトを追加",
|
||||
addSelected_other: "{{count}} 件のプロジェクトを追加",
|
||||
adding: "追加しています…",
|
||||
addFailed: "このフォルダーを追加できませんでした",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "オーケストレーション",
|
||||
|
||||
@@ -1677,7 +1677,20 @@ export const ko: TranslationResources = {
|
||||
description:
|
||||
"에이전트가 작업할 폴더를 추가하세요. 나중에 사이드바에서 더 추가할 수 있습니다.",
|
||||
addProject: "프로젝트 추가",
|
||||
addAnotherProject: "다른 프로젝트 추가…",
|
||||
added: "추가됨",
|
||||
suggestions: {
|
||||
loading: "작업했던 폴더를 찾는 중",
|
||||
stillReading: "{{providers}} 기록을 아직 읽는 중…",
|
||||
readFailed: "{{provider}} 기록을 읽지 못했습니다",
|
||||
empty: "최근 프로젝트를 찾지 못했습니다",
|
||||
sessions_one: "세션 {{count}}개",
|
||||
sessions_other: "세션 {{count}}개",
|
||||
addSelected_one: "프로젝트 {{count}}개 추가",
|
||||
addSelected_other: "프로젝트 {{count}}개 추가",
|
||||
adding: "추가하는 중…",
|
||||
addFailed: "이 폴더를 추가하지 못했습니다",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "오케스트레이션",
|
||||
|
||||
@@ -1698,7 +1698,20 @@ export const ptBR: TranslationResources = {
|
||||
description:
|
||||
"Adicione as pastas em que os agentes devem trabalhar. Você pode adicionar mais depois pela barra lateral.",
|
||||
addProject: "Adicionar projeto",
|
||||
addAnotherProject: "Adicionar outro projeto…",
|
||||
added: "Adicionado",
|
||||
suggestions: {
|
||||
loading: "Procurando pastas em que você trabalhou",
|
||||
stillReading: "Ainda lendo o histórico de {{providers}}…",
|
||||
readFailed: "Não foi possível ler o histórico de {{provider}}",
|
||||
empty: "Nenhum projeto recente encontrado",
|
||||
sessions_one: "{{count}} sessão",
|
||||
sessions_other: "{{count}} sessões",
|
||||
addSelected_one: "Adicionar {{count}} projeto",
|
||||
addSelected_other: "Adicionar {{count}} projetos",
|
||||
adding: "Adicionando…",
|
||||
addFailed: "Não foi possível adicionar esta pasta",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orquestração",
|
||||
|
||||
@@ -1696,7 +1696,20 @@ export const ru: TranslationResources = {
|
||||
description:
|
||||
"Добавьте папки, в которых будут работать агенты. Позже можно добавить ещё из боковой панели.",
|
||||
addProject: "Добавить проект",
|
||||
addAnotherProject: "Добавить другой проект…",
|
||||
added: "Добавлено",
|
||||
suggestions: {
|
||||
loading: "Ищем папки, в которых вы работали",
|
||||
stillReading: "Ещё читаем историю {{providers}}…",
|
||||
readFailed: "Не удалось прочитать историю {{provider}}",
|
||||
empty: "Недавних проектов не найдено",
|
||||
sessions_one: "{{count}} сессия",
|
||||
sessions_other: "Сессий: {{count}}",
|
||||
addSelected_one: "Добавить {{count}} проект",
|
||||
addSelected_other: "Добавить проекты: {{count}}",
|
||||
adding: "Добавляем…",
|
||||
addFailed: "Не удалось добавить эту папку",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Оркестрация",
|
||||
|
||||
@@ -1648,7 +1648,20 @@ export const zhCN: TranslationResources = {
|
||||
title: "添加项目",
|
||||
description: "添加你希望代理工作的文件夹。之后也可以从侧边栏添加更多。",
|
||||
addProject: "添加项目",
|
||||
addAnotherProject: "添加其他项目…",
|
||||
added: "已添加",
|
||||
suggestions: {
|
||||
loading: "正在查找你工作过的文件夹",
|
||||
stillReading: "仍在读取 {{providers}} 历史记录…",
|
||||
readFailed: "无法读取 {{provider}} 历史记录",
|
||||
empty: "未找到最近的项目",
|
||||
sessions_one: "{{count}} 个会话",
|
||||
sessions_other: "{{count}} 个会话",
|
||||
addSelected_one: "添加 {{count}} 个项目",
|
||||
addSelected_other: "添加 {{count}} 个项目",
|
||||
adding: "正在添加…",
|
||||
addFailed: "无法添加此文件夹",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "编排",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useProjectSuggestions, type ProjectSuggestionsController } from "@/project-suggestions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model";
|
||||
import { OnboardingStepContent } from "./steps";
|
||||
@@ -24,12 +25,29 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
const step = state.steps[state.stepIndex];
|
||||
const isFirst = state.stepIndex === 0;
|
||||
const isLast = state.stepIndex === state.steps.length - 1;
|
||||
// Held here so Continue can add the checked folders; reading history from the first step means
|
||||
// the Projects step rarely waits.
|
||||
const projectSuggestions = useProjectSuggestions(state.serverId);
|
||||
const { addChecked } = projectSuggestions;
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
void model.complete();
|
||||
}, [model]);
|
||||
const handleBack = useCallback(() => model.back(), [model]);
|
||||
const handleNext = useCallback(() => model.next(), [model]);
|
||||
const handleAddSuggestions = useCallback(() => {
|
||||
void (async () => {
|
||||
if (await addChecked()) model.next();
|
||||
})();
|
||||
}, [addChecked, model]);
|
||||
const primary = usePrimaryAction({
|
||||
step,
|
||||
isLast,
|
||||
projectSuggestions,
|
||||
onFinish: handleComplete,
|
||||
onNext: handleNext,
|
||||
onAddSuggestions: handleAddSuggestions,
|
||||
});
|
||||
|
||||
const header = useMemo(
|
||||
() => ({
|
||||
@@ -62,8 +80,8 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
{t("common.actions.back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onPress={isLast ? handleComplete : handleNext}>
|
||||
{isLast ? t("onboarding.setup.finish") : t("onboarding.setup.continue")}
|
||||
<Button variant="default" onPress={primary.onPress} loading={primary.busy}>
|
||||
{primary.label}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
@@ -98,6 +116,7 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
<OnboardingStepContent
|
||||
step={step}
|
||||
serverId={state.serverId}
|
||||
projectSuggestions={projectSuggestions}
|
||||
onFinish={handleComplete}
|
||||
/>
|
||||
</View>
|
||||
@@ -107,6 +126,37 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's main button. On Projects, with folders checked, it adds them and moves on only once
|
||||
* every one was added; otherwise it continues, or finishes on the last step.
|
||||
*/
|
||||
function usePrimaryAction(input: {
|
||||
step: OnboardingStepId;
|
||||
isLast: boolean;
|
||||
projectSuggestions: ProjectSuggestionsController;
|
||||
onFinish: () => void;
|
||||
onNext: () => void;
|
||||
onAddSuggestions: () => void;
|
||||
}): { label: string; busy: boolean; onPress: () => void } {
|
||||
const { t } = useTranslation();
|
||||
const { step, isLast, projectSuggestions, onFinish, onNext, onAddSuggestions } = input;
|
||||
const { checkedCount, isAdding } = projectSuggestions;
|
||||
if (isLast) {
|
||||
return { label: t("onboarding.setup.finish"), busy: false, onPress: onFinish };
|
||||
}
|
||||
// Rows leave the list as they are added, so the count can reach zero while adding.
|
||||
if (step === "projects" && (isAdding || checkedCount > 0)) {
|
||||
return {
|
||||
label: isAdding
|
||||
? t("onboarding.setup.steps.projects.suggestions.adding")
|
||||
: t("onboarding.setup.steps.projects.suggestions.addSelected", { count: checkedCount }),
|
||||
busy: isAdding,
|
||||
onPress: onAddSuggestions,
|
||||
};
|
||||
}
|
||||
return { label: t("onboarding.setup.continue"), busy: false, onPress: onNext };
|
||||
}
|
||||
|
||||
function StepRail({
|
||||
state,
|
||||
onSelect,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProjectSuggestionsController } from "@/project-suggestions";
|
||||
import type { OnboardingStepId } from "../model";
|
||||
import { AppearanceStep } from "./appearance-step";
|
||||
import { OrchestrationStep } from "./orchestration-step";
|
||||
@@ -8,10 +9,12 @@ import { ProvidersStep } from "./providers-step";
|
||||
export function OnboardingStepContent({
|
||||
step,
|
||||
serverId,
|
||||
projectSuggestions,
|
||||
onFinish,
|
||||
}: {
|
||||
step: OnboardingStepId;
|
||||
serverId: string;
|
||||
projectSuggestions: ProjectSuggestionsController;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
switch (step) {
|
||||
@@ -20,7 +23,7 @@ export function OnboardingStepContent({
|
||||
case "providers":
|
||||
return <ProvidersStep serverId={serverId} />;
|
||||
case "projects":
|
||||
return <ProjectsStep serverId={serverId} />;
|
||||
return <ProjectsStep serverId={serverId} suggestions={projectSuggestions} />;
|
||||
case "orchestration":
|
||||
return <OrchestrationStep serverId={serverId} />;
|
||||
case "phone":
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check, FolderPlus } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { ProjectSuggestionList, type ProjectSuggestionsController } from "@/project-suggestions";
|
||||
import { useProjectRootPaths } from "@/stores/session-store-hooks";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -17,7 +18,14 @@ function basename(path: string): string {
|
||||
return segments.at(-1) ?? path;
|
||||
}
|
||||
|
||||
export function ProjectsStep({ serverId }: { serverId: string }) {
|
||||
/** Adding the checked suggestions is the setup's primary action; see the modal footer. */
|
||||
export function ProjectsStep({
|
||||
serverId,
|
||||
suggestions,
|
||||
}: {
|
||||
serverId: string;
|
||||
suggestions: ProjectSuggestionsController;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const projectRootPaths = useProjectRootPaths(serverId);
|
||||
@@ -25,6 +33,7 @@ export function ProjectsStep({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ProjectSuggestionList suggestions={suggestions} />
|
||||
{projectRootPaths.length > 0 ? (
|
||||
<View style={settingsStyles.card}>
|
||||
{projectRootPaths.map((rootPath, index) => (
|
||||
@@ -50,7 +59,9 @@ export function ProjectsStep({ serverId }: { serverId: string }) {
|
||||
) : null}
|
||||
<View style={styles.actions}>
|
||||
<Button variant="outline" leftIcon={FolderPlus} onPress={handleAddProject}>
|
||||
{t("onboarding.setup.steps.projects.addProject")}
|
||||
{suggestions.list.status === "ready" && suggestions.list.rows.length > 0
|
||||
? t("onboarding.setup.steps.projects.addAnotherProject")
|
||||
: t("onboarding.setup.steps.projects.addProject")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Project suggestions: folders the user already ran agents in, offered as projects to add.
|
||||
* Requires the host's `projectSuggestions` feature; on older hosts the list renders nothing.
|
||||
*/
|
||||
export { ProjectSuggestionList } from "./internal/project-suggestions";
|
||||
export {
|
||||
useProjectSuggestions,
|
||||
type ProjectSuggestionsController,
|
||||
} from "./internal/use-project-suggestions";
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ProjectSuggestion, ProviderSuggestions } from "./suggestions";
|
||||
import type {
|
||||
ProjectSuggestionsController,
|
||||
SuggestionRow as SuggestionRowModel,
|
||||
} from "./use-project-suggestions";
|
||||
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const accentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const SKELETON_ROWS = [0, 1, 2];
|
||||
|
||||
/**
|
||||
* Folders the user already ran agents in, ready to add as projects. Rows appear as each provider
|
||||
* answers and a failed provider can be retried. Adding the checked rows is the owner's primary
|
||||
* action, through the same controller. Renders nothing on a host that cannot suggest projects.
|
||||
*/
|
||||
export function ProjectSuggestionList({
|
||||
suggestions,
|
||||
}: {
|
||||
suggestions: ProjectSuggestionsController;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { list } = suggestions;
|
||||
if (list.status === "unsupported") return null;
|
||||
if (list.status === "loading") return <SuggestionsSkeleton />;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{list.rows.length > 0 ? (
|
||||
<View style={settingsStyles.card}>
|
||||
{list.rows.map((row, index) => (
|
||||
<SuggestionRow
|
||||
key={row.suggestion.rootPath}
|
||||
row={row}
|
||||
first={index === 0}
|
||||
providerLabels={list.providerLabels}
|
||||
onToggle={list.toggle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.statusText}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.empty")}
|
||||
</Text>
|
||||
)}
|
||||
{list.reading.length > 0 ? <StillReading providers={list.reading} /> : null}
|
||||
{list.failed.map((provider) => (
|
||||
<ProviderFailure key={provider.provider} provider={provider} onRetry={list.retry} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionRow({
|
||||
row,
|
||||
first,
|
||||
providerLabels,
|
||||
onToggle,
|
||||
}: {
|
||||
row: SuggestionRowModel;
|
||||
first: boolean;
|
||||
providerLabels: ReadonlyMap<string, string>;
|
||||
onToggle: (suggestion: ProjectSuggestion) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { suggestion, checked, addFailure } = row;
|
||||
const handlePress = useCallback(() => onToggle(suggestion), [onToggle, suggestion]);
|
||||
const accessibilityState = useMemo(() => ({ checked }), [checked]);
|
||||
const lastActivity = useTimeAgo(useMemo(() => new Date(suggestion.lastActivityAt), [suggestion]));
|
||||
const providers = suggestion.providerIds.map((id) => providerLabels.get(id) ?? id).join(" · ");
|
||||
const rowStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
styles.row,
|
||||
!first && settingsStyles.rowBorder,
|
||||
hovered && styles.rowHovered,
|
||||
],
|
||||
[first],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={suggestion.displayName}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={checked}
|
||||
>
|
||||
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
|
||||
{checked ? (
|
||||
<ThemedCheck size={12} strokeWidth={3} uniProps={accentForegroundMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{suggestion.displayName}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{suggestion.rootPath}
|
||||
</Text>
|
||||
{addFailure === undefined ? null : (
|
||||
<Text style={settingsStyles.rowError}>
|
||||
{addFailure ?? t("onboarding.setup.steps.projects.suggestions.addFailed")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<Text style={styles.metaText} numberOfLines={1}>
|
||||
{providers}
|
||||
</Text>
|
||||
<Text style={styles.metaText} numberOfLines={1}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.sessions", {
|
||||
count: suggestion.sessionCount,
|
||||
})}
|
||||
{" · "}
|
||||
{lastActivity}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
function StillReading({ providers }: { providers: readonly ProviderSuggestions[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
<ThemedLoadingSpinner uniProps={mutedMapping} />
|
||||
<Text style={styles.statusText}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.stillReading", {
|
||||
providers: providers.map((provider) => provider.label).join(", "),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderFailure({
|
||||
provider,
|
||||
onRetry,
|
||||
}: {
|
||||
provider: ProviderSuggestions;
|
||||
onRetry: (provider: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleRetry = useCallback(() => onRetry(provider.provider), [onRetry, provider.provider]);
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusText}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.readFailed", { provider: provider.label })}
|
||||
</Text>
|
||||
<Button variant="ghost" size="sm" onPress={handleRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionsSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
const pulse = useSkeletonPulse();
|
||||
return (
|
||||
<View
|
||||
style={settingsStyles.card}
|
||||
accessibilityLabel={t("onboarding.setup.steps.projects.suggestions.loading")}
|
||||
testID="project-suggestions-skeleton"
|
||||
>
|
||||
{SKELETON_ROWS.map((row) => (
|
||||
<View
|
||||
key={row}
|
||||
style={[settingsStyles.row, styles.row, row > 0 && settingsStyles.rowBorder]}
|
||||
>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonCheckbox} />
|
||||
<View style={styles.skeletonText}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonPath} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKBOX_SIZE = 16;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
row: {
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
checkbox: {
|
||||
width: CHECKBOX_SIZE,
|
||||
height: CHECKBOX_SIZE,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
checkboxChecked: {
|
||||
width: CHECKBOX_SIZE,
|
||||
height: CHECKBOX_SIZE,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
meta: {
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
metaText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
skeletonCheckbox: {
|
||||
width: CHECKBOX_SIZE,
|
||||
height: CHECKBOX_SIZE,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
skeletonText: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
skeletonTitle: {
|
||||
width: 140,
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
skeletonPath: {
|
||||
width: 220,
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addSuggestedProjects,
|
||||
isSuggestionChecked,
|
||||
mergeProviderSuggestions,
|
||||
type ProjectSuggestion,
|
||||
} from "./suggestions";
|
||||
|
||||
const NOW = new Date("2026-10-31T12:00:00.000Z");
|
||||
|
||||
function suggestion(
|
||||
rootPath: string,
|
||||
overrides: Partial<ProjectSuggestion> = {},
|
||||
): ProjectSuggestion {
|
||||
return {
|
||||
rootPath,
|
||||
displayName: rootPath.split("/").at(-1) ?? rootPath,
|
||||
sessionCount: 1,
|
||||
providerIds: ["claude"],
|
||||
lastActivityAt: "2026-10-30T12:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergeProviderSuggestions", () => {
|
||||
it("shows one row per folder across providers that answered, newest first", () => {
|
||||
const rows = mergeProviderSuggestions(
|
||||
[
|
||||
{
|
||||
provider: "claude",
|
||||
label: "Claude",
|
||||
status: "ready",
|
||||
suggestions: [
|
||||
suggestion("/dev/app", { sessionCount: 2, lastActivityAt: "2026-10-29T00:00:00.000Z" }),
|
||||
suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
status: "failed",
|
||||
message: "Timed out",
|
||||
suggestions: [
|
||||
suggestion("/dev/app", {
|
||||
sessionCount: 3,
|
||||
providerIds: ["codex"],
|
||||
lastActivityAt: "2026-10-30T00:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{ provider: "opencode", label: "OpenCode", status: "loading" },
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
suggestion("/dev/app", {
|
||||
sessionCount: 5,
|
||||
providerIds: ["claude", "codex"],
|
||||
lastActivityAt: "2026-10-30T00:00:00.000Z",
|
||||
}),
|
||||
suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops folders that are already projects", () => {
|
||||
const rows = mergeProviderSuggestions(
|
||||
[
|
||||
{
|
||||
provider: "claude",
|
||||
label: "Claude",
|
||||
status: "ready",
|
||||
suggestions: [suggestion("/dev/app"), suggestion("/dev/docs")],
|
||||
},
|
||||
],
|
||||
new Set(["/dev/app"]),
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.rootPath)).toEqual(["/dev/docs"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSuggestionChecked", () => {
|
||||
const busyRecent = suggestion("/dev/app", { sessionCount: 3 });
|
||||
const quiet = suggestion("/dev/docs", { sessionCount: 2 });
|
||||
const busyOld = suggestion("/dev/legacy", {
|
||||
sessionCount: 9,
|
||||
lastActivityAt: "2026-09-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
it("starts checked for folders used at least three times in the last 30 days", () => {
|
||||
expect([busyRecent, quiet, busyOld].map((row) => isSuggestionChecked(row, {}, NOW))).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the user's choice once they toggle a row", () => {
|
||||
const choices = { "/dev/app": false, "/dev/docs": true };
|
||||
expect(isSuggestionChecked(busyRecent, choices, NOW)).toBe(false);
|
||||
expect(isSuggestionChecked(quiet, choices, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSuggestedProjects", () => {
|
||||
it("adds every folder and reports the ones the host refused", async () => {
|
||||
const added: string[] = [];
|
||||
const failures = await addSuggestedProjects(
|
||||
["/dev/app", "/dev/gone", "/dev/docs"],
|
||||
async (path) => {
|
||||
if (path === "/dev/gone") return { ok: false, error: "Directory not found" };
|
||||
added.push(path);
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
expect(added).toEqual(["/dev/app", "/dev/docs"]);
|
||||
expect(failures).toEqual(new Map([["/dev/gone", "Directory not found"]]));
|
||||
});
|
||||
|
||||
it("keeps going when adding one folder throws", async () => {
|
||||
const failures = await addSuggestedProjects(["/dev/app", "/dev/docs"], async (path) => {
|
||||
if (path === "/dev/app") throw new Error("Host disconnected");
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
expect(failures).toEqual(new Map([["/dev/app", "Host disconnected"]]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ProjectSuggestion } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type { ProjectSuggestion };
|
||||
|
||||
const PRESELECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const PRESELECT_MIN_SESSIONS = 3;
|
||||
|
||||
/** One provider's answer. Each provider is asked separately so a slow one never holds up the rest. */
|
||||
export type ProviderSuggestions =
|
||||
| { provider: string; label: string; status: "loading" }
|
||||
| { provider: string; label: string; status: "ready"; suggestions: ProjectSuggestion[] }
|
||||
| {
|
||||
provider: string;
|
||||
label: string;
|
||||
status: "failed";
|
||||
message: string;
|
||||
suggestions: ProjectSuggestion[];
|
||||
};
|
||||
|
||||
/** Combines the providers that answered into one row per folder, most recent first. */
|
||||
export function mergeProviderSuggestions(
|
||||
providers: readonly ProviderSuggestions[],
|
||||
addedRootPaths: ReadonlySet<string>,
|
||||
): ProjectSuggestion[] {
|
||||
const byRoot = new Map<string, ProjectSuggestion>();
|
||||
for (const provider of providers) {
|
||||
if (provider.status === "loading") continue;
|
||||
for (const suggestion of provider.suggestions) {
|
||||
if (addedRootPaths.has(suggestion.rootPath)) continue;
|
||||
const existing = byRoot.get(suggestion.rootPath);
|
||||
byRoot.set(
|
||||
suggestion.rootPath,
|
||||
existing ? combineSuggestions(existing, suggestion) : suggestion,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Array.from(byRoot.values()).sort((a, b) =>
|
||||
b.lastActivityAt.localeCompare(a.lastActivityAt),
|
||||
);
|
||||
}
|
||||
|
||||
function combineSuggestions(a: ProjectSuggestion, b: ProjectSuggestion): ProjectSuggestion {
|
||||
return {
|
||||
...a,
|
||||
sessionCount: a.sessionCount + b.sessionCount,
|
||||
providerIds: Array.from(new Set([...a.providerIds, ...b.providerIds])).sort(),
|
||||
lastActivityAt: a.lastActivityAt > b.lastActivityAt ? a.lastActivityAt : b.lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Folders the user worked in often and recently start checked. */
|
||||
export function isPreselected(suggestion: ProjectSuggestion, now: Date): boolean {
|
||||
return (
|
||||
suggestion.sessionCount >= PRESELECT_MIN_SESSIONS &&
|
||||
now.getTime() - Date.parse(suggestion.lastActivityAt) <= PRESELECT_WINDOW_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks start from the preselection; once the user toggles a row, their choice sticks even as
|
||||
* more providers answer and the row's counts change.
|
||||
*/
|
||||
export function isSuggestionChecked(
|
||||
suggestion: ProjectSuggestion,
|
||||
choices: Readonly<Record<string, boolean>>,
|
||||
now: Date,
|
||||
): boolean {
|
||||
return choices[suggestion.rootPath] ?? isPreselected(suggestion, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds each folder in turn and returns the ones that failed, with the host's message when it gave
|
||||
* one. Sequential so the sidebar fills in the order the user sees.
|
||||
*/
|
||||
export async function addSuggestedProjects(
|
||||
rootPaths: readonly string[],
|
||||
addProject: (rootPath: string) => Promise<{ ok: true } | { ok: false; error: string | null }>,
|
||||
): Promise<Map<string, string | null>> {
|
||||
const failures = new Map<string, string | null>();
|
||||
for (const rootPath of rootPaths) {
|
||||
try {
|
||||
const result = await addProject(rootPath);
|
||||
if (!result.ok) failures.set(rootPath, result.error);
|
||||
} catch (error) {
|
||||
failures.set(rootPath, error instanceof Error ? error.message : null);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useProjectRootPaths } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
addSuggestedProjects,
|
||||
isSuggestionChecked,
|
||||
mergeProviderSuggestions,
|
||||
type ProjectSuggestion,
|
||||
type ProviderSuggestions,
|
||||
} from "./suggestions";
|
||||
import { useProviderSuggestions } from "./use-provider-suggestions";
|
||||
|
||||
export interface SuggestionRow {
|
||||
suggestion: ProjectSuggestion;
|
||||
checked: boolean;
|
||||
/** Undefined when the folder was not attempted; null when the host gave no reason. */
|
||||
addFailure: string | null | undefined;
|
||||
}
|
||||
|
||||
export type SuggestionList =
|
||||
| { status: "unsupported" }
|
||||
/** Nothing to show yet: the provider list or every provider's first answer is pending. */
|
||||
| { status: "loading" }
|
||||
| {
|
||||
status: "ready";
|
||||
rows: SuggestionRow[];
|
||||
providerLabels: ReadonlyMap<string, string>;
|
||||
reading: ProviderSuggestions[];
|
||||
failed: ProviderSuggestions[];
|
||||
toggle: (suggestion: ProjectSuggestion) => void;
|
||||
retry: (provider: string) => void;
|
||||
};
|
||||
|
||||
export interface ProjectSuggestionsController {
|
||||
list: SuggestionList;
|
||||
checkedCount: number;
|
||||
isAdding: boolean;
|
||||
/** Adds every checked folder. Resolves true when all of them were added. */
|
||||
addChecked: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The suggestion list and the user's selection. Whoever owns the step's primary action holds
|
||||
* this, so proceeding can add what is checked; calling it early also starts reading history.
|
||||
*/
|
||||
export function useProjectSuggestions(serverId: string): ProjectSuggestionsController {
|
||||
const state = useProviderSuggestions(serverId);
|
||||
const projectRootPaths = useProjectRootPaths(serverId);
|
||||
const openProject = useOpenProject(serverId);
|
||||
const [now] = useState(() => new Date());
|
||||
const [choices, setChoices] = useState<Record<string, boolean>>({});
|
||||
const [addFailures, setAddFailures] = useState<Map<string, string | null>>(new Map());
|
||||
|
||||
const providers = state.status === "ready" ? state.providers : null;
|
||||
const suggestions = useMemo(
|
||||
() => (providers ? mergeProviderSuggestions(providers, new Set(projectRootPaths)) : []),
|
||||
[providers, projectRootPaths],
|
||||
);
|
||||
const checkedRootPaths = useMemo(
|
||||
() =>
|
||||
suggestions
|
||||
.filter((suggestion) => isSuggestionChecked(suggestion, choices, now))
|
||||
.map((suggestion) => suggestion.rootPath),
|
||||
[suggestions, choices, now],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(suggestion: ProjectSuggestion) => {
|
||||
setChoices((current) => ({
|
||||
...current,
|
||||
[suggestion.rootPath]: !isSuggestionChecked(suggestion, current, now),
|
||||
}));
|
||||
},
|
||||
[now],
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationFn: (rootPaths: string[]) => addSuggestedProjects(rootPaths, openProject),
|
||||
onSuccess: setAddFailures,
|
||||
});
|
||||
const addChecked = useCallback(async () => {
|
||||
const failures = await mutateAsync(checkedRootPaths);
|
||||
return failures.size === 0;
|
||||
}, [mutateAsync, checkedRootPaths]);
|
||||
|
||||
const list = useMemo((): SuggestionList => {
|
||||
if (state.status !== "ready") return state;
|
||||
const reading = state.providers.filter((provider) => provider.status === "loading");
|
||||
if (suggestions.length === 0 && reading.length > 0) return { status: "loading" };
|
||||
return {
|
||||
status: "ready",
|
||||
rows: suggestions.map((suggestion) => ({
|
||||
suggestion,
|
||||
checked: checkedRootPaths.includes(suggestion.rootPath),
|
||||
addFailure: addFailures.get(suggestion.rootPath),
|
||||
})),
|
||||
providerLabels: new Map(
|
||||
state.providers.map((provider) => [provider.provider, provider.label]),
|
||||
),
|
||||
reading,
|
||||
failed: state.providers.filter((provider) => provider.status === "failed"),
|
||||
toggle,
|
||||
retry: state.retry,
|
||||
};
|
||||
}, [state, suggestions, checkedRootPaths, addFailures, toggle]);
|
||||
|
||||
return { list, checkedCount: checkedRootPaths.length, isAdding: isPending, addChecked };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ProviderSuggestions } from "./suggestions";
|
||||
|
||||
type SuggestionsResponse = Awaited<ReturnType<DaemonClient["listProjectSuggestions"]>>;
|
||||
|
||||
// History changes slowly. Opening the step still re-asks, keeping the rows already shown.
|
||||
const SUGGESTIONS_STALE_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
export type ProjectSuggestionsState =
|
||||
| { status: "unsupported" }
|
||||
/** The provider list itself has not arrived yet. */
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; providers: ProviderSuggestions[]; retry: (provider: string) => void };
|
||||
|
||||
function suggestionsQueryKey(serverId: string, provider: string) {
|
||||
return ["project-suggestions", serverId, provider] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the host for suggestions once per enabled provider, in parallel. The queries are shared, so
|
||||
* calling this early (when setup opens) means the Projects step usually finds them answered.
|
||||
*/
|
||||
export function useProviderSuggestions(serverId: string): ProjectSuggestionsState {
|
||||
const queryClient = useQueryClient();
|
||||
const supported = useHostFeature(serverId, "projectSuggestions");
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { entries } = useProvidersSnapshot(serverId, { enabled: supported });
|
||||
const providers = useMemo(
|
||||
() =>
|
||||
entries
|
||||
?.filter((entry) => entry.enabled !== false)
|
||||
.map((entry) => ({ id: entry.provider, label: entry.label ?? entry.provider })),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const results = useFetchQueries<SuggestionsResponse>(
|
||||
(supported && providers ? providers : []).map((provider) => ({
|
||||
queryKey: suggestionsQueryKey(serverId, provider.id),
|
||||
dataShape: "value",
|
||||
enabled: Boolean(client) && isConnected,
|
||||
// A provider that cannot answer stays failed until the user presses Retry.
|
||||
retry: false,
|
||||
staleTimeMs: SUGGESTIONS_STALE_TIME_MS,
|
||||
queryFn: () => {
|
||||
if (!client) throw new Error("Host disconnected");
|
||||
return client.listProjectSuggestions({ providers: [provider.id] });
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const retry = useCallback(
|
||||
(provider: string) => {
|
||||
void queryClient.refetchQueries({ queryKey: suggestionsQueryKey(serverId, provider) });
|
||||
},
|
||||
[queryClient, serverId],
|
||||
);
|
||||
|
||||
return useMemo((): ProjectSuggestionsState => {
|
||||
if (!supported) return { status: "unsupported" };
|
||||
if (!providers) return { status: "loading" };
|
||||
return {
|
||||
status: "ready",
|
||||
providers: providers.map((provider, index) =>
|
||||
toProviderSuggestions(provider, results[index]),
|
||||
),
|
||||
retry,
|
||||
};
|
||||
}, [supported, providers, results, retry]);
|
||||
}
|
||||
|
||||
function toProviderSuggestions(
|
||||
provider: { id: string; label: string },
|
||||
result: { data?: SuggestionsResponse; error: Error | null; isFetching: boolean } | undefined,
|
||||
): ProviderSuggestions {
|
||||
const base = { provider: provider.id, label: provider.label };
|
||||
const data = result?.data;
|
||||
const providerError = data?.providerErrors.find((error) => error.provider === provider.id);
|
||||
const failure = result?.error?.message ?? providerError?.message ?? null;
|
||||
// A retry shows as reading again; a background refresh of good results keeps the rows.
|
||||
if (!result || (result.isFetching && (failure !== null || !data))) {
|
||||
return { ...base, status: "loading" };
|
||||
}
|
||||
if (failure !== null) {
|
||||
return { ...base, status: "failed", message: failure, suggestions: data?.suggestions ?? [] };
|
||||
}
|
||||
if (!data) return { ...base, status: "loading" };
|
||||
return { ...base, status: "ready", suggestions: data.suggestions };
|
||||
}
|
||||
@@ -869,6 +869,14 @@ export type WorkspaceLabelDeleteInspectPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace.label.delete.inspect.response" }
|
||||
>["payload"];
|
||||
export type ProjectSuggestionsPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "project.suggestions.response" }
|
||||
>["payload"];
|
||||
export type ProjectSuggestionsOptions = Omit<
|
||||
Extract<SessionInboundMessage, { type: "project.suggestions.request" }>,
|
||||
"type" | "requestId"
|
||||
> & { requestId?: string };
|
||||
export type ProjectListPayload = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "project.list.response" }
|
||||
@@ -2976,6 +2984,19 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Repositories the user ran agent sessions in recently, grouped by main checkout. */
|
||||
listProjectSuggestions(options?: ProjectSuggestionsOptions): Promise<ProjectSuggestionsPayload> {
|
||||
return this.sendNamespacedCorrelatedSessionRequest<"project.suggestions.response">({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "project.suggestions.request",
|
||||
...(options?.providers ? { providers: options.providers } : {}),
|
||||
...(options?.since ? { since: options.since } : {}),
|
||||
...(options?.limit ? { limit: options.limit } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async openProject(cwd: string, requestId?: string): Promise<OpenProjectPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
|
||||
@@ -1370,6 +1370,15 @@ export const ProjectListRequestMessageSchema = z.object({
|
||||
sync: DirectorySyncRequestSchema.optional(),
|
||||
});
|
||||
|
||||
export const ProjectSuggestionsRequestSchema = z.object({
|
||||
type: z.literal("project.suggestions.request"),
|
||||
requestId: z.string(),
|
||||
providers: z.array(z.string()).optional(),
|
||||
// ISO timestamp. Sessions older than this are ignored. Defaults to 30 days ago.
|
||||
since: z.string().optional(),
|
||||
limit: z.number().int().positive().max(100).optional(),
|
||||
});
|
||||
|
||||
export const FetchAgentHistoryRequestMessageSchema = z.object({
|
||||
type: z.literal("fetch_agent_history_request"),
|
||||
requestId: z.string(),
|
||||
@@ -3296,6 +3305,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
FetchRecentProviderSessionsRequestMessageSchema,
|
||||
FetchWorkspacesRequestMessageSchema,
|
||||
ProjectListRequestMessageSchema,
|
||||
ProjectSuggestionsRequestSchema,
|
||||
FetchAgentRequestMessageSchema,
|
||||
DeleteAgentRequestMessageSchema,
|
||||
ArchiveAgentRequestMessageSchema,
|
||||
@@ -3813,6 +3823,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
projectCreateDirectory: z.boolean().optional(),
|
||||
// COMPAT(projectList): added in v0.2.4, drop the gate when floor >= v0.2.4.
|
||||
projectList: z.boolean().optional(),
|
||||
// COMPAT(projectSuggestions): added in v0.14.1, remove gate after 2027-05-01.
|
||||
projectSuggestions: z.boolean().optional(),
|
||||
// COMPAT(commitsList): added in v0.1.110, remove gate after 2027-01-16.
|
||||
commitsList: z.boolean().optional(),
|
||||
// COMPAT(commitBaseClassification): added in v0.2.0, remove gate after 2027-01-23.
|
||||
@@ -4498,6 +4510,24 @@ export const ProjectListResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProjectSuggestionSchema = z.object({
|
||||
// The main repository root for git checkouts and their worktrees; the session cwd otherwise.
|
||||
rootPath: z.string(),
|
||||
displayName: z.string(),
|
||||
sessionCount: z.number().int().nonnegative(),
|
||||
providerIds: z.array(z.string()),
|
||||
lastActivityAt: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectSuggestionsResponseSchema = z.object({
|
||||
type: z.literal("project.suggestions.response"),
|
||||
payload: z.object({
|
||||
requestId: z.string(),
|
||||
suggestions: z.array(ProjectSuggestionSchema),
|
||||
providerErrors: z.array(z.object({ provider: z.string(), message: z.string() })),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ScriptStatusUpdateMessageSchema = z.object({
|
||||
type: z.literal("script_status_update"),
|
||||
payload: z.object({
|
||||
@@ -7064,6 +7094,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
WorkspaceLabelDeleteInspectResponseSchema,
|
||||
ProjectUpdateMessageSchema,
|
||||
ProjectListResponseMessageSchema,
|
||||
ProjectSuggestionsResponseSchema,
|
||||
ScriptStatusUpdateMessageSchema,
|
||||
WorkspaceSetupProgressMessageSchema,
|
||||
WorkspaceSetupStatusResponseMessageSchema,
|
||||
@@ -7428,6 +7459,9 @@ export type FetchRecentProviderSessionsRequestMessage = z.infer<
|
||||
>;
|
||||
export type FetchWorkspacesRequestMessage = z.infer<typeof FetchWorkspacesRequestMessageSchema>;
|
||||
export type ProjectListRequestMessage = z.infer<typeof ProjectListRequestMessageSchema>;
|
||||
export type ProjectSuggestionsRequest = z.infer<typeof ProjectSuggestionsRequestSchema>;
|
||||
export type ProjectSuggestion = z.infer<typeof ProjectSuggestionSchema>;
|
||||
export type ProjectSuggestionsResponse = z.infer<typeof ProjectSuggestionsResponseSchema>;
|
||||
export type FetchAgentRequestMessage = z.infer<typeof FetchAgentRequestMessageSchema>;
|
||||
export type AgentForkContextRequestMessage = z.infer<typeof AgentForkContextRequestMessageSchema>;
|
||||
export type SendAgentMessageRequest = z.infer<typeof SendAgentMessageRequestSchema>;
|
||||
|
||||
@@ -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