diff --git a/packages/app/e2e/browser/import-session-flow.spec.ts b/packages/app/e2e/browser/import-session-flow.spec.ts new file mode 100644 index 0000000000..00322c86a3 --- /dev/null +++ b/packages/app/e2e/browser/import-session-flow.spec.ts @@ -0,0 +1,293 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { TestInfo } from "@playwright/test"; +import { test, type Page } from "../support/fixtures"; +import { ImportSessionFlow } from "../support/helpers/import-session"; +import { + connectNewWorkspaceDaemonClient, + openProjectViaDaemon, + type OpenedProject, +} from "../support/helpers/new-workspace"; +import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace"; + +const SCREENSHOT_DIRECTORY = path.join( + process.env.HOME ?? tmpdir(), + ".paseo/plans/import-session-ux", +); +const claudeConfigDirectory = mkdtempSync(path.join(tmpdir(), "paseo-import-flow-claude-")); +const brokenProvider = "broken-acp"; + +test.use({ + e2eDaemonConfig: { + version: 1, + agents: { + providers: { + codex: { enabled: false }, + copilot: { enabled: false }, + omp: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + [brokenProvider]: { + extends: "acp", + label: "Broken ACP", + command: ["missing-agent-command", "acp"], + }, + }, + }, + }, + e2eDaemonEnvironment: { CLAUDE_CONFIG_DIR: claudeConfigDirectory }, +}); + +interface ImportFlowScenario { + project: OpenedProject; + reuseTarget: OpenedProject; + projectName: string; + projectRoot: string; + worktreeDirectory: string; + unrelatedDirectory: string; + importSessionId: string; + repoCleanup(): Promise; + unrelatedCleanup(): Promise; +} + +let scenario: ImportFlowScenario; +let client: Awaited>; + +test.setTimeout(120_000); + +test.beforeAll(async () => { + await mkdir(SCREENSHOT_DIRECTORY, { recursive: true }); + const repo = await createTempGitRepo("isf-", { + originUrl: "https://github.com/paseo-e2e/import-session-fixture.git", + }); + const unrelated = await createTempDirectory("isf-other-"); + const worktreeDirectory = path.join(repo.path, "worktrees", "review-fix"); + await mkdir(path.dirname(worktreeDirectory), { recursive: true }); + execFileSync("git", ["worktree", "add", "-b", "review-fix", worktreeDirectory], { + cwd: repo.path, + stdio: "ignore", + }); + + client = await connectNewWorkspaceDaemonClient({ ownProjects: false }); + const project = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); + if (!project.workspace) { + throw new Error(project.error ?? "Failed to create the import-session fixture workspace"); + } + const projects = await client.listProjects(); + const projectDescriptor = projects.projects.find( + (candidate) => candidate.projectId === project.workspace?.projectId, + ); + if (!projectDescriptor?.projectKey) { + throw new Error("Fixture workspace has no project key"); + } + + const openedProject: OpenedProject = { + workspaceId: project.workspace.id, + projectId: project.workspace.projectId, + projectKey: projectDescriptor.projectKey, + projectDisplayName: project.workspace.projectDisplayName, + workspaceName: project.workspace.name, + workspaceDirectory: project.workspace.workspaceDirectory, + }; + const reuseTarget = await openProjectViaDaemon(client, unrelated.path); + const importSessionId = "fixture-custom-title"; + await seedClaudeSessions({ + projectRoot: repo.path, + worktreeDirectory, + unrelatedDirectory: unrelated.path, + importSessionId, + }); + scenario = { + project: openedProject, + reuseTarget, + projectName: project.workspace.projectDisplayName, + projectRoot: repo.path, + worktreeDirectory, + unrelatedDirectory: unrelated.path, + importSessionId, + repoCleanup: repo.cleanup, + unrelatedCleanup: unrelated.cleanup, + }; +}); + +test.afterAll(async () => { + await client?.removeProject(scenario?.project.projectId).catch(() => undefined); + await client?.removeProject(scenario?.reuseTarget.projectId).catch(() => undefined); + await client?.close().catch(() => undefined); + await scenario?.repoCleanup().catch(() => undefined); + await scenario?.unrelatedCleanup().catch(() => undefined); + await rm(claudeConfigDirectory, { recursive: true, force: true }); +}); + +test("captures the compact import-session journey", async ({ page }, testInfo) => { + const flow = new ImportSessionFlow(page); + await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 }); + + await test.step("the mobile sidebar exposes import in its footer", async () => { + await flow.revealMobileEntryPoint(); + await capture(page, testInfo, "01-mobile-sidebar-footer.png"); + }); + + await test.step("the host-wide sheet is newest first and fits its provider filter", async () => { + await flow.openGlobally(); + await flow.expectScope("Sessions on", false); + await flow.expectRows({ + first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"], + folders: [ + [scenario.importSessionId, scenario.projectName], + ["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`], + ["fixture-unrelated", scenario.reuseTarget.projectDisplayName], + ], + }); + await flow.expectProviderError("Broken ACP"); + await flow.expectProviderFilterFits(390); + await flow.revealSession("fixture-unrelated"); + await capture(page, testInfo, "02-mobile-sheet-unscoped.png"); + }); + + await test.step("search narrows across the fixture corpus", async () => { + await flow.search("invoice"); + await capture(page, testInfo, "03-mobile-search-narrowed.png"); + }); + + await test.step("load more grows the result set and then disappears", async () => { + await flow.resetSearch(); + await capture(page, testInfo, "04-mobile-load-more-visible.png"); + await flow.loadMore(); + await capture(page, testInfo, "05-mobile-load-more-complete.png"); + }); + + await test.step("one provider fails inline and Retry settles", async () => { + await flow.retryProvider(brokenProvider, "Broken ACP"); + await capture(page, testInfo, "06-mobile-provider-error-retry.png"); + }); + + await test.step("the selected row imports and opens the hydrated transcript", async () => { + await flow.importSession(scenario.importSessionId); + await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready."); + await capture(page, testInfo, "08-mobile-agent-after-import.png"); + }); + + await test.step("workspace actions start scoped and can widen to the host", async () => { + await flow.openFromWorkspaceHeader(); + await capture(page, testInfo, "09-mobile-workspace-scoped.png"); + await flow.showAll(); + await flow.expectRows({ + folders: [["fixture-unrelated", scenario.reuseTarget.projectDisplayName]], + }); + await capture(page, testInfo, "10-mobile-workspace-show-all.png"); + + await flow.importSession("fixture-root-03"); + await flow.expectImportedIntoWorkspace( + scenario.reuseTarget.workspaceId, + "Review fixture item 3", + ); + }); +}); + +test("captures the desktop import sheet and command-center entry", async ({ page }, testInfo) => { + const flow = new ImportSessionFlow(page); + await flow.openWorkspace(scenario.project.workspaceId, { + width: 1280, + height: 800, + }); + + await test.step("desktop shows the flat host-wide sheet", async () => { + await flow.openGlobally(); + // The compact test may already have imported the newest fixture row, so this + // asserts recency as an ordering between two rows nothing imports. + await flow.expectRows({ + before: ["fixture-worktree", "fixture-unrelated"], + folders: [["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`]], + }); + await flow.revealSession("fixture-unrelated"); + await capture(page, testInfo, "11-desktop-sheet-unscoped.png"); + await flow.close(); + }); + + await test.step("import matches the command but not Home", async () => { + await flow.expectCommandCenterMatch(); + await capture(page, testInfo, "12-desktop-command-center-import.png"); + }); +}); + +async function capture(page: Page, testInfo: TestInfo, name: string): Promise { + const outputPath = testInfo.outputPath(name); + await page.screenshot({ path: outputPath, fullPage: true }); + await copyFile(outputPath, path.join(SCREENSHOT_DIRECTORY, name)); +} + +async function seedClaudeSessions(input: { + projectRoot: string; + worktreeDirectory: string; + unrelatedDirectory: string; + importSessionId: string; +}): Promise { + const sessions = [ + { + cwd: input.projectRoot, + id: input.importSessionId, + title: "Invoice migration plan", + prompt: "Review the invoice migration", + answer: "The fixture transcript is ready.", + }, + { + cwd: input.worktreeDirectory, + id: "fixture-worktree", + title: "Review worktree fix", + prompt: "Check the worktree import flow", + }, + { + cwd: input.unrelatedDirectory, + id: "fixture-unrelated", + title: "Unrelated directory notes", + prompt: "Review the unrelated directory", + }, + ...Array.from({ length: 20 }, (_, index) => ({ + cwd: [input.projectRoot, input.worktreeDirectory, input.unrelatedDirectory][index % 3]!, + id: `fixture-root-${String(index + 1).padStart(2, "0")}`, + title: `Root session ${String(index + 1).padStart(2, "0")}`, + prompt: index === 7 ? "Investigate invoice rendering" : `Review fixture item ${index + 1}`, + })), + ]; + const newest = Date.now() - 60_000; + for (const [index, session] of sessions.entries()) { + const projectDirectory = path.join( + claudeConfigDirectory, + "projects", + session.cwd.replace(/[^a-zA-Z0-9]/g, "-"), + ); + await mkdir(projectDirectory, { recursive: true }); + const sessionPath = path.join(projectDirectory, `${session.id}.jsonl`); + const records = [ + { + type: "user", + uuid: `${session.id}-user`, + message: { role: "user", content: session.prompt }, + cwd: session.cwd, + sessionId: session.id, + }, + ...(session.answer + ? [ + { + type: "assistant", + uuid: `${session.id}-assistant`, + message: { + role: "assistant", + content: [{ type: "text", text: session.answer }], + }, + cwd: session.cwd, + sessionId: session.id, + }, + ] + : []), + { type: "custom-title", customTitle: session.title, sessionId: session.id }, + ]; + await writeFile(sessionPath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`); + const timestamp = new Date(newest - index * 60_000); + await utimes(sessionPath, timestamp, timestamp); + } +} diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 28654291cc..0763bfb332 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -22,7 +22,7 @@ import { expectSetupPanel, openHomeWithProject, navigateToWorkspaceViaSidebar, - returnHomeFromWorkspace, + leaveWorkspaceViaHistory, openWorkspaceScriptsMenu, startWorkspaceScriptFromMenu, closeWorkspaceScriptsMenu, @@ -195,7 +195,7 @@ test.describe("Workspace setup streaming", () => { await expectFailedSetupTabSeededInMainPane(page, workspace.id); await closeSetupTab(page, workspace.id); - await returnHomeFromWorkspace(page); + await leaveWorkspaceViaHistory(page); await navigateToWorkspaceViaSidebar(page, workspace.id); await expectSetupTabNotSeeded(page, workspace.id); } finally { diff --git a/packages/app/e2e/support/fixtures.ts b/packages/app/e2e/support/fixtures.ts index 9fe122762c..43b4191b64 100644 --- a/packages/app/e2e/support/fixtures.ts +++ b/packages/app/e2e/support/fixtures.ts @@ -35,6 +35,8 @@ const metroTest = base.extend({ const daemonTest = metroTest.extend< { projectOwnership: void }, { + e2eDaemonConfig: Record | undefined; + e2eDaemonEnvironment: Record; e2eForkProviders: string[]; e2eInjectPaseoTools: boolean; e2eWorker: void; @@ -43,9 +45,17 @@ const daemonTest = metroTest.extend< >({ e2eForkProviders: [[], { scope: "worker", option: true }], e2eInjectPaseoTools: [false, { scope: "worker", option: true }], + e2eDaemonConfig: [undefined, { scope: "worker", option: true }], + e2eDaemonEnvironment: [{}, { scope: "worker", option: true }], e2eWorker: [ - async ({ e2eForkProviders, e2eInjectPaseoTools }, provide, workerInfo) => { + async ( + { e2eDaemonConfig, e2eDaemonEnvironment, e2eForkProviders, e2eInjectPaseoTools }, + provide, + workerInfo, + ) => { const worker = await startE2EWorker(workerInfo.workerIndex, { + daemonConfig: e2eDaemonConfig, + environment: e2eDaemonEnvironment, forkProviders: e2eForkProviders, injectPaseoTools: e2eInjectPaseoTools, }); diff --git a/packages/app/e2e/support/helpers/command-center-scroll.ts b/packages/app/e2e/support/helpers/command-center-scroll.ts index 3350fefbc2..dd4712fb36 100644 --- a/packages/app/e2e/support/helpers/command-center-scroll.ts +++ b/packages/app/e2e/support/helpers/command-center-scroll.ts @@ -83,6 +83,7 @@ export async function expectCommandCenterToRemainAtTheTop(page: Page): Promise { const expectedActions = [ { title: "New workspace", shortcut: "⌘N" }, + { title: "Import session" }, { title: "History" }, { title: "Schedules" }, { title: "Settings", shortcut: "⌘," }, @@ -105,6 +106,9 @@ export async function expectPrimaryCommandCenterActions(panel: Locator): Promise await input.fill("home"); await expect(panel.getByRole("button").filter({ hasText: "Home" })).toBeVisible(); await expect(panel.getByRole("button").filter({ hasText: "Add project" })).toHaveCount(0); + await input.fill("import"); + await expect(panel.getByRole("button").filter({ hasText: "Import session" })).toBeVisible(); + await expect(panel.getByRole("button").filter({ hasText: "Home" })).toHaveCount(0); await input.fill(""); } diff --git a/packages/app/e2e/support/helpers/e2e-worker.ts b/packages/app/e2e/support/helpers/e2e-worker.ts index a5ee080ff9..d373224fd8 100644 --- a/packages/app/e2e/support/helpers/e2e-worker.ts +++ b/packages/app/e2e/support/helpers/e2e-worker.ts @@ -10,6 +10,13 @@ export interface E2EWorker { close(): Promise; } +export interface E2EWorkerOptions { + forkProviders?: string[]; + injectPaseoTools?: boolean; + daemonConfig?: Record; + environment?: Record; +} + function resolveOptionalHome(value: string | undefined): string | null { const trimmed = value?.trim(); if (!trimmed) return null; @@ -156,7 +163,7 @@ async function applyMetadataFork(targetHome: string, providerIds: string[]): Pro export async function startE2EWorker( workerIndex: number, - options: { forkProviders?: string[]; injectPaseoTools?: boolean } = {}, + options: E2EWorkerOptions = {}, ): Promise { const requestedRoot = resolveOptionalHome(process.env.E2E_PASEO_HOME); const paseoHome = requestedRoot @@ -169,6 +176,14 @@ export async function startE2EWorker( try { await applyMetadataFork(paseoHome, options.forkProviders ?? []); + // Worker-scoped fixture config lets a spec exercise provider discovery without + // reading the developer's provider state or sharing configuration with other specs. + if (options.daemonConfig) { + await writeFile( + path.join(paseoHome, "config.json"), + `${JSON.stringify(options.daemonConfig, null, 2)}\n`, + ); + } if (options.injectPaseoTools) { await enablePaseoTools(paseoHome); } @@ -179,6 +194,7 @@ export async function startE2EWorker( NODE_ENV: "development", PATH: `${fakeEditorBin}${path.delimiter}${process.env.PATH ?? ""}`, PASEO_E2E_EDITOR_RECORD_PATH: editorRecordPath, + ...options.environment, }, }); diff --git a/packages/app/e2e/support/helpers/import-session.ts b/packages/app/e2e/support/helpers/import-session.ts new file mode 100644 index 0000000000..86818f6f4e --- /dev/null +++ b/packages/app/e2e/support/helpers/import-session.ts @@ -0,0 +1,145 @@ +import { expect, type Page } from "@playwright/test"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { gotoAppShell } from "./app"; +import { openCommandCenter } from "./command-center"; +import { getServerId } from "./server-id"; +import { expectMobileAgentSidebarVisible, openMobileAgentSidebar } from "./sidebar"; + +const rowTestId = (id: string) => `import-session-session-claude-${id}`; +const rowFolderTestId = (id: string) => `import-session-row-folder-claude-${id}`; + +export class ImportSessionFlow { + constructor(private readonly page: Page) {} + async openWorkspace(workspaceId: string, viewport: { width: number; height: number }) { + await this.page.setViewportSize(viewport); + await gotoAppShell(this.page); + await this.page.goto(buildHostWorkspaceRoute(getServerId(), workspaceId)); + await expect(this.page.getByRole("button", { name: "Workspace actions" })).toBeVisible({ + timeout: 30_000, + }); + } + async revealMobileEntryPoint() { + await openMobileAgentSidebar(this.page); + await expectMobileAgentSidebarVisible(this.page); + const button = this.page.getByTestId("sidebar-import-session"); + await expect(button).toHaveAccessibleName("Import session"); + await button.hover(); + await expect(this.page.getByText("Import session", { exact: true })).toBeVisible(); + } + async openGlobally() { + await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); + await this.page.getByTestId("sidebar-import-session").click(); + await this.expectSheetReady(); + } + async openFromWorkspaceHeader() { + await this.page.getByRole("button", { name: "Workspace actions" }).click(); + await this.page.getByTestId("workspace-header-import-agent").click(); + await this.expectScope("This workspace"); + await expect(this.page.getByTestId("import-session-show-all")).toBeVisible(); + await expect(this.page.getByText("Workspace actions", { exact: true })).not.toBeVisible(); + } + async expectScope(text: string, exact = true) { + const scope = this.page.getByTestId("import-session-scope"); + if (exact) await expect(scope).toHaveText(text); + else await expect(scope).toContainText(text); + await expect(scope).toBeVisible(); + } + async expectRows(input: { + first?: string[]; + before?: [string, string]; + folders?: Array<[string, string]>; + }) { + const ids = await this.rowIds(); + if (input.first) expect(ids.slice(0, input.first.length)).toEqual(input.first.map(rowTestId)); + if (input.before) { + expect(ids.indexOf(rowTestId(input.before[0]))).toBeGreaterThanOrEqual(0); + expect(ids.indexOf(rowTestId(input.before[0]))).toBeLessThan( + ids.indexOf(rowTestId(input.before[1])), + ); + } + for (const [id, folder] of input.folders ?? []) { + await expect(this.page.getByTestId(rowFolderTestId(id))).toHaveText(folder); + } + } + async expectProviderError(label: string) { + await expect(this.page.getByTestId("import-session-provider-errors")).toContainText( + `Could not load ${label} sessions`, + ); + } + async expectProviderFilterFits(width: number) { + await expect(this.page.getByTestId("import-session-scope")).toBeInViewport(); + const filter = this.page.getByRole("button", { name: "Filter: All" }); + await expect(filter).toBeInViewport(); + const bounds = await filter.boundingBox(); + expect(bounds?.x ?? width).toBeGreaterThanOrEqual(0); + expect((bounds?.x ?? width) + (bounds?.width ?? 1)).toBeLessThanOrEqual(width); + } + async revealSession(id: string) { + await this.page.getByTestId(rowTestId(id)).scrollIntoViewIfNeeded(); + } + async search(query: string) { + await this.page.getByTestId("import-session-search").fill(query); + await expect(this.page.getByText("Invoice migration plan", { exact: true })).toBeVisible(); + await expect(this.page.getByText("Root session 01", { exact: true })).toHaveCount(0); + await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0); + } + async resetSearch() { + await this.page.getByTestId("import-session-search").fill(""); + await expect(this.page.getByTestId("import-session-load-more")).toBeVisible(); + } + async loadMore() { + await this.page.getByTestId("import-session-load-more").click(); + await expect(this.page.getByText("Root session 20", { exact: true })).toBeVisible(); + await expect(this.page.getByTestId("import-session-load-more")).toHaveCount(0); + } + async retryProvider(provider: string, label: string) { + await this.expectProviderError(label); + await this.page.getByTestId(`import-session-retry-${provider}`).click(); + await expect(this.page.getByTestId(`import-session-retry-${provider}`)).toBeEnabled(); + await this.expectProviderError(label); + await expect(this.page.getByRole("progressbar")).toHaveCount(0); + } + async importSession(id: string) { + await this.page.getByTestId(rowTestId(id)).click(); + await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0, { + timeout: 30_000, + }); + } + async expectTranscript(userText: string, assistantText: string) { + await expect(this.page.getByTestId("user-message")).toContainText(userText); + await expect(this.page.getByTestId("assistant-message")).toContainText(assistantText); + } + async showAll() { + await this.page.getByTestId("import-session-show-all").click(); + await expect(this.page.getByTestId("import-session-scope")).toContainText("Sessions on"); + } + async expectImportedIntoWorkspace(workspaceId: string, userText: string) { + await expect(this.page).toHaveURL(buildHostWorkspaceRoute(getServerId(), workspaceId), { + timeout: 30_000, + }); + const workspace = this.page.getByTestId(`workspace-deck-entry-${getServerId()}:${workspaceId}`); + await expect(workspace.getByTestId("user-message")).toContainText(userText); + } + async close() { + await this.page.keyboard.press("Escape"); + await expect(this.page.getByTestId("import-session-sheet")).toHaveCount(0); + } + async expectCommandCenterMatch() { + const panel = await openCommandCenter(this.page); + await panel.getByTestId("command-center-input").fill("import"); + await expect(panel.getByText("Import session", { exact: true })).toBeVisible(); + await expect(panel.getByText("Home", { exact: true })).toHaveCount(0); + } + private async expectSheetReady() { + const sheet = this.page.getByTestId("import-session-sheet"); + await expect(sheet).toBeVisible({ timeout: 30_000 }); + await expect(sheet.getByText("Loading sessions...", { exact: true })).toHaveCount(0, { + timeout: 30_000, + }); + } + private async rowIds() { + return await this.page + .locator('[data-testid^="import-session-session-claude-"]') + .evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid"))); + } +} diff --git a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts index 2d8efe8aea..2b5dddc05e 100644 --- a/packages/app/e2e/support/helpers/sidebar-nav-settings.ts +++ b/packages/app/e2e/support/helpers/sidebar-nav-settings.ts @@ -39,6 +39,7 @@ function itemLabel(key: SidebarNavKey): string { } async function rowTop(locator: Locator): Promise { + await locator.waitFor({ state: "visible" }); const box = await locator.boundingBox(); if (!box) { throw new Error("Expected a laid-out sidebar row to measure"); diff --git a/packages/app/e2e/support/helpers/startup-dsl.ts b/packages/app/e2e/support/helpers/startup-dsl.ts index fe78109fe5..dc65b63e08 100644 --- a/packages/app/e2e/support/helpers/startup-dsl.ts +++ b/packages/app/e2e/support/helpers/startup-dsl.ts @@ -163,7 +163,7 @@ class StartupAssertions { await expect(hostRow).toBeVisible({ timeout: 15_000 }); await expect(hostRow).toContainText(input.label); await expect(this.page.getByTestId("sidebar-add-project")).toBeVisible(); - await expect(this.page.getByTestId("sidebar-home")).toBeVisible(); + await expect(this.page.getByTestId("sidebar-import-session")).toBeVisible(); await expect(this.page.getByTestId("sidebar-settings")).toBeVisible(); await expect(this.page.getByTestId("welcome-screen")).toHaveCount(0); return this; diff --git a/packages/app/e2e/support/helpers/workspace-setup.ts b/packages/app/e2e/support/helpers/workspace-setup.ts index 8603429866..f990e52e86 100644 --- a/packages/app/e2e/support/helpers/workspace-setup.ts +++ b/packages/app/e2e/support/helpers/workspace-setup.ts @@ -276,9 +276,9 @@ export async function navigateToWorkspaceViaSidebar( }); } -export async function returnHomeFromWorkspace(page: Page): Promise { - await page.getByRole("button", { name: "Home" }).click(); - await expect(page).not.toHaveURL(/\/workspace\//, { timeout: 30_000 }); +export async function leaveWorkspaceViaHistory(page: Page): Promise { + await page.getByRole("button", { name: "History", exact: true }).click(); + await expect(page).toHaveURL(/\/sessions$/, { timeout: 30_000 }); } export async function openWorkspaceScriptsMenu(page: Page): Promise { diff --git a/packages/app/src/command-center/root-registration.tsx b/packages/app/src/command-center/root-registration.tsx index 272713486c..bb39882630 100644 --- a/packages/app/src/command-center/root-registration.tsx +++ b/packages/app/src/command-center/root-registration.tsx @@ -8,6 +8,7 @@ import { FolderPlus, History, Home, + Import, Keyboard, PanelLeft, Plus, @@ -17,6 +18,7 @@ import { withUnistyles } from "react-native-unistyles"; import { getIsElectronRuntime, useIsCompactFormFactor } from "@/constants/layout"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useImportSession } from "@/hooks/use-import-session"; import { useKeyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher-context"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; @@ -52,6 +54,7 @@ const ThemedSettings = withUnistyles(Settings, (theme) => ({ color: theme.colors.foregroundMuted, })); const ThemedHome = withUnistyles(Home, (theme) => ({ color: theme.colors.foregroundMuted })); +const ThemedImport = withUnistyles(Import, (theme) => ({ color: theme.colors.foregroundMuted })); const ThemedFolder = withUnistyles(Folder, (theme) => ({ color: theme.colors.foregroundMuted })); const ThemedCircleDashed = withUnistyles(CircleDashed, (theme) => ({ color: theme.colors.foregroundMuted, @@ -88,6 +91,10 @@ function HomeIcon({ size }: CommandCenterIconProps) { return ; } +function ImportIcon({ size }: CommandCenterIconProps) { + return ; +} + function FolderIcon({ size }: CommandCenterIconProps) { return ; } @@ -106,6 +113,7 @@ export function CommandCenterRootActions() { const { overrides } = useKeyboardShortcutOverrides(); const shortcutsAvailable = useKeyboardShortcutsAvailable(); const openAddProject = useOpenAddProject(); + const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const settingsRoute = useMemo(() => buildSettingsRoute(), []); const homeRoute = useMemo(() => buildOpenProjectRoute(), []); const sessionsRoute = useMemo(() => buildSessionsRoute(), []); @@ -166,11 +174,29 @@ export function CommandCenterRootActions() { }, }, { - id: "home", + id: "import-session", group: "actions", groupRank: 0, rank: 2, - keywords: ["home", "start", "import", "session", "pair", "device", "providers"], + keywords: ["import", "session", "terminal"], + visibility: "always", + run: () => { + clearCommandCenterFocusRestoreElement(); + openImportSession(); + }, + presentation: { + kind: "action", + title: t("importSession.title"), + sectionTitle: t("shell.commandCenter.actions"), + icon: ImportIcon, + }, + }, + { + id: "home", + group: "actions", + groupRank: 0, + rank: 3, + keywords: ["home", "start", "pair", "device", "providers"], visibility: "query", run: () => { clearCommandCenterFocusRestoreElement(); @@ -187,7 +213,7 @@ export function CommandCenterRootActions() { id: "history", group: "actions", groupRank: 0, - rank: 3, + rank: 4, keywords: ["history", "sessions", "recent"], visibility: "always", run: () => { @@ -205,7 +231,7 @@ export function CommandCenterRootActions() { id: "schedules", group: "actions", groupRank: 0, - rank: 4, + rank: 5, keywords: ["schedules", "scheduled", "automation", "recurring"], visibility: "always", run: () => { @@ -223,7 +249,7 @@ export function CommandCenterRootActions() { id: "settings", group: "actions", groupRank: 0, - rank: 5, + rank: 6, keywords: ["settings", "preferences", "config", "configuration"], visibility: "always", run: () => { @@ -250,7 +276,7 @@ export function CommandCenterRootActions() { id: "toggle-left-sidebar", group: "actions", groupRank: 0, - rank: 7, + rank: 8, keywords: ["toggle", "sidebar", "left", "panel", "workspaces"], visibility: "query", run: () => { @@ -274,7 +300,7 @@ export function CommandCenterRootActions() { id: "keyboard-shortcuts", group: "actions", groupRank: 0, - rank: 6, + rank: 7, keywords: ["keyboard", "shortcuts", "keys", "hotkeys"], visibility: "always", run: () => setShortcutsDialogOpen(true), @@ -309,6 +335,7 @@ export function CommandCenterRootActions() { homeRoute, keyboardActionDispatcher, openAddProject, + openImportSession, overrides, schedulesRoute, sessionsRoute, @@ -322,5 +349,5 @@ export function CommandCenterRootActions() { ]); useCommandCenterActions({ sourceId: "root", enabled: true, actions }); - return null; + return importSessionSheet; } diff --git a/packages/app/src/components/import-session-sheet-view-model.test.ts b/packages/app/src/components/import-session-sheet-view-model.test.ts index 784ad9d39d..785b18da7b 100644 --- a/packages/app/src/components/import-session-sheet-view-model.test.ts +++ b/packages/app/src/components/import-session-sheet-view-model.test.ts @@ -4,10 +4,16 @@ import { aggregateSessionEntries, ALL_FILTER_VALUE, buildProviderLabelMap, - collectErroredProviderLabels, + collectProviderErrorRows, computeEmptyState, + formatDirectoryLabel, getPromptPreview, getSessionTitle, + hasMoreSessions, + nextPageLimit, + PER_PROVIDER_LIMIT, + resolveDirectoryLabel, + resolveImportTarget, resolveProvidersToFetch, requiresImportSessionsHostUpgrade, type SessionsQueryResult, @@ -167,27 +173,176 @@ describe("sumFilteredAlreadyImportedCount", () => { }); }); -describe("collectErroredProviderLabels", () => { - it("returns no labels when no providers are being fetched", () => { - expect(collectErroredProviderLabels(null, [], new Map())).toEqual([]); +describe("collectProviderErrorRows", () => { + it("returns no rows when no providers are being fetched", () => { + expect(collectProviderErrorRows(null, [], new Map())).toEqual([]); }); - it("returns labels for each errored provider, falling back to provider id", () => { - const labels = collectErroredProviderLabels( + it("returns a row for each provider whose request failed", () => { + const rows = collectProviderErrorRows( ["claude", "codex"], [settled(undefined, { isError: true }), settled({ entries: [] })], new Map([["claude", "Claude Code"]]), ); - expect(labels).toEqual(["Claude Code"]); + expect(rows).toEqual([{ provider: "claude", label: "Claude Code" }]); + }); + + it("returns a row for a provider the daemon reported as failed in a good response", () => { + const rows = collectProviderErrorRows( + ["codex"], + [ + settled({ + entries: [], + providerErrors: [{ provider: "codex", message: "timed out" }], + }), + ], + new Map([["codex", "Codex"]]), + ); + expect(rows).toEqual([{ provider: "codex", label: "Codex" }]); }); it("uses provider id when the label map has no entry", () => { - const labels = collectErroredProviderLabels( + const rows = collectProviderErrorRows( ["codex"], [settled(undefined, { isError: true })], new Map(), ); - expect(labels).toEqual(["codex"]); + expect(rows).toEqual([{ provider: "codex", label: "codex" }]); + }); +}); + +describe("nextPageLimit", () => { + it("grows the per-provider limit one step at a time", () => { + expect(nextPageLimit(PER_PROVIDER_LIMIT)).toBe(45); + expect(nextPageLimit(45)).toBe(90); + expect(nextPageLimit(90)).toBe(200); + }); + + it("stops at the protocol ceiling", () => { + expect(nextPageLimit(200)).toBe(200); + }); +}); + +describe("hasMoreSessions", () => { + it("reports more when a provider filled the requested page", () => { + expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 2)).toBe(true); + }); + + it("reports no more once every provider returned a short page", () => { + expect(hasMoreSessions([settled({ entries: [entry()] })], 2)).toBe(false); + }); + + it("reports no more at the last page size, however full the page is", () => { + expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 200)).toBe(false); + }); + + it("keeps reporting more while the next page replaces placeholder rows", () => { + expect( + hasMoreSessions([settled({ entries: [entry()] }, { isPlaceholderData: true })], 45), + ).toBe(true); + }); +}); + +describe("resolveDirectoryLabel", () => { + const projects = [ + { rootPath: "/home/me/paseo", name: "paseo" }, + { rootPath: "/home/me/paseo/packages/app", name: "paseo app" }, + ]; + + it("names the project root after the project alone", () => { + expect(resolveDirectoryLabel("/home/me/paseo", projects)).toEqual({ name: "paseo" }); + }); + + it("qualifies a worktree under the project with its path below the root", () => { + expect( + resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]), + ).toEqual({ name: "paseo", detail: ".dev/worktrees/abc/zebra" }); + }); + + it("tells two worktrees of the same project apart", () => { + const zebra = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]); + const otter = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/def/otter", [projects[0]!]); + expect(zebra).not.toEqual(otter); + expect([zebra.detail, otter.detail]).toEqual([ + ".dev/worktrees/abc/zebra", + ".dev/worktrees/def/otter", + ]); + }); + + it("picks the most specific project root containing the directory", () => { + expect(resolveDirectoryLabel("/home/me/paseo/packages/app/src", projects)).toEqual({ + name: "paseo app", + detail: "src", + }); + }); + + it("ignores trailing slashes on both sides", () => { + expect( + resolveDirectoryLabel("/home/me/paseo/", [{ rootPath: "/home/me/paseo/", name: "p" }]), + ).toEqual({ name: "p" }); + }); + + it("does not match a project root that is only a string prefix", () => { + expect(resolveDirectoryLabel("/home/me/paseo-fork", projects)).toEqual({ + name: "/home/me/paseo-fork", + }); + }); + + it("falls back to the path when no project owns the directory", () => { + expect(resolveDirectoryLabel("/tmp/scratch", projects)).toEqual({ name: "/tmp/scratch" }); + }); +}); + +describe("formatDirectoryLabel", () => { + it("shows the project name alone at its root", () => { + expect(formatDirectoryLabel({ name: "paseo" })).toBe("paseo"); + }); + + it("appends the path under the root so worktrees of one project read apart", () => { + expect(formatDirectoryLabel({ name: "paseo", detail: ".dev/worktrees/zebra" })).toBe( + "paseo · .dev/worktrees/zebra", + ); + }); +}); + +describe("resolveImportTarget", () => { + it("trusts a scoped listing, whose rows the daemon already matched realpath-aware", () => { + expect( + resolveImportTarget({ + entryCwd: "/private/repo/paseo", + workspaceCwd: "/repo/paseo", + workspaceId: "ws-1", + isScopedListing: true, + }), + ).toEqual({ workspaceId: "ws-1", crossWorkspace: false }); + }); + + it("keeps the current workspace for a Show-all row in that workspace's directory", () => { + expect( + resolveImportTarget({ + entryCwd: "/repo/paseo/", + workspaceCwd: "/repo/paseo", + workspaceId: "ws-1", + isScopedListing: false, + }), + ).toEqual({ workspaceId: "ws-1", crossWorkspace: false }); + }); + + it("drops the workspace for a Show-all row from another directory, which the daemon rejects", () => { + expect( + resolveImportTarget({ + entryCwd: "/repo/other", + workspaceCwd: "/repo/paseo", + workspaceId: "ws-1", + isScopedListing: false, + }), + ).toEqual({ crossWorkspace: true }); + }); + + it("treats a sheet with no workspace as cross-workspace", () => { + expect(resolveImportTarget({ entryCwd: "/repo/paseo", isScopedListing: false })).toEqual({ + crossWorkspace: true, + }); }); }); @@ -236,6 +391,7 @@ describe("computeEmptyState", () => { isQueryingProviders: true, allQueriesSettled: true, selectedProvider: ALL_FILTER_VALUE, + hasQuery: false, aggregatedCount: 0, visibleCount: 0, totalAlreadyImportedCount: 0, @@ -274,6 +430,15 @@ describe("computeEmptyState", () => { }); }); + it("says nothing matched when a query narrowed the list to nothing", () => { + const result = computeEmptyState({ + ...baseInputs, + hasQuery: true, + totalAlreadyImportedCount: 4, + }); + expect(result.emptyStateTitle).toBe("No sessions match your search."); + }); + it("shows the already-imported message when imported entries were filtered out", () => { const result = computeEmptyState({ ...baseInputs, diff --git a/packages/app/src/components/import-session-sheet-view-model.ts b/packages/app/src/components/import-session-sheet-view-model.ts index ec6cd78f9a..d9f31944c1 100644 --- a/packages/app/src/components/import-session-sheet-view-model.ts +++ b/packages/app/src/components/import-session-sheet-view-model.ts @@ -5,6 +5,18 @@ import { i18n } from "@/i18n/i18next"; export const PER_PROVIDER_LIMIT = 15; export const ALL_FILTER_VALUE = "__all__"; +/** + * Paging grows the per-provider limit instead of carrying an offset. The daemon + * ranks the whole candidate set on every request, so an offset into a previous + * ranking would drift as sessions move; refetching a larger page cannot. 200 is + * the protocol ceiling on `limit`. + */ +const PAGE_LIMITS: readonly number[] = [PER_PROVIDER_LIMIT, 45, 90, 200]; + +export function nextPageLimit(limit: number): number { + return PAGE_LIMITS.find((candidate) => candidate > limit) ?? limit; +} + export function requiresImportSessionsHostUpgrade(input: { supportsSnapshot: boolean; workspaceId?: string | null; @@ -18,11 +30,13 @@ export interface SessionsQueryResult { | { entries: FetchRecentProviderSessionEntry[]; filteredAlreadyImportedCount?: number; + providerErrors?: Array<{ provider: string; message: string }>; } | undefined; isError: boolean; isLoading: boolean; isPending: boolean; + isPlaceholderData?: boolean; } export function resolveProvidersToFetch( @@ -81,20 +95,132 @@ export function sumFilteredAlreadyImportedCount( return total; } -export function collectErroredProviderLabels( +/** + * A provider that returned a full page may have more behind it. The daemon + * slices to `limit` after dropping already-imported sessions, so a short page is + * the end of that provider's list. Placeholder rows belong to the previous page, + * so the button holds its place instead of blinking out while the next loads. + */ +export function hasMoreSessions( + queries: ReadonlyArray, + limit: number, +): boolean { + if (nextPageLimit(limit) === limit) return false; + return queries.some((entryQuery) => { + const count = entryQuery.data?.entries.length ?? 0; + return entryQuery.isPlaceholderData ? count > 0 : count >= limit; + }); +} + +export interface ProviderErrorRow { + provider: string; + label: string; +} + +/** + * One row per provider that could not be listed, whether the request itself + * failed or the daemon reported the provider as failed in an otherwise good + * response. Both mean the same thing to the user, and both retry the same way. + */ +export function collectProviderErrorRows( providersToFetch: AgentProvider[] | null, queries: ReadonlyArray, providerLabelById: ReadonlyMap, -): string[] { +): ProviderErrorRow[] { if (providersToFetch === null) return []; - const labels: string[] = []; - for (let index = 0; index < queries.length; index++) { - if (queries[index]?.isError) { - const provider = providersToFetch[index]; - labels.push(providerLabelById.get(provider) ?? provider); - } + const rows: ProviderErrorRow[] = []; + for (let index = 0; index < providersToFetch.length; index++) { + const query = queries[index]; + const provider = providersToFetch[index]; + if (!query || provider === undefined) continue; + const failed = query.isError || (query.data?.providerErrors?.length ?? 0) > 0; + if (!failed) continue; + rows.push({ provider, label: providerLabelById.get(provider) ?? provider }); } - return labels; + return rows; +} + +export interface DirectoryProject { + rootPath: string; + name: string; +} + +export interface DirectoryLabel { + /** The owning project's name, or the whole path when no project owns it. */ + name: string; + /** + * The directory's path under its project root, absent at the root itself. + * Worktrees of one project all resolve to the same name, so without this the + * sheet would show several identical headings. + */ + detail?: string; +} + +function withoutTrailingSlash(path: string): string { + return path.length > 1 ? path.replace(/\/+$/, "") : path; +} + +/** The project's name when the app knows the directory, else the directory itself. */ +export function resolveDirectoryLabel( + directory: string, + projects: ReadonlyArray, +): DirectoryLabel { + const normalized = withoutTrailingSlash(directory); + let bestRoot: string | null = null; + let bestName: string | null = null; + for (const project of projects) { + const root = withoutTrailingSlash(project.rootPath); + if (!root) continue; + if (bestRoot !== null && root.length <= bestRoot.length) continue; + if (normalized !== root && !normalized.startsWith(`${root}/`)) continue; + bestRoot = root; + bestName = project.name; + } + if (bestRoot === null || bestName === null) { + return { name: normalized }; + } + const relative = normalized.slice(bestRoot.length + 1); + return relative ? { name: bestName, detail: relative } : { name: bestName }; +} + +/** The one-line form a row shows under its prompt preview. */ +export function formatDirectoryLabel(label: DirectoryLabel): string { + return label.detail ? `${label.name} · ${label.detail}` : label.name; +} + +export interface ImportTarget { + /** + * Omitted unless the row's directory is the scoped workspace's own: the daemon + * rejects an import whose cwd does not match the requested workspace. + */ + workspaceId?: string; + /** + * The agent lands in a workspace other than the one the sheet was opened from, + * so the caller has to open that workspace instead of adding a tab here. + */ + crossWorkspace: boolean; +} + +export function resolveImportTarget(input: { + entryCwd: string; + /** The directory the sheet was opened for, absent when it was opened host-wide. */ + workspaceCwd?: string | null; + workspaceId?: string | null; + /** The listed rows came from a fetch scoped to `workspaceCwd`. */ + isScopedListing: boolean; +}): ImportTarget { + if (!input.workspaceId || !input.workspaceCwd) { + return { crossWorkspace: true }; + } + // A scoped listing only holds rows the daemon already matched to this + // workspace's directory with realpaths resolved, which the client cannot do. + // "Show all" drops that guarantee, so each row is compared by path instead. + const belongsToWorkspace = + input.isScopedListing || + withoutTrailingSlash(input.entryCwd) === withoutTrailingSlash(input.workspaceCwd); + return belongsToWorkspace + ? { workspaceId: input.workspaceId, crossWorkspace: false } + : { crossWorkspace: true }; } export function getSessionTitle(entry: FetchRecentProviderSessionEntry): string { @@ -123,6 +249,7 @@ export interface EmptyStateInputs { isQueryingProviders: boolean; allQueriesSettled: boolean; selectedProvider: string; + hasQuery: boolean; aggregatedCount: number; visibleCount: number; totalAlreadyImportedCount: number; @@ -142,6 +269,11 @@ export function computeEmptyState(input: EmptyStateInputs): { if (!showEmptyState) { return { showEmptyState, emptyStateTitle: "" }; } + // A query narrowing the list to nothing is a different answer from a host with + // nothing to import, and the recovery is different too. + if (input.hasQuery) { + return { showEmptyState, emptyStateTitle: i18n.t("importSession.empty.noMatches") }; + } const isFilteredEmpty = input.selectedProvider !== ALL_FILTER_VALUE && input.aggregatedCount > 0; if (isFilteredEmpty) { const label = input.providerLabelById.get(input.selectedProvider) ?? input.selectedProvider; diff --git a/packages/app/src/components/import-session-sheet.test.tsx b/packages/app/src/components/import-session-sheet.test.tsx index 0f2820ba76..65e32d752c 100644 --- a/packages/app/src/components/import-session-sheet.test.tsx +++ b/packages/app/src/components/import-session-sheet.test.tsx @@ -14,12 +14,12 @@ import { ImportSessionSheet } from "@/components/import-session-sheet"; const { theme } = vi.hoisted(() => ({ theme: { - spacing: { 1: 4, 1.5: 6, 2: 8, 3: 12, 4: 16, 6: 24 }, + spacing: { 1: 4, 1.5: 6, 2: 8, 2.5: 10, 3: 12, 4: 16, 6: 24, 8: 32 }, borderWidth: { 1: 1 }, borderRadius: { md: 6, lg: 8, full: 9999 }, fontSize: { xs: 11, sm: 13, base: 15 }, fontWeight: { normal: "400", medium: "500", semibold: "600" }, - iconSize: { sm: 14, md: 16 }, + iconSize: { sm: 14, md: 16, lg: 24 }, opacity: { 50: 0.5 }, colors: { foreground: "#fff", @@ -30,6 +30,8 @@ const { theme } = vi.hoisted(() => ({ surface3: "#333", border: "#444", borderAccent: "#555", + interactionHighlight: "rgba(255,255,255,0.08)", + palette: { red: { 300: "#f87171" } }, }, }, })); @@ -75,6 +77,8 @@ vi.mock("lucide-react-native", () => { Inbox: icon("Inbox"), Layers: icon("Layers"), RotateCw: icon("RotateCw"), + Search: icon("Search"), + X: icon("X"), }; }); @@ -117,6 +121,22 @@ vi.mock("@/components/ui/combobox", () => ({ ComboboxItem: ({ label }: { label: string }) => React.createElement("span", null, label), })); +interface SheetSearchProps { + onChange: (value: string) => void; + placeholder?: string; + testID?: string; +} + +function SheetSearchInput({ search }: { search: SheetSearchProps }) { + const handleChange = React.useCallback( + (event: React.ChangeEvent) => search.onChange(event.target.value), + [search], + ); + return ( + + ); +} + vi.mock("@/components/adaptive-modal-sheet", () => ({ AdaptiveModalSheet: ({ visible, @@ -125,14 +145,21 @@ vi.mock("@/components/adaptive-modal-sheet", () => ({ testID, }: { visible: boolean; - header?: { title: string; actions?: ReactNode }; + header?: { + title: string; + subtitle?: ReactNode; + actions?: ReactNode; + search?: { onChange: (value: string) => void; placeholder?: string; testID?: string }; + }; children: ReactNode; testID?: string; }) => visible ? (

{header?.title}

+ {header?.subtitle} {header?.actions} + {header?.search ? : null} {children}
) : null, @@ -163,26 +190,62 @@ vi.mock("@/hooks/use-providers-snapshot", () => ({ }), })); +const mockHostFeatures = vi.hoisted(() => ({ + current: { importSessionSearch: true, importSessionWorkspaceTarget: true } as Record< + string, + boolean + >, +})); + +vi.mock("@/runtime/host-features", () => ({ + useHostFeature: (_serverId: string | null, feature: string) => + mockHostFeatures.current[feature] === true, +})); + +vi.mock("@/runtime/host-runtime", () => ({ + useHosts: () => [{ serverId: "server-1", label: "workbench" }], +})); + +const mockHostProjects = vi.hoisted(() => ({ + current: [] as Array<{ iconWorkingDir: string; projectName: string }>, +})); + +vi.mock("@/projects/host-projects", () => ({ + useHostProjects: () => mockHostProjects.current, +})); + interface RenderOptions { visible?: boolean; onClose?: () => void; onImportedAgent?: (agentId: string) => void; onImported?: (agent: Awaited>) => void; cwd?: string | null; + workspaceId?: string; + supportsSearch?: boolean; + projects?: Array<{ iconWorkingDir: string; projectName: string }>; snapshot?: { entries?: ProviderSnapshotEntry[]; supportsSnapshot?: boolean; }; } -function renderSheet( - client: Pick, - options?: RenderOptions, -) { +function applyHostMocks(options?: RenderOptions) { mockSnapshot.current = { entries: options?.snapshot?.entries, supportsSnapshot: options?.snapshot?.supportsSnapshot ?? false, }; + mockHostFeatures.current = { + importSessionSearch: options?.supportsSearch ?? true, + importSessionWorkspaceTarget: true, + }; + mockHostProjects.current = options?.projects ?? []; +} + +function renderSheet( + client: Pick, + options?: RenderOptions, +) { + applyHostMocks(options); const queryClient = new QueryClient({ defaultOptions: { @@ -200,6 +263,7 @@ function renderSheet( client={client} serverId="server-1" cwd={cwd} + workspaceId={options?.workspaceId} onClose={options?.onClose ?? vi.fn()} onImportedAgent={options?.onImportedAgent ?? vi.fn()} onImported={options?.onImported} @@ -245,6 +309,16 @@ function createImportedAgentSnapshot(id: string): Awaited + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: `thread-${index}`, + title: `Session ${index}`, + }), + ); +} + function createProviderSessionEntry( overrides?: Partial, ): FetchRecentProviderSessionEntry { @@ -377,7 +451,8 @@ describe("ImportSessionSheet", () => { }, ); - await screen.findByText("Could not load recent sessions."); + await screen.findByText("Could not load Claude Code sessions"); + screen.getByTestId("import-session-retry-claude"); }); it("loads recent provider sessions for the workspace and renders descriptor-owned labels", async () => { @@ -478,19 +553,34 @@ describe("ImportSessionSheet", () => { }); it("imports a selected session by provider handle and reports the imported agent", async () => { - const fetchRecentProviderSessions = vi.fn(async () => ({ - requestId: "recent-provider-sessions", - entries: [ - createProviderSessionEntry({ - providerId: "claude", - providerLabel: "Claude Code", - cwd: "/repo/paseo-realpath", + const events: string[] = []; + let fetchCount = 0; + const fetchRecentProviderSessions = vi.fn(async () => { + fetchCount += 1; + events.push("fetch"); + if (fetchCount > 1) { + return await new Promise(() => {}); + } + return { + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerLabel: "Claude Code", + cwd: "/repo/paseo-realpath", + }), + ], + }; + }); + let resolveImport!: (agent: ReturnType) => void; + const importAgent = vi.fn( + () => + new Promise>((resolve) => { + resolveImport = resolve; }), - ], - })); - const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported")); - const onClose = vi.fn(); - const onImportedAgent = vi.fn(); + ); + const onClose = vi.fn(() => events.push("close")); + const onImportedAgent = vi.fn(() => events.push("navigate")); renderSheet( { fetchRecentProviderSessions, importAgent } as Pick< @@ -500,21 +590,31 @@ describe("ImportSessionSheet", () => { { onClose, onImportedAgent, + workspaceId: "ws-current", snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, }, ); - fireEvent.click(await screen.findByTestId("import-session-session-claude-provider-thread-1")); + const row = await screen.findByTestId("import-session-session-claude-provider-thread-1"); + expect(events).toEqual(["fetch"]); + fireEvent.click(row); + + await screen.findByText("Importing..."); + expect(events).toEqual(["fetch"]); + resolveImport(createImportedAgentSnapshot("agent-imported")); await waitFor(() => { expect(importAgent).toHaveBeenCalledWith({ providerId: "claude", providerHandleId: "provider-thread-1", cwd: "/repo/paseo-realpath", + workspaceId: "ws-current", }); + expect(events).toEqual(["fetch", "close", "navigate"]); }); expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); expect(onClose).toHaveBeenCalledTimes(1); + expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1); }); it("shows an import error state without closing when selected session import fails", async () => { @@ -649,7 +749,7 @@ describe("ImportSessionSheet", () => { ); await screen.findByText("Session codex"); - await screen.findByText("Could not load sessions for Claude Code."); + await screen.findByText("Could not load Claude Code sessions"); }); it("filters the merged list when a provider badge is selected and restores it on All", async () => { @@ -758,7 +858,7 @@ describe("ImportSessionSheet", () => { expect(fetchRecentProviderSessions).not.toHaveBeenCalled(); }); - it("omits cwd from fetch and renders the session cwd on each row when cwd is unset", async () => { + it("omits cwd from fetch and names each row's folder when cwd is unset", async () => { const fetchRecentProviderSessions = vi.fn(async () => ({ requestId: "recent-provider-sessions", entries: [ @@ -793,6 +893,9 @@ describe("ImportSessionSheet", () => { expect.objectContaining({ cwd: expect.anything() }), ); await screen.findByText("/home/me/work/other-project"); + expect( + screen.getByTestId("import-session-row-folder-claude-provider-thread-1").textContent, + ).toBe("/home/me/work/other-project"); }); it("uses the session's cwd when importing in cwd-less mode and fires onImported", async () => { @@ -836,10 +939,344 @@ describe("ImportSessionSheet", () => { }); expect(onImported).toHaveBeenCalledTimes(1); expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" })); - expect(onImportedAgent).toHaveBeenCalledWith("agent-imported"); + expect(onImportedAgent).not.toHaveBeenCalled(); expect(onClose).toHaveBeenCalledTimes(1); }); + it("names the scope after the host when unscoped and after the workspace when scoped", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + })); + const importAgent = vi.fn(); + const client = createRecentSessionsClient(fetchRecentProviderSessions, importAgent); + + renderSheet(client, { + cwd: null, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }); + await screen.findByText("Sessions on workbench"); + expect(screen.queryByTestId("import-session-show-all")).toBeNull(); + cleanup(); + + renderSheet(client, { + workspaceId: "ws-current", + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }); + await screen.findByText("This workspace"); + screen.getByTestId("import-session-show-all"); + }); + + it("lists unscoped rows newest first and names each row's folder", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "newest", + cwd: "/home/me/paseo", + title: "Newest in paseo", + lastActivityAt: "2026-04-30T12:00:00.000Z", + }), + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "elsewhere", + cwd: "/tmp/scratch", + title: "Scratch session", + lastActivityAt: "2026-04-30T11:00:00.000Z", + }), + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "older", + cwd: "/home/me/paseo", + title: "Older in paseo", + lastActivityAt: "2026-04-30T10:00:00.000Z", + }), + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "worktree", + cwd: "/home/me/paseo/.dev/worktrees/zebra", + title: "Worktree session", + lastActivityAt: "2026-04-30T09:00:00.000Z", + }), + ], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + projects: [{ iconWorkingDir: "/home/me/paseo", projectName: "paseo" }], + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByTestId("import-session-session-claude-newest"); + expect( + screen + .getAllByTestId(/^import-session-session-/) + .map((row) => row.getAttribute("data-testid")), + ).toEqual([ + "import-session-session-claude-newest", + "import-session-session-claude-elsewhere", + "import-session-session-claude-older", + "import-session-session-claude-worktree", + ]); + expect( + screen.getAllByTestId(/^import-session-row-folder-/).map((folder) => folder.textContent), + ).toEqual(["paseo", "/tmp/scratch", "paseo", "paseo · .dev/worktrees/zebra"]); + }); + + it("leaves the folder off every row when the sheet is scoped to one workspace", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: "scoped", + cwd: "/repo/paseo", + title: "Scoped session", + }), + ], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + workspaceId: "ws-current", + projects: [{ iconWorkingDir: "/repo/paseo", projectName: "paseo" }], + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("Scoped session"); + expect(screen.queryAllByTestId(/^import-session-row-folder-/)).toHaveLength(0); + }); + + it("sends the debounced search query to every provider", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalled(); + }); + + fireEvent.change(screen.getByTestId("import-session-search"), { + target: { value: "invoice" }, + }); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + providers: ["claude"], + limit: 15, + query: "invoice", + }); + }); + }); + + it("hides the search field when the host cannot search sessions", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + supportsSearch: false, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("import-session-search")).toBeNull(); + }); + + it("asks for a larger page when Load more is pressed, and stops offering it on a short page", async () => { + const fetchRecentProviderSessions = vi.fn(async (options: { limit?: number } | undefined) => ({ + requestId: "recent-provider-sessions", + entries: createPageOfEntries(options?.limit === 15 ? 15 : 20), + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + cwd: null, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + fireEvent.click(await screen.findByTestId("import-session-load-more")); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + providers: ["claude"], + limit: 45, + }); + }); + await waitFor(() => { + expect(screen.queryByTestId("import-session-load-more")).toBeNull(); + }); + }); + + it("stops the refresh spinner and retries only the failed provider", async () => { + const fetchRecentProviderSessions = vi.fn( + async (options: { providers?: string[] } | undefined) => { + const provider = options?.providers?.[0]; + if (provider === "claude") { + throw new Error("claude offline"); + } + return { + requestId: `recent-${provider}`, + entries: [ + createProviderSessionEntry({ + providerId: provider ?? "codex", + providerHandleId: `${provider}-thread`, + title: `Session ${provider}`, + }), + ], + }; + }, + ); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { + supportsSnapshot: true, + entries: [createSnapshotEntry("claude"), createSnapshotEntry("codex")], + }, + }, + ); + + await screen.findByText("Session codex"); + await screen.findByText("Could not load Claude Code sessions"); + await waitFor(() => { + expect(screen.queryByTestId("import-session-loading-spinner")).toBeNull(); + }); + + fetchRecentProviderSessions.mockClear(); + fireEvent.click(screen.getByTestId("import-session-retry-claude")); + + await waitFor(() => { + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + cwd: "/repo/paseo", + providers: ["claude"], + limit: 15, + }); + }); + expect(fetchRecentProviderSessions).toHaveBeenCalledTimes(1); + }); + + it("shows an error row for a provider the daemon reported as failed", async () => { + const fetchRecentProviderSessions = vi.fn(async () => ({ + requestId: "recent-provider-sessions", + entries: [createProviderSessionEntry({ providerId: "codex", providerLabel: "Codex" })], + providerErrors: [{ provider: "codex", message: "timed out" }], + })); + const importAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("codex")] }, + }, + ); + + await screen.findByText("Could not load Codex sessions"); + }); + + it("imports a foreign-directory row without the current workspace once Show all is on", async () => { + const fetchRecentProviderSessions = vi.fn(async (options: { cwd?: string } | undefined) => ({ + requestId: "recent-provider-sessions", + entries: [ + createProviderSessionEntry({ + providerId: "claude", + providerHandleId: options?.cwd ? "scoped-thread" : "foreign-thread", + cwd: options?.cwd ?? "/home/me/work/other-project", + title: options?.cwd ? "Scoped session" : "Foreign session", + }), + ], + })); + const importAgent = vi.fn(async () => createImportedAgentSnapshot("agent-imported")); + const onImported = vi.fn(); + const onImportedAgent = vi.fn(); + + renderSheet( + { fetchRecentProviderSessions, importAgent } as Pick< + DaemonClient, + "fetchRecentProviderSessions" | "importAgent" + >, + { + workspaceId: "ws-current", + onImported, + onImportedAgent, + snapshot: { supportsSnapshot: true, entries: [createSnapshotEntry("claude")] }, + }, + ); + + await screen.findByText("Scoped session"); + fireEvent.click(screen.getByTestId("import-session-show-all")); + + await screen.findByText("Foreign session"); + expect(fetchRecentProviderSessions).toHaveBeenCalledWith({ + providers: ["claude"], + limit: 15, + }); + + fireEvent.click(screen.getByTestId("import-session-session-claude-foreign-thread")); + + await waitFor(() => { + expect(importAgent).toHaveBeenCalledWith({ + providerId: "claude", + providerHandleId: "foreign-thread", + cwd: "/home/me/work/other-project", + }); + }); + expect(onImported).toHaveBeenCalledWith(expect.objectContaining({ id: "agent-imported" })); + expect(onImportedAgent).not.toHaveBeenCalled(); + }); + it("refetches sessions when the refresh button is clicked", async () => { const fetchRecentProviderSessions = vi.fn(async () => ({ requestId: "recent-provider-sessions", diff --git a/packages/app/src/components/import-session-sheet.tsx b/packages/app/src/components/import-session-sheet.tsx index 21b81b27d2..4ae38a9aea 100644 --- a/packages/app/src/components/import-session-sheet.tsx +++ b/packages/app/src/components/import-session-sheet.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Pressable, type PressableStateCallbackType, Text, View } from "react-native"; -import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import { keepPreviousData, useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import type { DaemonClient, @@ -10,22 +10,33 @@ import type { AgentProvider } from "@getpaseo/protocol/agent-types"; import { ChevronDown, Inbox, Layers, RotateCw } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Combobox, ComboboxItem, type ComboboxOption } from "@/components/ui/combobox"; import { getProviderIcon } from "@/components/provider-icons"; import { formatTimeAgo } from "@/utils/time"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; +import { useHostProjects } from "@/projects/host-projects"; import { useHostFeature } from "@/runtime/host-features"; +import { useHosts } from "@/runtime/host-runtime"; import { i18n } from "@/i18n/i18next"; import { aggregateSessionEntries, ALL_FILTER_VALUE, buildProviderLabelMap, - collectErroredProviderLabels, + collectProviderErrorRows, computeEmptyState, + type DirectoryProject, + formatDirectoryLabel, getPromptPreview, getSessionTitle, + hasMoreSessions, + resolveDirectoryLabel, + nextPageLimit, PER_PROVIDER_LIMIT, + type ProviderErrorRow, + resolveImportTarget, resolveProvidersToFetch, requiresImportSessionsHostUpgrade, sumFilteredAlreadyImportedCount, @@ -33,6 +44,8 @@ import { const IMPORT_SHEET_SNAP_POINTS = ["70%", "92%"]; const DISABLED_ACCESSIBILITY_STATE = { disabled: true }; +/** Long enough that a typed word is one request, short enough to feel live. */ +const SEARCH_DEBOUNCE_MS = 200; type RecentProviderSessionsClient = Pick< DaemonClient, @@ -48,7 +61,9 @@ interface ImportSessionSheetProps { cwd?: string | null; workspaceId?: string | null; onClose: () => void; + /** The agent belongs to the workspace the sheet was opened from; open it here. */ onImportedAgent?: (agentId: string) => void; + /** The agent belongs to its own workspace; open that workspace and navigate. */ onImported?: (agent: ImportedAgent) => void; } @@ -56,27 +71,51 @@ type RecentSessionsResponse = Awaited< ReturnType >; +type SessionsQueryKey = ReadonlyArray; + +function buildSessionsQueryKey(input: { + cwd: string | null; + query: string; + limit: number; + provider?: string; +}): SessionsQueryKey { + return [ + "recent-provider-sessions", + input.cwd, + input.query, + input.limit, + ...(input.provider === undefined ? [] : [input.provider]), + ]; +} + interface SessionsQueryConfig { - queryKey: ReadonlyArray; + queryKey: SessionsQueryKey; enabled: boolean; + // A provider that cannot answer stays broken until the user asks again. React + // Query's default retry would keep the header spinner turning and pile up + // in-flight daemon requests behind a dead provider (#2512). + retry: false; + placeholderData: typeof keepPreviousData; queryFn: () => Promise; } function buildSessionsQueriesConfig(args: { providersToFetch: AgentProvider[] | null; - sessionsQueryRoot: ReadonlyArray; visible: boolean; client: RecentProviderSessionsClient | null; - cwd: string | null | undefined; + cwd: string | null; + query: string; + limit: number; hostDisconnectedMessage?: string; }): SessionsQueryConfig[] { - const { providersToFetch, sessionsQueryRoot, visible, client, cwd, hostDisconnectedMessage } = - args; + const { providersToFetch, visible, client, cwd, query, limit, hostDisconnectedMessage } = args; if (providersToFetch === null) return []; const enabled = visible && Boolean(client); return providersToFetch.map((provider) => ({ - queryKey: [...sessionsQueryRoot, provider], + queryKey: buildSessionsQueryKey({ cwd, query, limit, provider }), enabled, + retry: false as const, + placeholderData: keepPreviousData, queryFn: async () => { if (!client) { throw new Error(hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")); @@ -84,7 +123,8 @@ function buildSessionsQueriesConfig(args: { return await client.fetchRecentProviderSessions({ ...(cwd ? { cwd } : {}), providers: [provider], - limit: PER_PROVIDER_LIMIT, + limit, + ...(query ? { query } : {}), }); }, })); @@ -96,8 +136,6 @@ interface SheetStatusMessagesProps { hasNoImportableProviders: boolean; isLoadingSessions: boolean; hasRows: boolean; - allQueriesErrored: boolean; - erroredProviderLabels: ReadonlyArray; importErrored: boolean; } @@ -107,8 +145,6 @@ function SheetStatusMessages({ hasNoImportableProviders, isLoadingSessions, hasRows, - allQueriesErrored, - erroredProviderLabels, importErrored, }: SheetStatusMessagesProps) { const { theme } = useUnistyles(); @@ -130,16 +166,6 @@ function SheetStatusMessages({ {t("importSession.status.loading")} ) : null} - {allQueriesErrored ? ( - {t("importSession.status.failedAll")} - ) : null} - {!allQueriesErrored && erroredProviderLabels.length > 0 ? ( - - {t("importSession.status.failedProviders", { - providers: erroredProviderLabels.join(", "), - })} - - ) : null} {importErrored ? ( {t("importSession.status.failedImport")} ) : null} @@ -147,6 +173,48 @@ function SheetStatusMessages({ ); } +function ProviderErrorBanner({ + rows, + onRetry, +}: { + rows: ReadonlyArray; + onRetry: (provider: string) => void; +}) { + return ( + + {rows.map((row) => ( + + ))} + + ); +} + +function ProviderErrorBannerRow({ + row, + onRetry, +}: { + row: ProviderErrorRow; + onRetry: (provider: string) => void; +}) { + const { t } = useTranslation(); + const handleRetry = useCallback(() => onRetry(row.provider), [onRetry, row.provider]); + return ( + + + {t("importSession.status.failedProvider", { provider: row.label })} + + + + ); +} + function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPress: () => void }) { const { theme } = useUnistyles(); const { t } = useTranslation(); @@ -177,6 +245,38 @@ function RefreshAction({ isRefreshing, onPress }: { isRefreshing: boolean; onPre ); } +function ScopeSubtitle({ + hostLabel, + isScoped, + onShowAll, +}: { + hostLabel: string; + isScoped: boolean; + onShowAll: () => void; +}) { + const { t } = useTranslation(); + return ( + + + {isScoped + ? t("importSession.scope.workspace") + : t("importSession.scope.host", { host: hostLabel })} + + {isScoped ? ( + + ) : null} + + ); +} + function SheetEmptyState({ title }: { title: string }) { const { theme } = useUnistyles(); return ( @@ -193,13 +293,14 @@ function ImportSessionSheetRow({ entry, disabled, importing, - showCwd, + folder, onImportSession, }: { entry: FetchRecentProviderSessionEntry; disabled: boolean; importing: boolean; - showCwd: boolean; + /** The row's directory, shown only when rows can come from more than one. */ + folder: string | null; onImportSession: (entry: FetchRecentProviderSessionEntry) => void; }) { const { theme } = useUnistyles(); @@ -248,9 +349,13 @@ function ImportSessionSheetRow({ {promptPreview} - {showCwd && entry.cwd ? ( - - {entry.cwd} + {folder ? ( + + {folder} ) : null} @@ -258,6 +363,35 @@ function ImportSessionSheetRow({ ); } +function SessionRows({ + entries, + disabled, + importingSessionKey, + resolveFolder, + onImportSession, +}: { + entries: ReadonlyArray; + disabled: boolean; + importingSessionKey: string | null; + resolveFolder: (entry: FetchRecentProviderSessionEntry) => string | null; + onImportSession: (entry: FetchRecentProviderSessionEntry) => void; +}) { + return ( + + {entries.map((entry) => ( + + ))} + + ); +} + export function ImportSessionSheet({ visible, client, @@ -272,8 +406,32 @@ export function ImportSessionSheet({ const queryClient = useQueryClient(); const { theme } = useUnistyles(); + // "Show all" widens a workspace-scoped sheet to the whole host. The sheet's own + // `cwd` stays the scope it was opened with, because that is what decides where + // an imported agent lands. + const [isShowingAllDirectories, setIsShowingAllDirectories] = useState(false); + const [searchInput, setSearchInput] = useState(""); + const [pageLimit, setPageLimit] = useState(PER_PROVIDER_LIMIT); + const [selectedProvider, setSelectedProvider] = useState(ALL_FILTER_VALUE); + + const scopeCwd = isShowingAllDirectories ? null : (cwd ?? null); + const supportsSearch = useHostFeature(serverId, "importSessionSearch"); + const query = useDebouncedValue(supportsSearch ? searchInput : "", SEARCH_DEBOUNCE_MS).trim(); + + useEffect(() => { + if (visible) return; + setIsShowingAllDirectories(false); + setSearchInput(""); + }, [visible]); + + // A narrower or wider list starts at page one; keeping a grown limit would + // fetch 200 rows for a query that matches three. + useEffect(() => { + setPageLimit(PER_PROVIDER_LIMIT); + }, [query, scopeCwd]); + const { entries: snapshotEntries, supportsSnapshot } = useProvidersSnapshot(serverId, { - cwd, + cwd: scopeCwd, enabled: visible, }); const supportsWorkspaceTarget = useHostFeature(serverId, "importSessionWorkspaceTarget"); @@ -293,22 +451,20 @@ export function ImportSessionSheet({ [snapshotEntries], ); - const sessionsQueryRoot = useMemo( - () => ["recent-provider-sessions", cwd ?? null] as const, - [cwd], - ); + const sessionsQueryRoot = useMemo(() => ["recent-provider-sessions", scopeCwd], [scopeCwd]); const queriesConfig = useMemo( () => buildSessionsQueriesConfig({ providersToFetch, - sessionsQueryRoot, visible, client, - cwd, + cwd: scopeCwd, + query, + limit: pageLimit, hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"), }), - [providersToFetch, sessionsQueryRoot, visible, client, cwd, t], + [providersToFetch, visible, client, scopeCwd, query, pageLimit, t], ); const queries = useQueries({ queries: queriesConfig }); @@ -320,8 +476,6 @@ export function ImportSessionSheet({ ); const filterProviders = useMemo(() => [...(providersToFetch ?? [])].sort(), [providersToFetch]); - - const [selectedProvider, setSelectedProvider] = useState(ALL_FILTER_VALUE); const [isFilterOpen, setIsFilterOpen] = useState(false); const filterAnchorRef = useRef(null); @@ -339,6 +493,27 @@ export function ImportSessionSheet({ return aggregatedEntries.filter((entry) => entry.providerId === selectedProvider); }, [aggregatedEntries, selectedProvider]); + const projectServerIds = useMemo(() => (serverId ? [serverId] : []), [serverId]); + const hostProjects = useHostProjects(projectServerIds); + const directoryProjects = useMemo( + () => + hostProjects.map((project) => ({ + rootPath: project.iconWorkingDir, + name: project.projectName, + })), + [hostProjects], + ); + + // A scoped sheet only lists one directory, so naming it on every row is noise. + const showRowFolders = scopeCwd === null; + const resolveFolder = useCallback( + (entry: FetchRecentProviderSessionEntry) => + showRowFolders + ? formatDirectoryLabel(resolveDirectoryLabel(entry.cwd, directoryProjects)) + : null, + [showRowFolders, directoryProjects], + ); + const filterComboboxOptions = useMemo( () => [ { id: ALL_FILTER_VALUE, label: t("importSession.filters.all") }, @@ -414,19 +589,31 @@ export function ImportSessionSheet({ if (!entry.cwd) { throw new Error("Session is missing a working directory"); } + const target = resolveImportTarget({ + entryCwd: entry.cwd, + workspaceCwd: cwd, + workspaceId, + isScopedListing: scopeCwd !== null, + }); const agent = await client.importAgent({ providerId: entry.providerId, providerHandleId: entry.providerHandleId, cwd: entry.cwd, - ...(workspaceId ? { workspaceId } : {}), + ...(target.workspaceId ? { workspaceId: target.workspaceId } : {}), }); - return agent; + return { agent, target }; }, - onSuccess: async (agent) => { - await queryClient.invalidateQueries({ queryKey: sessionsQueryRoot }); + onSuccess: ({ agent, target }) => { onClose(); - onImportedAgent?.(agent.id); - onImported?.(agent); + if (target.crossWorkspace) { + onImported?.(agent); + } else { + onImportedAgent?.(agent.id); + } + void queryClient.invalidateQueries({ + queryKey: sessionsQueryRoot, + refetchType: "none", + }); }, }); @@ -442,23 +629,61 @@ export function ImportSessionSheet({ [importMutation], ); - const erroredProviderLabels = useMemo( - () => collectErroredProviderLabels(providersToFetch, queries, providerLabelById), + const providerErrorRows = useMemo( + () => collectProviderErrorRows(providersToFetch, queries, providerLabelById), [queries, providersToFetch, providerLabelById], ); - const isRefreshing = queries.some((query) => query.isFetching); + // Every query settles, errors included, so this stops turning (#2512). + const isRefreshing = queries.some((providerQuery) => providerQuery.isFetching); const handleRefresh = useCallback(() => { void queryClient.invalidateQueries({ queryKey: sessionsQueryRoot }); }, [queryClient, sessionsQueryRoot]); + const handleRetryProvider = useCallback( + (provider: string) => { + void queryClient.refetchQueries({ + queryKey: buildSessionsQueryKey({ cwd: scopeCwd, query, limit: pageLimit, provider }), + }); + }, + [pageLimit, query, queryClient, scopeCwd], + ); + + const handleShowAll = useCallback(() => setIsShowingAllDirectories(true), []); + const handleLoadMore = useCallback(() => setPageLimit(nextPageLimit), []); + + const hosts = useHosts(); + const hostLabel = useMemo( + () => hosts.find((host) => host.serverId === serverId)?.label ?? serverId ?? "", + [hosts, serverId], + ); + const header = useMemo( () => ({ title: t("importSession.title"), + subtitle: ( + + ), + ...(supportsSearch + ? { + search: { + onChange: setSearchInput, + placeholder: t("importSession.searchPlaceholder"), + // The compact sheet keeps its content mounted while hidden, so the + // field has to be told to drop the text the state already dropped. + resetKey: visible ? "open" : "closed", + testID: "import-session-search", + }, + } + : {}), actions: , }), - [isRefreshing, handleRefresh, t], + [handleRefresh, handleShowAll, hostLabel, isRefreshing, scopeCwd, supportsSearch, t, visible], ); const isSnapshotUnsupported = requiresHostUpgrade; @@ -467,22 +692,27 @@ export function ImportSessionSheet({ const isQueryingProviders = queries.length > 0; const isLoadingSessions = isWaitingForSnapshot || - (isQueryingProviders && queries.some((query) => query.isLoading || query.isPending)); - const allQueriesErrored = isQueryingProviders && queries.every((query) => query.isError); + (isQueryingProviders && + queries.some((providerQuery) => providerQuery.isLoading || providerQuery.isPending)); + const allQueriesErrored = + isQueryingProviders && queries.every((providerQuery) => providerQuery.isError); const allQueriesSettled = - isQueryingProviders && queries.every((query) => !query.isLoading && !query.isPending); + isQueryingProviders && + queries.every((providerQuery) => !providerQuery.isLoading && !providerQuery.isPending); const { showEmptyState, emptyStateTitle } = computeEmptyState({ isLoadingSessions, allQueriesErrored, isQueryingProviders, allQueriesSettled, selectedProvider, + hasQuery: query.length > 0, aggregatedCount: aggregatedEntries.length, visibleCount: visibleEntries.length, totalAlreadyImportedCount, providerLabelById, }); const showFilter = filterProviders.length > 1; + const showLoadMore = hasMoreSessions(queries, pageLimit); return ( 0} - allQueriesErrored={allQueriesErrored} - erroredProviderLabels={erroredProviderLabels} importErrored={importMutation.isError} /> + {providerErrorRows.length > 0 ? ( + + ) : null} {visibleEntries.length > 0 ? ( - - {visibleEntries.map((entry) => ( - - ))} + + ) : null} + {showLoadMore ? ( + + ) : null} {showEmptyState ? : null} @@ -560,6 +798,21 @@ export function ImportSessionSheet({ } const styles = StyleSheet.create((theme) => ({ + subtitleRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + minWidth: 0, + }, + subtitleText: { + flexShrink: 1, + minWidth: 0, + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + }, + subtitleAction: { + marginHorizontal: -theme.spacing[2], + }, filterTriggerWrap: { paddingBottom: theme.spacing[2], }, @@ -568,6 +821,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", gap: theme.spacing[1.5], alignSelf: "flex-start", + maxWidth: "100%", paddingVertical: theme.spacing[1.5], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.md, @@ -582,6 +836,8 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, }, filterTriggerText: { + flexShrink: 1, + minWidth: 0, color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, @@ -589,6 +845,10 @@ const styles = StyleSheet.create((theme) => ({ list: { gap: theme.spacing[1], }, + footer: { + paddingTop: theme.spacing[2], + alignItems: "center", + }, row: { flexDirection: "row", alignItems: "flex-start", @@ -636,7 +896,7 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.base, lineHeight: 20, }, - rowCwd: { + rowFolder: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, @@ -650,6 +910,26 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, }, + errorBanner: { + marginBottom: theme.spacing[2], + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.border, + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[3], + }, + errorRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: theme.spacing[2], + }, + errorText: { + flexShrink: 1, + minWidth: 0, + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + }, emptyState: { alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 0b6ec160aa..1cdfe83e6d 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,5 @@ import { router } from "expo-router"; -import { FolderPlus, GitBranch, Home, Server, Settings, X } from "lucide-react-native"; +import { FolderPlus, GitBranch, Import, Server, Settings, X } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -30,6 +30,7 @@ import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useImportSession } from "@/hooks/use-import-session"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { type SidebarProjectEntry, @@ -46,11 +47,7 @@ import { usePanelStore } from "@/stores/panel-store"; import { useOwnsWindowChromeCorner, WindowChromeSafeArea } from "@/utils/desktop-window"; import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; -import { - buildOpenProjectRoute, - buildSettingsAddHostRoute, - buildSettingsRoute, -} from "@/utils/host-routes"; +import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; @@ -78,7 +75,7 @@ interface SidebarSharedProps { toggleProjectCollapsed: (projectViewKey: string) => void; handleRefresh: () => void; handleOpenProject: () => void; - handleHome: () => void; + handleImportSession: () => void; handleSettings: () => void; labels: SidebarLabels; handleAddHost: () => void; @@ -88,7 +85,7 @@ interface SidebarSharedProps { interface SidebarLabels { addProject: string; hosts: string; - home: string; + importSession: string; settings: string; searchHosts: string; closeSidebar: string; @@ -145,6 +142,7 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole }, [isRevalidating, isManualRefresh]); const openProjectPicker = useOpenAddProject(); + const { open: openImportSession, sheet: importSessionSheet } = useImportSession(); const handleOpenProjectMobile = useCallback(() => { showMobileAgent(); @@ -185,20 +183,16 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole openHostOverview(serverId); }, []); - const handleHomeMobile = useCallback(() => { + const handleImportSessionMobile = useCallback(() => { showMobileAgent(); - router.push(buildOpenProjectRoute()); - }, [showMobileAgent]); - - const handleHomeDesktop = useCallback(() => { - router.push(buildOpenProjectRoute()); - }, []); + openImportSession(); + }, [openImportSession, showMobileAgent]); const labels = useMemo( (): SidebarLabels => ({ addProject: t("sidebar.actions.addProject"), hosts: t("sidebar.actions.hosts"), - home: t("sidebar.actions.home"), + importSession: t("importSession.title"), settings: t("sidebar.actions.settings"), searchHosts: t("sidebar.host.searchPlaceholder"), closeSidebar: t("sidebar.actions.closeSidebar"), @@ -228,36 +222,42 @@ export const LeftSidebar = memo(function LeftSidebar({ active }: { active: boole if (isCompactLayout) { return ( - - - + <> + + + + {importSessionSheet} + ); } return ( - - - + <> + + + + {importSessionSheet} + ); }); @@ -444,7 +444,7 @@ function IconTooltipContent({ function SidebarFooter({ theme, handleOpenProject, - handleHome, + handleImportSession, handleSettings, labels, handleAddHost, @@ -452,12 +452,12 @@ function SidebarFooter({ }: { theme: SidebarTheme; handleOpenProject: () => void; - handleHome: () => void; + handleImportSession: () => void; handleSettings: () => void; labels: { addProject: string; hosts: string; - home: string; + importSession: string; settings: string; searchHosts: string; }; @@ -483,10 +483,10 @@ function SidebarFooter({ onOpenHostSettings={handleOpenHostSettings} /> @@ -522,7 +522,7 @@ function MobileSidebar({ toggleProjectCollapsed, handleRefresh, handleOpenProject, - handleHome, + handleImportSession, handleSettings, labels, handleAddHost, @@ -595,6 +595,7 @@ function MobileSidebar({ onRefresh={handleRefresh} onWorkspacePress={handleWorkspacePress} onAddProject={handleOpenProject} + onImportSession={handleImportSession} parentGestureRef={closeGestureRef} dragGestureHostActive={active} listHeaderComponent={workspacesSectionHeaderElement} @@ -604,7 +605,7 @@ function MobileSidebar({ )} @@ -780,7 +782,7 @@ function DesktopSidebar({ void; onWorkspacePress?: () => void; onAddProject?: () => void; + onImportSession?: () => void; listFooterComponent?: ReactElement | null; // Rendered inside the scroll area, below the Pinned section and above the workspace // list. Holds the "Workspaces" section header so pinned items sit above it. @@ -1879,6 +1880,7 @@ export function SidebarWorkspaceList({ onRefresh: _onRefresh, onWorkspacePress, onAddProject, + onImportSession, listFooterComponent, listHeaderComponent, parentGestureRef, @@ -1972,6 +1974,7 @@ export function SidebarWorkspaceList({ shortcutIndexByWorkspaceKey={shortcutIndexByWorkspaceKey} onWorkspacePress={onWorkspacePress} onAddProject={onAddProject} + onImportSession={onImportSession} listFooterComponent={listFooterComponent} listHeaderComponent={listHeaderComponent} sidebarFilterEmpty={sidebarFilterEmpty} @@ -2067,6 +2070,7 @@ function ProjectModeList({ shortcutIndexByWorkspaceKey, onWorkspacePress, onAddProject, + onImportSession, listFooterComponent, listHeaderComponent, sidebarFilterEmpty, @@ -2375,7 +2379,7 @@ function ProjectModeList({ const projectBody = projects.length === 0 ? ( - + ) : ( void }) { +export function SidebarProjectEmptyState({ + onAddProject, + onImportSession, +}: { + onAddProject?: () => void; + onImportSession?: () => void; +}) { const { t } = useTranslation(); return ( @@ -47,6 +53,9 @@ export function SidebarProjectEmptyState({ onAddProject }: { onAddProject?: () = + ); } diff --git a/packages/app/src/hooks/use-import-session.tsx b/packages/app/src/hooks/use-import-session.tsx new file mode 100644 index 0000000000..bdcf5c4563 --- /dev/null +++ b/packages/app/src/hooks/use-import-session.tsx @@ -0,0 +1,94 @@ +import { useCallback, useState, type ReactNode } from "react"; +import { type Href, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; +import { ImportSessionSheet } from "@/components/import-session-sheet"; +import { useHostChooser } from "@/hosts/host-chooser"; +import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { useOpenProject } from "@/hooks/use-open-project"; + +interface UseImportSessionOptions { + serverId?: string; + cwd?: string; + workspaceId?: string; +} + +interface UseImportSessionResult { + open: () => void; + sheet: ReactNode; +} + +interface ImportedAgentTarget { + id: string; + cwd: string; +} + +/** + * Sends the user to an agent that belongs to a workspace other than the one they + * are looking at. The imported session's own directory decides the destination, + * so the project has to exist before the route can resolve. + */ +export function useNavigateToImportedAgent( + serverId: string | null | undefined, +): (agent: ImportedAgentTarget) => Promise { + const router = useRouter(); + const normalizedServerId = serverId?.trim() || null; + const openProject = useOpenProject(normalizedServerId); + + return useCallback( + async (agent: ImportedAgentTarget) => { + if (!normalizedServerId) return; + const project = await openProject(agent.cwd); + if (project.ok) { + router.push(buildHostAgentDetailRoute(normalizedServerId, agent.id) as Href); + } + }, + [normalizedServerId, openProject, router], + ); +} + +export function useImportSession({ + serverId, + cwd, + workspaceId, +}: UseImportSessionOptions = {}): UseImportSessionResult { + const { t } = useTranslation(); + const chooseHost = useHostChooser(); + const [importServerId, setImportServerId] = useState(null); + const [isSheetOpen, setIsSheetOpen] = useState(false); + const client = useHostRuntimeClient(importServerId ?? ""); + const navigateToImportedAgent = useNavigateToImportedAgent(importServerId); + + const openForHost = useCallback((chosenServerId: string) => { + setImportServerId(chosenServerId); + setIsSheetOpen(true); + }, []); + + const open = useCallback(() => { + if (serverId) { + openForHost(serverId); + return; + } + chooseHost({ + title: t("importSession.chooseHostTitle"), + onChooseHost: openForHost, + }); + }, [chooseHost, openForHost, serverId, t]); + + const close = useCallback(() => setIsSheetOpen(false), []); + + return { + open, + sheet: ( + + ), + }; +} diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 17be56be3b..46b3428026 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -285,6 +285,7 @@ describe("translation resources", () => { it("includes workspace and panel keys for the Batch 4A migration", () => { expect(en.importSession.title).toBe("Import session"); + expect(en.importSession.chooseHostTitle).toBe("Import from host"); expect(en.importSession.status.connectHost).toBe("Connect to a host to import sessions"); expect(en.importSession.actions.refresh).toBe("Refresh sessions"); expect(en.workspace.fileExplorer.sort.name).toBe("Name"); @@ -298,6 +299,9 @@ describe("translation resources", () => { expect(en.workspace.browser.controls.enterUrl).toBe("Enter URL"); expect(en.workspace.terminal.hostDisconnected).toBe("Host is not connected"); expect(en.panels.file.directoryMissing).toBe("Workspace directory not found."); + expect(en.openProject.tiles.importSession.description).toBe( + "Open a Claude Code, Codex or other session you started in a terminal", + ); }); it("includes workspace Git and review keys for the Batch 4B migration", () => { @@ -444,7 +448,6 @@ describe("translation resources", () => { expect(en.sidebar.host.searchPlaceholder).toBe("Search hosts..."); expect(en.sidebar.actions.addProject).toBe("Add project"); expect(en.sidebar.actions.hosts).toBe("Hosts"); - expect(en.sidebar.actions.home).toBe("Home"); expect(en.sidebar.actions.settings).toBe("Settings"); expect(en.sidebar.actions.closeSidebar).toBe("Close sidebar"); expect(en.sidebar.sections.sessions).toBe("History"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 7379e9f78d..ae99bfdb58 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -357,7 +357,13 @@ export const ar: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "جلسة الاستيراد", + searchPlaceholder: "البحث في الجلسات...", + scope: { + host: "الجلسات على {{host}}", + workspace: "مساحة العمل هذه", + }, filters: { all: "الجميع", }, @@ -366,12 +372,13 @@ export const ar: TranslationResources = { updateHost: "قم بتحديث المضيف لاستيراد الجلسات.", noProviders: "لم يتم تمكين أي موفري خدمات قابلين للاستيراد.", loading: "جارٍ تحميل الجلسات الأخيرة...", - failedAll: "تعذر تحميل الجلسات الأخيرة.", - failedProviders: "تعذر تحميل جلسات العمل لـ{{providers}}.", + failedProvider: "تعذر تحميل جلسات {{provider}}", failedImport: "تعذر استيراد الجلسة المحددة.", }, actions: { refresh: "تحديث الجلسات", + showAll: "عرض الكل", + loadMore: "تحميل المزيد", }, preview: { untitledSession: "جلسة بلا عنوان", @@ -379,6 +386,7 @@ export const ar: TranslationResources = { }, empty: { noRecent: "لا توجد جلسات حديثة لاستيرادها.", + noMatches: "لا توجد جلسات تطابق بحثك.", alreadyImported: "تم بالفعل استيراد كافة الجلسات الأخيرة.", noProviderSessions: "لم يتم العثور على جلسات{{provider}}.", }, @@ -1082,7 +1090,6 @@ export const ar: TranslationResources = { addProject: "إضافة مشروع", newWorkspace: "مساحة عمل جديدة", hosts: "المضيفون", - home: "بيت", settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", }, diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index c33009f27f..5c79d4d7ea 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -356,6 +356,12 @@ export const en = { }, importSession: { title: "Import session", + chooseHostTitle: "Import from host", + searchPlaceholder: "Search sessions...", + scope: { + host: "Sessions on {{host}}", + workspace: "This workspace", + }, filters: { all: "All", }, @@ -364,12 +370,13 @@ export const en = { updateHost: "Update the host to import sessions.", noProviders: "No importable providers are enabled.", loading: "Loading recent sessions...", - failedAll: "Could not load recent sessions.", - failedProviders: "Could not load sessions for {{providers}}.", + failedProvider: "Could not load {{provider}} sessions", failedImport: "Could not import selected session.", }, actions: { refresh: "Refresh sessions", + showAll: "Show all", + loadMore: "Load more", }, preview: { untitledSession: "Untitled session", @@ -377,6 +384,7 @@ export const en = { }, empty: { noRecent: "No recent sessions to import.", + noMatches: "No sessions match your search.", alreadyImported: "All recent sessions are already imported.", noProviderSessions: "No {{provider}} sessions found.", }, @@ -1091,7 +1099,6 @@ export const en = { addProject: "Add project", newWorkspace: "New workspace", hosts: "Hosts", - home: "Home", settings: "Settings", closeSidebar: "Close sidebar", }, @@ -1423,7 +1430,7 @@ export const en = { }, importSession: { title: "Import session", - description: "Bring in recent external CLI sessions", + description: "Open a Claude Code, Codex or other session you started in a terminal", }, setupProviders: { title: "Setup providers", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1a36747194..5e797c9982 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -360,7 +360,13 @@ export const es: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "Importar sesión", + searchPlaceholder: "Buscar sesiones...", + scope: { + host: "Sesiones en {{host}}", + workspace: "Este espacio de trabajo", + }, filters: { all: "Todo", }, @@ -369,12 +375,13 @@ export const es: TranslationResources = { updateHost: "Actualice el host para importar sesiones.", noProviders: "No hay proveedores importables habilitados.", loading: "Cargando sesiones recientes...", - failedAll: "No se pudieron cargar las sesiones recientes.", - failedProviders: "No se pudieron cargar sesiones para{{providers}}.", + failedProvider: "No se pudieron cargar las sesiones de {{provider}}", failedImport: "No se pudo importar la sesión seleccionada.", }, actions: { refresh: "Actualizar sesiones", + showAll: "Mostrar todo", + loadMore: "Cargar más", }, preview: { untitledSession: "Sesión sin título", @@ -382,6 +389,7 @@ export const es: TranslationResources = { }, empty: { noRecent: "No hay sesiones recientes para importar.", + noMatches: "Ninguna sesión coincide con tu búsqueda.", alreadyImported: "Todas las sesiones recientes ya están importadas.", noProviderSessions: "No se encontraron sesiones{{provider}}.", }, @@ -1116,7 +1124,6 @@ export const es: TranslationResources = { addProject: "Agregar proyecto", newWorkspace: "Nuevo espacio de trabajo", hosts: "Hosts", - home: "Hogar", settings: "Ajustes", closeSidebar: "Cerrar barra lateral", }, diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 050b69e3a4..5ea0b3a6ab 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -361,7 +361,13 @@ export const fr: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "Session d'importation", + searchPlaceholder: "Rechercher des sessions...", + scope: { + host: "Sessions sur {{host}}", + workspace: "Cet espace de travail", + }, filters: { all: "Tous", }, @@ -370,12 +376,13 @@ export const fr: TranslationResources = { updateHost: "Mettez à jour l'hôte pour importer des sessions.", noProviders: "Aucun fournisseur importable n'est activé.", loading: "Chargement des sessions récentes...", - failedAll: "Impossible de charger les sessions récentes.", - failedProviders: "Impossible de charger les sessions pour{{providers}}.", + failedProvider: "Impossible de charger les sessions {{provider}}", failedImport: "Impossible d'importer la session sélectionnée.", }, actions: { refresh: "Sessions de rafraîchissement", + showAll: "Tout afficher", + loadMore: "Charger plus", }, preview: { untitledSession: "Séance sans titre", @@ -383,6 +390,7 @@ export const fr: TranslationResources = { }, empty: { noRecent: "Aucune session récente à importer.", + noMatches: "Aucune session ne correspond à votre recherche.", alreadyImported: "Toutes les sessions récentes sont déjà importées.", noProviderSessions: "Aucune session{{provider}}trouvée.", }, @@ -1116,7 +1124,6 @@ export const fr: TranslationResources = { addProject: "Ajouter un projet", newWorkspace: "Nouvel espace de travail", hosts: "Hôtes", - home: "Maison", settings: "Paramètres", closeSidebar: "Fermer la barre latérale", }, diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index eac0654489..3025c03119 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -360,7 +360,13 @@ export const ja: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "セッションをインポート", + searchPlaceholder: "セッションを検索...", + scope: { + host: "{{host}} のセッション", + workspace: "このワークスペース", + }, filters: { all: "すべて", }, @@ -369,12 +375,13 @@ export const ja: TranslationResources = { updateHost: "セッションをインポートするにはホストを更新してください。", noProviders: "インポート可能なプロバイダーが有効になっていません。", loading: "最近のセッションを読み込み中...", - failedAll: "最近のセッションを読み込めませんでした。", - failedProviders: "{{providers}}のセッションを読み込めませんでした。", + failedProvider: "{{provider}} のセッションを読み込めませんでした", failedImport: "選択したセッションをインポートできませんでした。", }, actions: { refresh: "セッションを更新", + showAll: "すべて表示", + loadMore: "さらに読み込む", }, preview: { untitledSession: "無題のセッション", @@ -382,6 +389,7 @@ export const ja: TranslationResources = { }, empty: { noRecent: "インポートする最近のセッションがありません。", + noMatches: "検索に一致するセッションがありません。", alreadyImported: "最近のセッションはすでにすべてインポートされています。", noProviderSessions: "{{provider}}のセッションが見つかりません。", }, @@ -1094,7 +1102,6 @@ export const ja: TranslationResources = { addProject: "プロジェクトを追加", newWorkspace: "新しいワークスペース", hosts: "ホスト", - home: "ホーム", settings: "設定", closeSidebar: "サイドバーを閉じる", }, diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 92e880671a..631bdfa84d 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -358,7 +358,13 @@ export const ko: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "세션 가져오기", + searchPlaceholder: "세션 검색...", + scope: { + host: "{{host}}의 세션", + workspace: "이 워크스페이스", + }, filters: { all: "전체", }, @@ -367,12 +373,13 @@ export const ko: TranslationResources = { updateHost: "세션을 가져오려면 호스트를 업데이트하세요.", noProviders: "가져올 수 있는 프로바이더가 활성화되어 있지 않습니다.", loading: "최근 세션을 불러오는 중...", - failedAll: "최근 세션을 불러올 수 없습니다.", - failedProviders: "{{providers}}의 세션을 불러올 수 없습니다.", + failedProvider: "{{provider}} 세션을 불러올 수 없습니다", failedImport: "선택한 세션을 가져올 수 없습니다.", }, actions: { refresh: "세션 새로고침", + showAll: "전체 표시", + loadMore: "더 보기", }, preview: { untitledSession: "제목 없는 세션", @@ -380,6 +387,7 @@ export const ko: TranslationResources = { }, empty: { noRecent: "가져올 최근 세션이 없습니다.", + noMatches: "검색과 일치하는 세션이 없습니다.", alreadyImported: "최근 세션이 모두 이미 가져와졌습니다.", noProviderSessions: "{{provider}} 세션을 찾을 수 없습니다.", }, @@ -1089,7 +1097,6 @@ export const ko: TranslationResources = { addProject: "프로젝트 추가", newWorkspace: "새 워크스페이스", hosts: "호스트", - home: "홈", settings: "설정", closeSidebar: "사이드바 닫기", }, diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 445d97b23d..d823baee7c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -360,7 +360,13 @@ export const ptBR: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "Importar sessão", + searchPlaceholder: "Buscar sessões...", + scope: { + host: "Sessões em {{host}}", + workspace: "Este workspace", + }, filters: { all: "Tudo", }, @@ -369,12 +375,13 @@ export const ptBR: TranslationResources = { updateHost: "Atualize o host para importar sessões.", noProviders: "Nenhum provedor importável está ativado.", loading: "Carregando sessões recentes...", - failedAll: "Não foi possível carregar sessões recentes.", - failedProviders: "Não foi possível carregar sessões de {{providers}}.", + failedProvider: "Não foi possível carregar as sessões de {{provider}}", failedImport: "Não foi possível importar a sessão selecionada.", }, actions: { refresh: "Atualizar sessões", + showAll: "Mostrar tudo", + loadMore: "Carregar mais", }, preview: { untitledSession: "Sessão sem título", @@ -382,6 +389,7 @@ export const ptBR: TranslationResources = { }, empty: { noRecent: "Nenhuma sessão recente para importar.", + noMatches: "Nenhuma sessão corresponde à sua busca.", alreadyImported: "Todas as sessões recentes já foram importadas.", noProviderSessions: "Nenhuma sessão de {{provider}} encontrada.", }, @@ -1107,7 +1115,6 @@ export const ptBR: TranslationResources = { addProject: "Adicionar projeto", newWorkspace: "Novo workspace", hosts: "Hosts", - home: "Início", settings: "Configurações", closeSidebar: "Fechar barra lateral", }, diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 03683dbb4e..607f2641a4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -359,7 +359,13 @@ export const ru: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "Импортировать сессию", + searchPlaceholder: "Поиск сессий...", + scope: { + host: "Сессии на {{host}}", + workspace: "Это рабочее пространство", + }, filters: { all: "Все", }, @@ -368,12 +374,13 @@ export const ru: TranslationResources = { updateHost: "Обновите хост, чтобы импортировать сессии.", noProviders: "Нет включённых провайдеров с поддержкой импорта.", loading: "Загрузка недавних сессий...", - failedAll: "Не удалось загрузить недавние сессии.", - failedProviders: "Не удалось загрузить сессии следующих провайдеров: {{providers}}.", + failedProvider: "Не удалось загрузить сессии провайдера {{provider}}", failedImport: "Не удалось импортировать выбранную сессию.", }, actions: { refresh: "Обновить список сессий", + showAll: "Показать все", + loadMore: "Загрузить ещё", }, preview: { untitledSession: "Сессия без названия", @@ -381,6 +388,7 @@ export const ru: TranslationResources = { }, empty: { noRecent: "Нет недавних сессий для импорта.", + noMatches: "Нет сессий, соответствующих запросу.", alreadyImported: "Все недавние сессии уже импортированы.", noProviderSessions: "Сессии провайдера {{provider}} не найдены.", }, @@ -1098,7 +1106,6 @@ export const ru: TranslationResources = { addProject: "Добавить проект", newWorkspace: "Новое рабочее пространство", hosts: "Хосты", - home: "Главная", settings: "Настройки", closeSidebar: "Закрыть боковую панель", }, diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index fdb88913a0..096639587d 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -357,7 +357,13 @@ export const zhCN: TranslationResources = { }, }, importSession: { + chooseHostTitle: en.importSession.chooseHostTitle, title: "导入会话", + searchPlaceholder: "搜索会话...", + scope: { + host: "{{host}} 上的会话", + workspace: "此 workspace", + }, filters: { all: "全部", }, @@ -366,12 +372,13 @@ export const zhCN: TranslationResources = { updateHost: "更新 Host 以导入会话。", noProviders: "没有已启用的可导入 Provider。", loading: "正在加载最近会话...", - failedAll: "无法加载最近会话。", - failedProviders: "无法加载 {{providers}} 的会话。", + failedProvider: "无法加载 {{provider}} 的会话", failedImport: "无法导入所选会话。", }, actions: { refresh: "刷新会话", + showAll: "显示全部", + loadMore: "加载更多", }, preview: { untitledSession: "未命名会话", @@ -379,6 +386,7 @@ export const zhCN: TranslationResources = { }, empty: { noRecent: "没有可导入的最近会话。", + noMatches: "没有与搜索匹配的会话。", alreadyImported: "所有最近会话都已导入。", noProviderSessions: "没有找到 {{provider}} 会话。", }, @@ -1074,7 +1082,6 @@ export const zhCN: TranslationResources = { addProject: "添加 project", newWorkspace: "新建工作区", hosts: "Hosts", - home: "首页", settings: "设置", closeSidebar: "关闭侧边栏", }, diff --git a/packages/app/src/screens/open-project-screen.tsx b/packages/app/src/screens/open-project-screen.tsx index 11e90136bf..9cbe43d83e 100644 --- a/packages/app/src/screens/open-project-screen.tsx +++ b/packages/app/src/screens/open-project-screen.tsx @@ -8,6 +8,7 @@ import { PaseoLogo } from "@/components/icons/paseo-logo"; import { CommunityLinks } from "@/components/community-links"; import { MenuHeader } from "@/components/headers/menu-header"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; +import { useImportSession } from "@/hooks/use-import-session"; import { useHostChooser } from "@/hosts/host-chooser"; import { usePanelStore } from "@/stores/panel-store"; import { @@ -19,24 +20,17 @@ import { import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; -import { buildHostAgentDetailRoute, buildSettingsHostSectionRoute } from "@/utils/host-routes"; -import { ImportSessionSheet } from "@/components/import-session-sheet"; -import { useHostRuntimeClient } from "@/runtime/host-runtime"; -import { useOpenProject } from "@/hooks/use-open-project"; -import type { Href } from "expo-router"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; export function OpenProjectScreen() { const { t } = useTranslation(); const router = useRouter(); const openDesktopAgentList = usePanelStore((s) => s.openDesktopAgentList); const openProjectPicker = useOpenAddProject(); + const importSession = useImportSession(); const chooseHost = useHostChooser(); const localServerId = useLocalDaemonServerId(); - const [importServerId, setImportServerId] = useState(null); - const importClient = useHostRuntimeClient(importServerId ?? ""); - const openImportedProject = useOpenProject(importServerId); const [isPairDeviceOpen, setIsPairDeviceOpen] = useState(false); - const [isImportSheetOpen, setIsImportSheetOpen] = useState(false); const isCompactLayout = useIsCompactFormFactor(); @@ -53,30 +47,6 @@ export function OpenProjectScreen() { const handleOpenPairDevice = useCallback(() => setIsPairDeviceOpen(true), []); const handleClosePairDevice = useCallback(() => setIsPairDeviceOpen(false), []); - const handleOpenImportSession = useCallback(() => { - chooseHost({ - title: "Import from host", - onChooseHost: (serverId) => { - setImportServerId(serverId); - setIsImportSheetOpen(true); - }, - }); - }, [chooseHost]); - const handleCloseImportSession = useCallback(() => setIsImportSheetOpen(false), []); - - const handleImported = useCallback( - (agent: { id: string; cwd: string }) => { - if (!importServerId) return; - void (async () => { - const result = await openImportedProject(agent.cwd); - if (result.ok) { - router.push(buildHostAgentDetailRoute(importServerId, agent.id) as Href); - } - })(); - }, - [importServerId, openImportedProject, router], - ); - const handleOpenProviders = useCallback(() => { chooseHost({ title: "Choose host", @@ -107,7 +77,7 @@ export function OpenProjectScreen() { icon={Inbox} title={t("openProject.tiles.importSession.title")} description={t("openProject.tiles.importSession.description")} - onPress={handleOpenImportSession} + onPress={importSession.open} testID="open-project-import-session" /> - + {importSession.sheet} ); } diff --git a/packages/app/src/screens/sessions-screen.tsx b/packages/app/src/screens/sessions-screen.tsx index b063e042a6..7cc3294ef5 100644 --- a/packages/app/src/screens/sessions-screen.tsx +++ b/packages/app/src/screens/sessions-screen.tsx @@ -3,7 +3,7 @@ import { View, Text } from "react-native"; import { useIsFocused } from "@react-navigation/native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { ChevronLeft } from "lucide-react-native"; +import { ChevronLeft, Import } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { MenuHeader } from "@/components/headers/menu-header"; @@ -15,6 +15,7 @@ import { HostFilter } from "@/components/hosts/host-filter"; import { ALL_HOSTS_OPTION_ID } from "@/components/hosts/host-picker"; import { type AgentHistoryHostError, useAgentHistory } from "@/hooks/use-agent-history"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { useImportSession } from "@/hooks/use-import-session"; import { useHosts } from "@/runtime/host-runtime"; import { buildOpenProjectRoute } from "@/utils/host-routes"; @@ -72,6 +73,7 @@ export function SessionsScreen() { function SessionsScreenContent() { const { theme } = useUnistyles(); const { t } = useTranslation(); + const importSession = useImportSession(); const hosts = useHosts(); const [selectedHost, setSelectedHost] = useState(ALL_HOSTS_OPTION_ID); const [searchInput, setSearchInput] = useState(""); @@ -201,6 +203,9 @@ function SessionsScreenContent() { Back )} + ) : null} {!isInitialLoad && !showLoadError && agents.length > 0 ? ( @@ -216,6 +221,7 @@ function SessionsScreenContent() { flat={isSearching} /> ) : null} + {importSession.sheet} ); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 436d4e1e02..a27a1ed418 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -41,6 +41,7 @@ import { WorkspaceActions } from "@/git/workspace-actions"; import { WorkspaceOpenInEditorButton } from "@/workspace/open-in-editor/button"; import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button"; import { ImportSessionSheet } from "@/components/import-session-sheet"; +import { useNavigateToImportedAgent } from "@/hooks/use-import-session"; import { useToast } from "@/contexts/toast-context"; import { getOrCreateClientId } from "@/utils/client-id"; import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; @@ -2083,6 +2084,9 @@ function WorkspaceScreenContent({ }, [persistenceKey, selectWorkspaceTabInPane], ); + // A "Show all" import can land in another workspace entirely; that + // agent has no tab here, so it opens its own workspace instead. + const navigateToImportedAgent = useNavigateToImportedAgent(normalizedServerId); const handleImportedAgent = useCallback( (agentId: string) => { if (!persistenceKey) { @@ -4093,6 +4097,7 @@ function WorkspaceScreenContent({ workspaceId={normalizedWorkspaceId} onClose={closeImportSheet} onImportedAgent={handleImportedAgent} + onImported={navigateToImportedAgent} /> { providers: ["my-claude"], since: "2026-04-30T00:00:00.000Z", limit: 25, + query: "invoice", }); expect(mock.sent).toHaveLength(1); @@ -4511,6 +4512,7 @@ test("fetches scoped recent provider sessions", async () => { providers?: string[]; since?: string; limit?: number; + query?: string; }; }; expect(request.message).toMatchObject({ @@ -4519,6 +4521,7 @@ test("fetches scoped recent provider sessions", async () => { providers: ["my-claude"], since: "2026-04-30T00:00:00.000Z", limit: 25, + query: "invoice", }); mock.triggerMessage( diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index fecbddc8e2..05da6177ad 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -2096,6 +2096,7 @@ export class DaemonClient { ...(options?.providers ? { providers: options.providers } : {}), ...(options?.since ? { since: options.since } : {}), ...(options?.limit ? { limit: options.limit } : {}), + ...(options?.query !== undefined ? { query: options.query } : {}), }); return this.sendRequest({ requestId: resolvedRequestId, diff --git a/packages/protocol/src/messages.test.ts b/packages/protocol/src/messages.test.ts index 015164f703..3394c8e15f 100644 --- a/packages/protocol/src/messages.test.ts +++ b/packages/protocol/src/messages.test.ts @@ -344,6 +344,21 @@ describe("agent detach RPC", () => { } expect(parsed.features?.importSessionWorkspaceTarget).toBe(true); }); + + test("parses the session import search feature gate", () => { + const parsed = parseServerInfoStatusPayload({ + status: "server_info", + serverId: "srv-test", + features: { + importSessionSearch: true, + }, + }); + + if (!parsed) { + throw new Error("Expected server info payload to parse"); + } + expect(parsed.features?.importSessionSearch).toBe(true); + }); }); describe("agent setting action responses", () => { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 250311f006..f8c50b2d82 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1371,6 +1371,7 @@ export const FetchRecentProviderSessionsRequestMessageSchema = z.object({ providers: z.array(z.string()).optional(), since: z.string().optional(), limit: z.number().int().positive().max(200).optional(), + query: z.string().optional(), }); export const FetchAgentRequestMessageSchema = z.object({ @@ -3495,6 +3496,8 @@ export const ServerInfoStatusPayloadSchema = z providerRemoval: z.boolean().optional(), // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: z.boolean().optional(), + // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. + importSessionSearch: z.boolean().optional(), // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after // 2027-01-17 once the supported daemon floor is >= v0.2.0. // Daemon advertises pluggable non-GitHub forge support (the forge registry); @@ -3971,6 +3974,14 @@ export const FetchRecentProviderSessionsResponseMessageSchema = z.object({ requestId: z.string(), entries: z.array(RecentProviderSessionDescriptorPayloadSchema), filteredAlreadyImportedCount: z.number().int().nonnegative().optional(), + providerErrors: z + .array( + z.object({ + provider: z.string(), + message: z.string(), + }), + ) + .optional(), }), }); diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 6d39d59571..a856f3674e 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -264,6 +264,7 @@ describe("workspace message schemas", () => { expect(request.type).toBe("fetch_recent_provider_sessions_request"); expect(request.providers).toEqual(["my-claude"]); + expect(request.query).toBeUndefined(); expect(response.payload).toEqual({ requestId: "req-recent-provider-sessions", entries: [ @@ -281,6 +282,30 @@ describe("workspace message schemas", () => { }); }); + test("parses session import search requests and per-provider errors", () => { + const request = SessionInboundMessageSchema.parse({ + type: "fetch_recent_provider_sessions_request", + requestId: "req-search-provider-sessions", + query: "invoice", + }); + const response = SessionOutboundMessageSchema.parse({ + type: "fetch_recent_provider_sessions_response", + payload: { + requestId: "req-search-provider-sessions", + entries: [], + providerErrors: [{ provider: "codex", message: "Codex listing timed out" }], + }, + }); + + expect(request.query).toBe("invoice"); + if (response.type !== "fetch_recent_provider_sessions_response") { + throw new Error("expected fetch_recent_provider_sessions_response"); + } + expect(response.payload.providerErrors).toEqual([ + { provider: "codex", message: "Codex listing timed out" }, + ]); + }); + test("parses fetch_recent_provider_sessions response with filteredAlreadyImportedCount", () => { const response = SessionOutboundMessageSchema.parse({ type: "fetch_recent_provider_sessions_response", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 4293f7210b..33858623fd 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -9897,7 +9897,7 @@ test.each([ expect(includedClient.calls).toBe(1); expect(skippedClient.calls).toBe(0); - expect(result.map((d) => d.provider)).toEqual([includedProvider]); + expect(result.sessions.map((d) => d.provider)).toEqual([includedProvider]); }, ); @@ -9917,7 +9917,7 @@ test("listImportableSessions includes derived providers that list persisted agen expect(claudeClient.calls).toBe(1); expect(ompClient.calls).toBe(1); - expect(result.map((d) => d.provider).sort()).toEqual(["claude", "omp"]); + expect(result.sessions.map((d) => d.provider).sort()).toEqual(["claude", "omp"]); }); test("listImportableSessions narrows to the providerFilter when supplied", async () => { @@ -9938,7 +9938,7 @@ test("listImportableSessions narrows to the providerFilter when supplied", async expect(claudeClient.calls).toBe(1); expect(codexClient.calls).toBe(0); - expect(result.map((d) => d.provider)).toEqual(["claude"]); + expect(result.sessions.map((d) => d.provider)).toEqual(["claude"]); }); test("listImportableSessions skips providers that lack supportsSessionListing even when row listing is defined", async () => { @@ -9965,7 +9965,121 @@ test("listImportableSessions skips providers that lack supportsSessionListing ev expect(listableClient.calls).toBe(1); expect(nonListableClient.calls).toBe(0); - expect(result.map((d) => d.provider)).toEqual(["claude"]); + expect(result.sessions.map((d) => d.provider)).toEqual(["claude"]); +}); + +test("listImportableSessions returns healthy rows alongside thrown and timed-out provider errors", async () => { + vi.useFakeTimers(); + try { + const healthyClient = new RecordingPersistedAgentsClient("claude"); + const failingClient = new RecordingPersistedAgentsClient("codex"); + failingClient.listImportableSessions = async () => { + throw new Error("codex listing failed"); + }; + const hangingClient = new RecordingPersistedAgentsClient("pi"); + hangingClient.listImportableSessions = async () => await new Promise(() => undefined); + const manager = new AgentManager({ + clients: { claude: healthyClient, codex: failingClient, pi: hangingClient }, + providerDefinitions: { + claude: { enabled: true, derivedFromProviderId: null }, + codex: { enabled: true, derivedFromProviderId: null }, + pi: { enabled: true, derivedFromProviderId: null }, + }, + logger, + }); + + const resultPromise = manager.listImportableSessions(); + await vi.advanceTimersByTimeAsync(8_000); + + await expect(resultPromise).resolves.toEqual({ + sessions: [ + { + provider: "claude", + providerHandleId: "claude-session", + cwd: "/tmp/recent", + title: null, + lastActivityAt: new Date("2026-01-01T00:00:00Z"), + firstPromptPreview: null, + lastPromptPreview: null, + }, + ], + providerErrors: [ + { provider: "codex", message: "codex listing failed" }, + { + provider: "pi", + message: "Timed out listing importable sessions for provider 'pi' after 8000ms", + }, + ], + }); + } finally { + vi.useRealTimers(); + } +}); + +test("listImportableSessions searches every provider result before global ranking", async () => { + const client = new RecordingPersistedAgentsClient("claude"); + client.listImportableSessions = async () => [ + ...Array.from({ length: 25 }, (_, index) => ({ + providerHandleId: `non-match-${index}`, + cwd: "/tmp/other", + title: "Other work", + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date(`2026-04-${String(index + 2).padStart(2, "0")}T00:00:00.000Z`), + })), + { + providerHandleId: "title-match", + cwd: "/tmp/archive", + title: "Invoice cleanup", + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date("2026-04-01T04:00:00.000Z"), + }, + { + providerHandleId: "first-prompt-match", + cwd: "/tmp/archive", + title: "Unrelated", + firstPromptPreview: "Investigate invoice totals", + lastPromptPreview: null, + lastActivityAt: new Date("2026-04-01T03:00:00.000Z"), + }, + { + providerHandleId: "last-prompt-match", + cwd: "/tmp/archive", + title: "Unrelated", + firstPromptPreview: null, + lastPromptPreview: "Finish invoice export", + lastActivityAt: new Date("2026-04-01T02:00:00.000Z"), + }, + { + providerHandleId: "cwd-match", + cwd: "/tmp/invoice-service", + title: "Unrelated", + firstPromptPreview: null, + lastPromptPreview: null, + lastActivityAt: new Date("2026-04-01T01:00:00.000Z"), + }, + ]; + const manager = new AgentManager({ + clients: { claude: client }, + providerDefinitions: { + claude: { enabled: true, derivedFromProviderId: null }, + }, + logger, + }); + + const result = await manager.listImportableSessions({ + query: "INVOICE", + limit: 10, + scanLimit: 500, + }); + + expect(result.sessions.map((session) => session.providerHandleId)).toEqual([ + "title-match", + "first-prompt-match", + "last-prompt-match", + "cwd-match", + ]); }); test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 5e9b8cbcf5..2874d546e6 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { resolve } from "node:path"; +import { basename, resolve } from "node:path"; import { stat } from "node:fs/promises"; import { AGENT_LIFECYCLE_STATUSES, @@ -81,9 +81,11 @@ import { type ProviderSubagentDescriptor, type ProviderSubagentStoreEvent, } from "./provider-subagents/store.js"; +import { withTimeout } from "../../utils/promise-timeout.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; +const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 8_000; const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: false, supportsSessionPersistence: true, @@ -224,6 +226,16 @@ export interface ManagedImportableProviderSession extends ImportableProviderSess provider: AgentProvider; } +export interface ImportableSessionProviderError { + provider: AgentProvider; + message: string; +} + +export interface ManagedImportableSessionsResult { + sessions: ManagedImportableProviderSession[]; + providerErrors: ImportableSessionProviderError[]; +} + export type AgentAttentionCallback = (params: { agentId: string; provider: AgentProvider; @@ -921,37 +933,56 @@ export class AgentManager { async listImportableSessions( options?: ImportablePersistedAgentQueryOptions, - ): Promise { + ): Promise { const providerEntries = Array.from(this.clients.entries()).filter( ([provider, client]) => client.capabilities.supportsSessionListing && !!client.listImportableSessions && this.isProviderImportable(provider, options?.providerFilter), ); - const sessionLists = await Promise.all( + const providerResults = await Promise.all( providerEntries.map(async ([provider, client]) => { try { - return ( - await client.listImportableSessions!({ + const sessions = await withTimeout( + client.listImportableSessions!({ limit: options?.limit, + query: options?.query, + scanLimit: options?.scanLimit, cwd: options?.cwd, - }) - ).map((session) => Object.assign(session, { provider })); + }), + IMPORTABLE_SESSION_LIST_TIMEOUT_MS, + `Timed out listing importable sessions for provider '${provider}' after ${IMPORTABLE_SESSION_LIST_TIMEOUT_MS}ms`, + ); + return { + sessions: sessions + .filter((session) => matchesImportableSessionQuery(session, options?.query)) + .map((session) => Object.assign(session, { provider })), + error: null, + }; } catch (error) { this.logger.warn( { err: error, provider }, "Failed to list importable sessions for provider", ); - return []; + return { + sessions: [], + error: { + provider, + message: error instanceof Error ? error.message : String(error), + }, + }; } }), ); - const sessions: ManagedImportableProviderSession[] = sessionLists.flat(); + const sessions = providerResults.flatMap((result) => result.sessions); const limit = options?.limit ?? 20; - return sessions - .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()) - .slice(0, limit); + return { + sessions: sessions + .sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime()) + .slice(0, limit), + providerErrors: providerResults.flatMap((result) => (result.error ? [result.error] : [])), + }; } private isProviderImportable( @@ -4959,6 +4990,18 @@ export class AgentManager { } } +function matchesImportableSessionQuery( + session: ImportableProviderSession, + rawQuery: string | undefined, +): boolean { + const query = rawQuery?.trim().toLowerCase(); + if (!query) return true; + const cwdBasename = basename(session.cwd.replaceAll("\\", "/")); + return [session.title, session.firstPromptPreview, session.lastPromptPreview, cwdBasename].some( + (value) => value?.toLowerCase().includes(query), + ); +} + export function commandMayHaveChangedExternalState(command: string): boolean { const normalized = command.toLowerCase(); // Commands that operate on remote state and do NOT trigger local file diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 4ccc141f10..c916228020 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -544,6 +544,13 @@ export interface AgentSlashCommand { export interface ListImportableSessionsOptions { limit?: number; + /** Optional case-insensitive descriptor search text. */ + query?: string; + /** + * Maximum number of cheap persisted-session candidates to inspect before + * applying the result limit. Providers must cap this at 500. + */ + scanLimit?: number; /** * Optional cwd hint. Providers that can cheaply pre-filter importable * sessions by working directory should do so before doing expensive work. diff --git a/packages/server/src/server/agent/import-sessions.test.ts b/packages/server/src/server/agent/import-sessions.test.ts index 70f1693180..9190e6942b 100644 --- a/packages/server/src/server/agent/import-sessions.test.ts +++ b/packages/server/src/server/agent/import-sessions.test.ts @@ -67,6 +67,10 @@ function makeImportableSession(args: { }; } +function makeImportableSessionsResult(sessions: ManagedImportableProviderSession[]) { + return { sessions, providerErrors: [] }; +} + function makeManagedAgent(args: { id?: string; provider?: string; @@ -201,7 +205,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import firstPrompt: "live prompt", }), ]; - const listImportableSessions = vi.fn(async () => sessions); + const listImportableSessions = vi.fn(async () => makeImportableSessionsResult(sessions)); const agentManager = { listAgents: () => [ @@ -248,6 +252,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import }); expect(result).toEqual({ filteredAlreadyImportedCount: 2, + providerErrors: [], entries: [ { providerId: "codex", @@ -288,7 +293,7 @@ test("listImportableProviderSessions looks past already-imported rows to fill th lastActivityAt: "2026-04-30T12:01:00.000Z", }); const listImportableSessions = vi.fn(async (options?: { limit?: number }) => - [imported, available].slice(0, options?.limit), + makeImportableSessionsResult([imported, available].slice(0, options?.limit)), ); const result = await listImportableProviderSessions({ @@ -317,6 +322,63 @@ test("listImportableProviderSessions looks past already-imported rows to fill th expect(result.filteredAlreadyImportedCount).toBe(1); }); +test("listImportableProviderSessions requests a bounded deep scan for search results", async () => { + const matchingSessions = [ + makeImportableSession({ + provider: "claude", + sessionId: "title-match", + cwd: "/tmp/archive", + title: "Invoice cleanup", + lastActivityAt: "2026-04-01T04:00:00.000Z", + }), + makeImportableSession({ + provider: "codex", + sessionId: "first-prompt-match", + cwd: "/tmp/archive", + title: "Unrelated", + firstPrompt: "Investigate invoice totals", + lastActivityAt: "2026-04-01T03:00:00.000Z", + }), + makeImportableSession({ + provider: "pi", + sessionId: "last-prompt-match", + cwd: "/tmp/archive", + title: "Unrelated", + lastPrompt: "Finish invoice export", + lastActivityAt: "2026-04-01T02:00:00.000Z", + }), + makeImportableSession({ + provider: "omp", + sessionId: "cwd-match", + cwd: "/tmp/invoice-service", + title: "Unrelated", + lastActivityAt: "2026-04-01T01:00:00.000Z", + }), + ]; + const listImportableSessions = vi.fn(async () => makeImportableSessionsResult(matchingSessions)); + + const result = await listImportableProviderSessions({ + request: makeRequest({ query: "INVOICE", limit: 10 }), + agentManager: { listAgents: () => [], listImportableSessions }, + agentStorage: { list: async () => [] }, + providerSnapshotManager: { getProviderLabel: (provider) => provider }, + }); + + expect(listImportableSessions).toHaveBeenCalledWith({ + limit: 500, + query: "invoice", + scanLimit: 500, + providerFilter: undefined, + cwd: undefined, + }); + expect(result.entries.map((entry) => entry.providerHandleId)).toEqual([ + "title-match", + "first-prompt-match", + "last-prompt-match", + "cwd-match", + ]); +}); + test("listImportableProviderSessions includes a provider session after its Paseo agent is archived", async () => { const cwd = "/tmp/project"; const archivedSession = makeImportableSession({ @@ -332,7 +394,7 @@ test("listImportableProviderSessions includes a provider session after its Paseo request: makeRequest({ cwd, providers: ["claude"] }), agentManager: { listAgents: () => [], - listImportableSessions: async () => [archivedSession], + listImportableSessions: async () => makeImportableSessionsResult([archivedSession]), }, agentStorage: { list: async () => [ @@ -376,7 +438,7 @@ test("listImportableProviderSessions includes an archived provider session still sessionId: "archived-live-session", }), ], - listImportableSessions: async () => [archivedSession], + listImportableSessions: async () => makeImportableSessionsResult([archivedSession]), }, agentStorage: { list: async () => [ @@ -424,7 +486,7 @@ test("listImportableProviderSessions filters out metadata generation sessions", request: makeRequest({ cwd, providers: ["codex"] }), agentManager: { listAgents: () => [], - listImportableSessions: async () => sessions, + listImportableSessions: async () => makeImportableSessionsResult(sessions), } satisfies Pick, agentStorage: { list: async () => [], @@ -449,17 +511,18 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }), agentManager: { listAgents: () => [], - listImportableSessions: async () => [ - makeImportableSession({ - provider: "pi", - sessionId: "pi-session", - nativeHandle: "pi-handle", - cwd: persistedCwd, - title: "Pi session", - lastActivityAt: "2026-04-30T12:00:00.000Z", - firstPrompt: "remember this", - }), - ], + listImportableSessions: async () => + makeImportableSessionsResult([ + makeImportableSession({ + provider: "pi", + sessionId: "pi-session", + nativeHandle: "pi-handle", + cwd: persistedCwd, + title: "Pi session", + lastActivityAt: "2026-04-30T12:00:00.000Z", + firstPrompt: "remember this", + }), + ]), } satisfies Pick, agentStorage: { list: async () => [], @@ -476,7 +539,7 @@ test("listImportableProviderSessions rejects invalid since values", async () => request: makeRequest({ since: "not-a-date" }), agentManager: { listAgents: () => [], - listImportableSessions: async () => [], + listImportableSessions: async () => makeImportableSessionsResult([]), } satisfies Pick, agentStorage: { list: async () => [], diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index ed22fefbab..492667ab88 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -25,6 +25,7 @@ type ImportAgentRequestMessage = z.infer const METADATA_GENERATION_PROMPT_PREFIX = "Generate metadata for a coding agent based on the user prompt."; +const IMPORT_SESSION_SEARCH_SCAN_LIMIT = 500; export type ImportSessionAgentManager = AgentLoaderManager & Pick< AgentManager, @@ -70,6 +71,7 @@ export interface ListImportableProviderSessionsInput { export interface ListImportableProviderSessionsResult { entries: RecentProviderSessionDescriptorPayload[]; filteredAlreadyImportedCount: number; + providerErrors: Array<{ provider: string; message: string }>; } export interface ImportProviderSessionInput { @@ -127,16 +129,20 @@ export async function listImportableProviderSessions( providerFilter, ); const importedHandles = importedSessions.handles; + const query = normalizeImportSessionQuery(request.query); + const listingLimit = query ? IMPORT_SESSION_SEARCH_SCAN_LIMIT : limit + importedSessions.count; - const sessions = await agentManager.listImportableSessions({ - limit: limit + importedSessions.count, + const listing = await agentManager.listImportableSessions({ + limit: listingLimit, + ...(query ? { query } : {}), + ...(query ? { scanLimit: IMPORT_SESSION_SEARCH_SCAN_LIMIT } : {}), providerFilter, cwd: request.cwd, }); let filteredAlreadyImportedCount = 0; const candidates: ManagedImportableProviderSession[] = []; const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null; - for (const session of sessions) { + for (const session of listing.sessions) { if (matchesRequestCwd && !matchesRequestCwd(session.cwd)) { continue; } @@ -164,7 +170,16 @@ export async function listImportableProviderSessions( }), ); - return { entries, filteredAlreadyImportedCount }; + return { + entries, + filteredAlreadyImportedCount, + providerErrors: listing.providerErrors, + }; +} + +function normalizeImportSessionQuery(query: string | undefined): string | null { + const normalized = query?.trim().toLowerCase(); + return normalized ? normalized : null; } export async function importProviderSession( diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index ce8d6b2edd..fab367e90a 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1057,6 +1057,7 @@ export class ACPAgentClient implements AgentClient { } const sessions: ImportableProviderSession[] = []; + const scanLimit = Math.min(options?.scanLimit ?? options?.limit ?? 500, 500); let cursor: string | null | undefined; for (;;) { const page: ListSessionsResponse = await this.runACPRequest(() => @@ -1074,7 +1075,7 @@ export class ACPAgentClient implements AgentClient { } cursor = page.nextCursor ?? null; if (!cursor) break; - if (options?.limit && sessions.length >= options.limit) break; + if (sessions.length >= scanLimit) break; } return typeof options?.limit === "number" ? sessions.slice(0, options.limit) : sessions; diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 443518eb60..5b5a9deda6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1488,6 +1488,62 @@ describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { }); describe("ClaudeAgentClient.listImportableSessions", () => { + test("uses the latest native custom title and leaves fixture mtimes unchanged", async () => { + const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-")); + const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = tmpConfigDir; + + try { + const projectDir = path.join(tmpConfigDir, "projects", "native-title-fixture"); + await fs.mkdir(projectDir, { recursive: true }); + const sessionFile = path.join(projectDir, "native-title-session.jsonl"); + await fs.copyFile( + new URL("./test-fixtures/import-session-native-titles.jsonl", import.meta.url), + sessionFile, + ); + const olderSessionFile = path.join(projectDir, "older-native-title-session.jsonl"); + await fs.copyFile( + new URL("./test-fixtures/import-session-native-titles.jsonl", import.meta.url), + olderSessionFile, + ); + const timestamp = new Date("2026-08-13T01:53:11.000Z"); + await fs.utimes(sessionFile, timestamp, timestamp); + const olderTimestamp = new Date("2026-08-12T01:53:11.000Z"); + await fs.utimes(olderSessionFile, olderTimestamp, olderTimestamp); + const fixtureFiles = [sessionFile, olderSessionFile]; + const mtimesBefore = await Promise.all( + fixtureFiles.map(async (file) => (await fs.stat(file)).mtimeMs), + ); + + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude/bin", + }); + + await expect(client.listImportableSessions({ limit: 1 })).resolves.toEqual([ + { + providerHandleId: "native-title-session", + cwd: "/tmp/paseo-claude-native-title", + title: "My research session", + firstPromptPreview: "Review this project", + lastPromptPreview: "Focus on the import flow", + lastActivityAt: timestamp, + }, + ]); + const mtimesAfter = await Promise.all( + fixtureFiles.map(async (file) => (await fs.stat(file)).mtimeMs), + ); + expect(mtimesAfter).toEqual(mtimesBefore); + } finally { + if (previousConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = previousConfigDir; + } + await fs.rm(tmpConfigDir, { recursive: true, force: true }); + } + }); + test("scopes candidates to the requested cwd before applying the limit", async () => { const tmpConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-import-")); const previousConfigDir = process.env.CLAUDE_CONFIG_DIR; diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 0432ea91e8..b041c3b233 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1611,7 +1611,8 @@ export class ClaudeAgentClient implements AgentClient { return []; } const limit = options?.limit ?? 20; - const candidates = await collectRecentClaudeSessions(sessionsRoot, limit * 3, { + const scanLimit = Math.min(options?.scanLimit ?? limit * 3, 500); + const candidates = await collectRecentClaudeSessions(sessionsRoot, scanLimit, { rootIsProjectDir: Boolean(options?.cwd), }); const parsed = await Promise.all( @@ -6115,13 +6116,26 @@ async function collectRecentClaudeSessions( interface ClaudeSessionDescriptorAccumulator { sessionId: string | null; cwd: string | null; - title: string | null; + customTitle: string | null; + aiTitle: string | null; + firstPromptTitle: string | null; firstPromptPreview: string | null; lastPromptPreview: string | null; } -function isFinishedAccumulator(acc: ClaudeSessionDescriptorAccumulator): boolean { - return Boolean(acc.sessionId && acc.cwd && acc.title); +const CLAUDE_IMPORT_DESCRIPTOR_RECORD_PATTERN = /"type"\s*:\s*"(?:user|custom-title|ai-title)"/; +const CLAUDE_SESSION_ID_KEY_PATTERN = /"sessionId"\s*:/; +const CLAUDE_CWD_KEY_PATTERN = /"cwd"\s*:/; + +function shouldParseClaudeSessionDescriptorLine( + line: string, + acc: ClaudeSessionDescriptorAccumulator, +): boolean { + return ( + CLAUDE_IMPORT_DESCRIPTOR_RECORD_PATTERN.test(line) || + (!acc.sessionId && CLAUDE_SESSION_ID_KEY_PATTERN.test(line)) || + (!acc.cwd && CLAUDE_CWD_KEY_PATTERN.test(line)) + ); } function applyClaudeSessionEntryToAccumulator( @@ -6144,12 +6158,18 @@ function applyClaudeSessionEntryToAccumulator( if (!acc.cwd && typeof entry.cwd === "string") { acc.cwd = entry.cwd; } + if (entry.type === "custom-title" && typeof entry.customTitle === "string") { + acc.customTitle = normalizeClaudeSessionTitle(entry.customTitle); + return; + } + if (entry.type === "ai-title" && typeof entry.aiTitle === "string") { + acc.aiTitle = normalizeClaudeSessionTitle(entry.aiTitle); + return; + } if (entry.type === "user" && entry.message) { const text = extractClaudeUserText(entry.message); if (text) { - if (!acc.title) { - acc.title = text; - } + acc.firstPromptTitle ??= text; const preview = normalizeImportablePromptPreview(text); acc.firstPromptPreview ??= preview; acc.lastPromptPreview = preview; @@ -6172,14 +6192,15 @@ async function parseClaudeSessionDescriptor( const acc: ClaudeSessionDescriptorAccumulator = { sessionId: null, cwd: null, - title: null, + customTitle: null, + aiTitle: null, + firstPromptTitle: null, firstPromptPreview: null, lastPromptPreview: null, }; - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line) continue; + for (const line of content.split(/\r?\n/)) { + if (!line || !shouldParseClaudeSessionDescriptorLine(line, acc)) continue; let entry: unknown; try { entry = JSON.parse(line); @@ -6187,12 +6208,9 @@ async function parseClaudeSessionDescriptor( continue; } applyClaudeSessionEntryToAccumulator(entry, acc); - if (isFinishedAccumulator(acc)) { - break; - } } - const { sessionId, cwd, title } = acc; + const { sessionId, cwd } = acc; if (!sessionId || !cwd) { return null; @@ -6201,13 +6219,22 @@ async function parseClaudeSessionDescriptor( return { providerHandleId: sessionId, cwd, - title: (title ?? "").trim() || `Claude session ${sessionId.slice(0, 8)}`, + title: + acc.customTitle ?? + acc.aiTitle ?? + normalizeClaudeSessionTitle(acc.firstPromptTitle) ?? + `Claude session ${sessionId.slice(0, 8)}`, firstPromptPreview: acc.firstPromptPreview, lastPromptPreview: acc.lastPromptPreview, lastActivityAt: mtime, }; } +function normalizeClaudeSessionTitle(title: string | null): string | null { + const normalized = title?.trim(); + return normalized ? normalized : null; +} + function normalizeImportablePromptPreview(text: string): string | null { const normalized = text.trim().replace(/\s+/g, " "); if (!normalized) return null; diff --git a/packages/server/src/server/agent/providers/claude/test-fixtures/import-session-native-titles.jsonl b/packages/server/src/server/agent/providers/claude/test-fixtures/import-session-native-titles.jsonl new file mode 100644 index 0000000000..1a07c2800a --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/test-fixtures/import-session-native-titles.jsonl @@ -0,0 +1,6 @@ +{"type":"user","message":{"role":"user","content":"Review this project"},"cwd":"/tmp/paseo-claude-native-title","sessionId":"native-title-session"} +{"type":"ai-title","aiTitle":"Initial generated title","sessionId":"native-title-session"} +{"parentUuid":null,"message":{"role":"user","content":"Focus on the import flow"},"type" : "user","cwd":"/tmp/paseo-claude-native-title","sessionId":"native-title-session"} +{"type":"ai-title","aiTitle":"Latest generated title","sessionId":"native-title-session"} +{"type":"custom-title","customTitle":"First custom title","sessionId":"native-title-session"} +{"type":"custom-title","customTitle":"My research session","sessionId":"native-title-session"} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 972cec1fd1..48744f84ac 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -7006,10 +7006,11 @@ export class CodexAppServerAgentClient implements AgentClient { client.notify("initialized", {}); const limit = options?.limit ?? 20; + const scanLimit = Math.min(options?.scanLimit ?? limit, 500); // thread/list returns the cheap `cwd` field. Fetch a wider window when // filtering since most threads will be from other cwds, then keep the // local realpath-aware filter for symlink-equivalent workspace paths. - const listLimit = options?.cwd ? Math.max(limit, 50) : limit; + const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit; const response = toObjectRecord( await client.request("thread/list", { limit: listLimit, diff --git a/packages/server/src/server/agent/providers/omp/session-descriptor.ts b/packages/server/src/server/agent/providers/omp/session-descriptor.ts index 16e4ee7fdf..5b471cb1ff 100644 --- a/packages/server/src/server/agent/providers/omp/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/omp/session-descriptor.ts @@ -79,8 +79,12 @@ export async function listOmpImportableSessions( const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null; const limit = options.limit ?? 20; const ranked = await rankSessionFilesByMtime(files); - const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN); - const candidates = matchesCwd ? ranked : ranked.slice(0, candidateLimit); + const candidateLimit = Math.min( + options.scanLimit ?? Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN), + 500, + ); + const candidates = + options.scanLimit === undefined && matchesCwd ? ranked : ranked.slice(0, candidateLimit); const sessions: ImportableProviderSession[] = []; for (const entry of candidates) { diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 315f715001..ad22023ec0 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1053,7 +1053,11 @@ async function collectOpenCodeImportableSessionsFromSdk( options?: ListImportableSessionsOptions, ): Promise { const limit = options?.limit ?? OPENCODE_PERSISTED_SESSION_LIMIT; - const sessionListLimit = options?.cwd ? Math.max(limit, OPENCODE_PERSISTED_SESSION_LIMIT) : limit; + const scanLimit = Math.min(options?.scanLimit ?? limit, 500); + const sessionListLimit = Math.min( + options?.cwd ? Math.max(scanLimit, OPENCODE_PERSISTED_SESSION_LIMIT) : scanLimit, + 500, + ); const response = await client.experimental.session.list({ archived: true, roots: true, diff --git a/packages/server/src/server/agent/providers/pi/session-descriptor.ts b/packages/server/src/server/agent/providers/pi/session-descriptor.ts index 187174fb88..01f55bc0ce 100644 --- a/packages/server/src/server/agent/providers/pi/session-descriptor.ts +++ b/packages/server/src/server/agent/providers/pi/session-descriptor.ts @@ -77,8 +77,12 @@ export async function listPiImportableSessions( const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null; const limit = options.limit ?? 20; const ranked = await rankSessionFilesByMtime(files); - const candidateLimit = Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN); - const candidates = matchesCwd ? ranked : ranked.slice(0, candidateLimit); + const candidateLimit = Math.min( + options.scanLimit ?? Math.max(limit * IMPORT_CANDIDATE_OVERSCAN, IMPORT_CANDIDATE_MIN), + 500, + ); + const candidates = + options.scanLimit === undefined && matchesCwd ? ranked : ranked.slice(0, candidateLimit); const sessions: ImportableProviderSession[] = []; for (const entry of candidates) { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index ddcfc8faa5..0ab2c0eea9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5560,6 +5560,7 @@ export class Session { ...(result.filteredAlreadyImportedCount > 0 ? { filteredAlreadyImportedCount: result.filteredAlreadyImportedCount } : {}), + ...(result.providerErrors.length > 0 ? { providerErrors: result.providerErrors } : {}), }, }); } catch (error) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 9e107a0358..97166cdf76 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -119,7 +119,10 @@ interface SessionTestAccess { listAgents(): unknown[]; getAgent(agentId: string): unknown; reloadAgentSession(agentId: string, overrides?: unknown, options?: unknown): Promise; - listImportableSessions(options?: unknown): Promise; + listImportableSessions(options?: unknown): Promise<{ + sessions: unknown[]; + providerErrors: Array<{ provider: string; message: string }>; + }>; importProviderSession(input: unknown): Promise; resumeAgentFromPersistence( handle: unknown, @@ -3159,9 +3162,12 @@ test("fetch_recent_provider_sessions_request lists importable provider sessions session.agentManager.listImportableSessions = async (options?: unknown) => { const providerFilter = (options as { providerFilter?: Set } | undefined) ?.providerFilter; - return providerFilter - ? importableSessions.filter((entry) => providerFilter.has(entry.provider)) - : importableSessions; + return { + sessions: providerFilter + ? importableSessions.filter((entry) => providerFilter.has(entry.provider)) + : importableSessions, + providerErrors: [], + }; }; session.agentStorage.list = async () => [ { @@ -3233,7 +3239,10 @@ test("fetch_recent_provider_sessions_request forwards providerFilter to agent ma session.agentStorage.list = async () => []; session.agentManager.listImportableSessions = async (options?: unknown) => { capturedOptions = options as { providerFilter?: Set; limit?: number }; - return []; + return { + sessions: [], + providerErrors: [{ provider: "claude", message: "Claude listing failed" }], + }; }; await session.handleMessage({ @@ -3251,6 +3260,7 @@ test("fetch_recent_provider_sessions_request forwards providerFilter to agent ma payload: { requestId: "req-provider-filter", entries: [], + providerErrors: [{ provider: "claude", message: "Claude listing failed" }], }, }, ]); @@ -3272,17 +3282,20 @@ test("fetch_recent_provider_sessions_request reports filteredAlreadyImportedCoun }, ]; session.agentStorage.list = async () => []; - session.agentManager.listImportableSessions = async () => [ - { - provider: "codex", - providerHandleId: "live-handle", - cwd: "/tmp/recent", - title: "Already live", - firstPromptPreview: "live prompt", - lastPromptPreview: "live prompt", - lastActivityAt: new Date("2026-04-30T12:01:00.000Z"), - }, - ]; + session.agentManager.listImportableSessions = async () => ({ + sessions: [ + { + provider: "codex", + providerHandleId: "live-handle", + cwd: "/tmp/recent", + title: "Already live", + firstPromptPreview: "live prompt", + lastPromptPreview: "live prompt", + lastActivityAt: new Date("2026-04-30T12:01:00.000Z"), + }, + ], + providerErrors: [], + }); await session.handleMessage({ type: "fetch_recent_provider_sessions_request", diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index a97e82f85d..0dee0a08d0 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -599,6 +599,55 @@ test("runInImportWorkspace uses an active requested workspace without creating a expect(await workspaceRegistry.list()).toEqual([workspace]); }); +test("runInImportWorkspace reuses an active workspace for an untargeted import", async () => { + const cwd = path.join(tmpDir, "active-import"); + mkdirSync(cwd); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + + const result = await provisioning.runInImportWorkspace( + { cwd }, + async (target) => target.workspaceId, + ); + + expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null }); + expect(await workspaceRegistry.list()).toEqual([workspace]); +}); + +test("runInImportWorkspace unarchives a workspace for an untargeted import", async () => { + const cwd = path.join(tmpDir, "archived-import"); + mkdirSync(cwd); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + await workspaceRegistry.archive(workspace.workspaceId, ARCHIVED_AT); + + const result = await provisioning.runInImportWorkspace( + { cwd }, + async (target) => target.workspaceId, + ); + + expect(result).toEqual({ value: workspace.workspaceId, createdWorkspace: null }); + expect(await workspaceRegistry.get(workspace.workspaceId)).toMatchObject({ + workspaceId: workspace.workspaceId, + archivedAt: null, + }); + expect(await workspaceRegistry.list()).toHaveLength(1); +}); + +test("runInImportWorkspace leaves a reused workspace intact when import fails", async () => { + const cwd = path.join(tmpDir, "failed-active-import"); + mkdirSync(cwd); + const workspace = await provisioning.createWorkspaceForDirectory(cwd); + const project = await projectRegistry.get(workspace.projectId); + + await expect( + provisioning.runInImportWorkspace({ cwd }, async () => { + throw new Error("provider session is unavailable"); + }), + ).rejects.toThrow("provider session is unavailable"); + + expect(await workspaceRegistry.list()).toEqual([workspace]); + expect(await projectRegistry.get(workspace.projectId)).toEqual(project); +}); + test.each(["missing", "archived"] as const)( "runInImportWorkspace rejects a %s requested workspace before importing", async (state) => { diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 90b0c7c128..367974edc4 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -115,18 +115,28 @@ export function createWorkspaceProvisioningService(deps: { }; } - const projectsBeforeImport = await projectRegistry.list(); - const workspace = await createWorkspaceForDirectory(input.cwd); + const [projectsBeforeImport, workspacesBeforeImport] = await Promise.all([ + projectRegistry.list(), + workspaceRegistry.list(), + ]); + const workspace = await findOrCreateWorkspaceForDirectory(input.cwd); + const createdWorkspace = workspacesBeforeImport.some( + (candidate) => candidate.workspaceId === workspace.workspaceId, + ) + ? null + : workspace; const previousProject = projectsBeforeImport.find((project) => project.projectId === workspace.projectId) ?? null; try { return { value: await operation(workspace), - createdWorkspace: workspace, + createdWorkspace, }; } catch (error) { - await rollbackFailedImportWorkspace(workspace, previousProject); + if (createdWorkspace) { + await rollbackFailedImportWorkspace(createdWorkspace, previousProject); + } throw error; } } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 4b64a6fb93..d348792cd7 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1736,6 +1736,8 @@ export class VoiceAssistantWebSocketServer { providerRemoval: true, // COMPAT(importSessionWorkspaceTarget): added in v0.1.110, remove gate after 2027-01-16. importSessionWorkspaceTarget: true, + // COMPAT(importSessionSearch): added in v0.7.3, remove gate after 2027-03-02. + importSessionSearch: true, // COMPAT(forgeProviders): added in v0.2.0-beta.1. Drop the gate after // 2027-01-17 once the supported daemon floor is >= v0.2.0. forgeProviders: true,