diff --git a/packages/app/e2e/browser/onboarding.spec.ts b/packages/app/e2e/browser/onboarding.spec.ts index 56d2d4cd6c..021cf54930 100644 --- a/packages/app/e2e/browser/onboarding.spec.ts +++ b/packages/app/e2e/browser/onboarding.spec.ts @@ -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(); } }); diff --git a/packages/app/e2e/support/helpers/isolated-host-daemon.ts b/packages/app/e2e/support/helpers/isolated-host-daemon.ts index c8dd3eaa44..dea61c3a37 100644 --- a/packages/app/e2e/support/helpers/isolated-host-daemon.ts +++ b/packages/app/e2e/support/helpers/isolated-host-daemon.ts @@ -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; [key: string]: unknown }; environment?: NodeJS.ProcessEnv; mutableRelay?: { enabled: boolean; @@ -93,6 +95,28 @@ async function waitForServer(port: number, child: ChildProcess): Promise { ); } +function withMutableRelay( + config: NonNullable, + relay: NonNullable, +): NonNullable { + 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 diff --git a/packages/app/e2e/support/helpers/onboarding.ts b/packages/app/e2e/support/helpers/onboarding.ts index 816d7b4e80..7fed8ae45a 100644 --- a/packages/app/e2e/support/helpers/onboarding.ts +++ b/packages/app/e2e/support/helpers/onboarding.ts @@ -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 { await docs.close(); return url; } + +export interface SetupHost { + serverId: string; + port: number; + close(): Promise; +} + +/** + * 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 { + 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 { + 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 }); +} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index ec35081cc7..ddedf63011 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -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: "التنسيق", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 86257b9a85..989da368e2 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 22025cd99e..d946bd538a 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 00cabf828c..ed0c8c2688 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 8594d545a4..f35c47ab4f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -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: "オーケストレーション", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 2fe36cf05f..b88d8b39e0 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -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: "오케스트레이션", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 9b8bb9cb07..934205ae25 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 700baf7c4e..893685d517 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -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: "Оркестрация", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index e4db59b904..b7fa2ce6a4 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -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: "编排", diff --git a/packages/app/src/onboarding/internal/onboarding-modal.tsx b/packages/app/src/onboarding/internal/onboarding-modal.tsx index a061bfd2bb..5f713e14dd 100644 --- a/packages/app/src/onboarding/internal/onboarding-modal.tsx +++ b/packages/app/src/onboarding/internal/onboarding-modal.tsx @@ -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")} )} - @@ -98,6 +116,7 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat @@ -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, diff --git a/packages/app/src/onboarding/internal/steps/index.tsx b/packages/app/src/onboarding/internal/steps/index.tsx index aa6a869a3b..adbf17e49c 100644 --- a/packages/app/src/onboarding/internal/steps/index.tsx +++ b/packages/app/src/onboarding/internal/steps/index.tsx @@ -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 ; case "projects": - return ; + return ; case "orchestration": return ; case "phone": diff --git a/packages/app/src/onboarding/internal/steps/projects-step.tsx b/packages/app/src/onboarding/internal/steps/projects-step.tsx index beda492cd3..de1c8b219b 100644 --- a/packages/app/src/onboarding/internal/steps/projects-step.tsx +++ b/packages/app/src/onboarding/internal/steps/projects-step.tsx @@ -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 ( + {projectRootPaths.length > 0 ? ( {projectRootPaths.map((rootPath, index) => ( @@ -50,7 +59,9 @@ export function ProjectsStep({ serverId }: { serverId: string }) { ) : null} diff --git a/packages/app/src/project-suggestions/index.ts b/packages/app/src/project-suggestions/index.ts new file mode 100644 index 0000000000..bb71dc1e89 --- /dev/null +++ b/packages/app/src/project-suggestions/index.ts @@ -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"; diff --git a/packages/app/src/project-suggestions/internal/project-suggestions.tsx b/packages/app/src/project-suggestions/internal/project-suggestions.tsx new file mode 100644 index 0000000000..4766b23dd9 --- /dev/null +++ b/packages/app/src/project-suggestions/internal/project-suggestions.tsx @@ -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 ; + + return ( + + {list.rows.length > 0 ? ( + + {list.rows.map((row, index) => ( + + ))} + + ) : ( + + {t("onboarding.setup.steps.projects.suggestions.empty")} + + )} + {list.reading.length > 0 ? : null} + {list.failed.map((provider) => ( + + ))} + + ); +} + +function SuggestionRow({ + row, + first, + providerLabels, + onToggle, +}: { + row: SuggestionRowModel; + first: boolean; + providerLabels: ReadonlyMap; + 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 ( + + + {checked ? ( + + ) : null} + + + + {suggestion.displayName} + + + {suggestion.rootPath} + + {addFailure === undefined ? null : ( + + {addFailure ?? t("onboarding.setup.steps.projects.suggestions.addFailed")} + + )} + + + + {providers} + + + {t("onboarding.setup.steps.projects.suggestions.sessions", { + count: suggestion.sessionCount, + })} + {" · "} + {lastActivity} + + + + ); +} + +function StillReading({ providers }: { providers: readonly ProviderSuggestions[] }) { + const { t } = useTranslation(); + return ( + + + + {t("onboarding.setup.steps.projects.suggestions.stillReading", { + providers: providers.map((provider) => provider.label).join(", "), + })} + + + ); +} + +function ProviderFailure({ + provider, + onRetry, +}: { + provider: ProviderSuggestions; + onRetry: (provider: string) => void; +}) { + const { t } = useTranslation(); + const handleRetry = useCallback(() => onRetry(provider.provider), [onRetry, provider.provider]); + return ( + + + {t("onboarding.setup.steps.projects.suggestions.readFailed", { provider: provider.label })} + + + + ); +} + +function SuggestionsSkeleton() { + const { t } = useTranslation(); + const pulse = useSkeletonPulse(); + return ( + + {SKELETON_ROWS.map((row) => ( + 0 && settingsStyles.rowBorder]} + > + + + + + + + ))} + + ); +} + +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, + }, +})); diff --git a/packages/app/src/project-suggestions/internal/suggestions.test.ts b/packages/app/src/project-suggestions/internal/suggestions.test.ts new file mode 100644 index 0000000000..f4b1ae9bd5 --- /dev/null +++ b/packages/app/src/project-suggestions/internal/suggestions.test.ts @@ -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 { + 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"]])); + }); +}); diff --git a/packages/app/src/project-suggestions/internal/suggestions.ts b/packages/app/src/project-suggestions/internal/suggestions.ts new file mode 100644 index 0000000000..aa7f940f3f --- /dev/null +++ b/packages/app/src/project-suggestions/internal/suggestions.ts @@ -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, +): ProjectSuggestion[] { + const byRoot = new Map(); + 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>, + 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> { + const failures = new Map(); + 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; +} diff --git a/packages/app/src/project-suggestions/internal/use-project-suggestions.ts b/packages/app/src/project-suggestions/internal/use-project-suggestions.ts new file mode 100644 index 0000000000..0fb29a48f4 --- /dev/null +++ b/packages/app/src/project-suggestions/internal/use-project-suggestions.ts @@ -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; + 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; +} + +/** + * 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>({}); + const [addFailures, setAddFailures] = useState>(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 }; +} diff --git a/packages/app/src/project-suggestions/internal/use-provider-suggestions.ts b/packages/app/src/project-suggestions/internal/use-provider-suggestions.ts new file mode 100644 index 0000000000..25e4dc9fdc --- /dev/null +++ b/packages/app/src/project-suggestions/internal/use-provider-suggestions.ts @@ -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>; + +// 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( + (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 }; +} diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 3aa91f84c7..49d3d13af5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -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, + "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 { + 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 { return this.sendCorrelatedSessionRequest({ requestId, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index dc63d56b75..5bb99619e9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -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; export type ProjectListRequestMessage = z.infer; +export type ProjectSuggestionsRequest = z.infer; +export type ProjectSuggestion = z.infer; +export type ProjectSuggestionsResponse = z.infer; export type FetchAgentRequestMessage = z.infer; export type AgentForkContextRequestMessage = z.infer; export type SendAgentMessageRequest = z.infer; diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 492667ab88..d85c66f515 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -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 ); diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index f7a5b17857..d040e508cc 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -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", diff --git a/packages/server/src/server/project-suggestions.test.ts b/packages/server/src/server/project-suggestions.test.ts new file mode 100644 index 0000000000..0a2e07af56 --- /dev/null +++ b/packages/server/src/server/project-suggestions.test.ts @@ -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 { + 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" }], + }); + }); +}); diff --git a/packages/server/src/server/project-suggestions.ts b/packages/server/src/server/project-suggestions.ts new file mode 100644 index 0000000000..2bc30cb1d2 --- /dev/null +++ b/packages/server/src/server/project-suggestions.ts @@ -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; + agentManager: Pick; + projectRegistry: Pick; + workspaceGitService: Pick; + 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 { + 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(); + 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; + 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, + workspaceGitService: Pick, +): Promise> { + 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, +): Promise { + 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, +): Promise> { + 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, + rootPath: string, + session: ManagedImportableProviderSession, +): void { + const key = normalizePathForIdentity(rootPath); + const group = groups.get(key) ?? { + rootPath, + sessionCount: 0, + providerIds: new Set(), + 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 { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 42c676b50b..0aa282ea94 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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, + ): Promise { + 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"); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7bfc4d5a0f..4b7c242f44 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -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.