From ac085d50c6c71f2708a305444802a09a14f4abb2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 2 Sep 2026 15:44:44 +0200 Subject: [PATCH] Make import session one tap away and make the sheet find your session (#4216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): make import session a first-class entry point * feat(server): improve import session discovery resilience Search up to 500 persisted candidates per provider and isolate listing failures behind eight-second deadlines so healthy results remain usable. Preserve native persisted title precedence while keeping discovery read-only. Parsing now continues through each transcript so native titles and the true last prompt are retained; the old early exit made lastPromptPreview equal firstPromptPreview. A cheap record prefilter avoids JSON.parse for unrelated lines during the full scan. * feat(app): searchable, grouped import session sheet with per-provider errors The sheet now carries a debounced search field gated on the host's importSessionSearch feature, grows its per-provider page on demand (15 -> 45 -> 90 -> 200) instead of capping at the 15 most recent, and groups unscoped rows by folder under the project name the app knows for that directory. Worktrees of one project would otherwise all head up as the same bare name, so a directory below its project root carries the path under that root beside the name. A workspace-scoped sheet says so and offers "Show all sessions". Importing a row from another directory once that is on drops the workspace from the request, which the daemon would otherwise reject, and routes the user to the agent's own workspace rather than opening a dead tab in the current one. That decision lives in resolveImportTarget: a scoped listing is trusted wholesale because the daemon already matched it realpath-aware, which the client cannot. A provider that fails now gets its own error row with a Retry that refetches only that provider, sourced from both a failed request and the daemon's providerErrors. React Query no longer retries these, so the header spinner settles once every provider has answered or given up (#2512). * test(e2e): import session flow with fixture Claude sessions * fix(app): shorten import sheet scope copy so it fits at phone width At 390px the scoped subtitle rendered "Sessions in this wor..." — the label, its action, and the sheet's refresh and close buttons do not share one line at phone width. The label already sits under the sheet title "Import session", so "Sessions in this" was repeating context the header had: it becomes "This workspace" and the action becomes "Show all". The unscoped label still names the host, which nothing else on the sheet does. * refactor(app): flat recency-ordered import list with per-row folder label Grouping by folder buried recency: a busy directory's block of fifteen pushed a two-minute-old session in a quiet directory below the fold, and the axis a user is scanning on is when they last touched the session, not where it lives. The unscoped sheet is now one list in the order aggregateSessionEntries already produces, and each row carries its folder as a muted third line. resolveDirectoryLabel stays — it is what keeps two worktrees of one project apart — and formatDirectoryLabel renders its two parts as the single ellipsised line a row has room for. A scoped sheet lists one directory, so its rows carry no folder line at all. * fix(app): close import sheet before refreshing sessions Provider-list invalidation waited for every active query before the sheet could close. Mark the disabled picker queries stale after close and navigation so reopening refreshes without delaying the imported agent. * fix(server): reuse workspaces for session imports Host-wide imports should join the oldest active workspace for their directory, or restore its archived workspace, instead of minting duplicates. Rollback remains limited to workspace records created by the failed import. * test(e2e): express the import session journey through a helper DSL * test(e2e): align sidebar journeys with import entry point --- .../e2e/browser/import-session-flow.spec.ts | 293 +++++++++++ .../browser/workspace-setup-streaming.spec.ts | 4 +- packages/app/e2e/support/fixtures.ts | 12 +- .../support/helpers/command-center-scroll.ts | 4 + .../app/e2e/support/helpers/e2e-worker.ts | 18 +- .../app/e2e/support/helpers/import-session.ts | 145 ++++++ .../support/helpers/sidebar-nav-settings.ts | 1 + .../app/e2e/support/helpers/startup-dsl.ts | 2 +- .../e2e/support/helpers/workspace-setup.ts | 6 +- .../src/command-center/root-registration.tsx | 43 +- .../import-session-sheet-view-model.test.ts | 183 ++++++- .../import-session-sheet-view-model.ts | 150 +++++- .../components/import-session-sheet.test.tsx | 485 +++++++++++++++++- .../src/components/import-session-sheet.tsx | 410 ++++++++++++--- packages/app/src/components/left-sidebar.tsx | 108 ++-- .../src/components/sidebar-workspace-list.tsx | 6 +- .../src/components/sidebar/empty-states.tsx | 13 +- packages/app/src/hooks/use-import-session.tsx | 94 ++++ packages/app/src/i18n/resources.test.ts | 5 +- packages/app/src/i18n/resources/ar.ts | 13 +- packages/app/src/i18n/resources/en.ts | 15 +- packages/app/src/i18n/resources/es.ts | 13 +- packages/app/src/i18n/resources/fr.ts | 13 +- packages/app/src/i18n/resources/ja.ts | 13 +- packages/app/src/i18n/resources/ko.ts | 13 +- packages/app/src/i18n/resources/pt-BR.ts | 13 +- packages/app/src/i18n/resources/ru.ts | 13 +- packages/app/src/i18n/resources/zh-CN.ts | 13 +- .../app/src/screens/open-project-screen.tsx | 46 +- packages/app/src/screens/sessions-screen.tsx | 8 +- .../screens/workspace/workspace-screen.tsx | 5 + packages/client/src/daemon-client.test.ts | 3 + packages/client/src/daemon-client.ts | 1 + packages/protocol/src/messages.test.ts | 15 + packages/protocol/src/messages.ts | 11 + .../protocol/src/messages.workspaces.test.ts | 25 + .../src/server/agent/agent-manager.test.ts | 122 ++++- .../server/src/server/agent/agent-manager.ts | 67 ++- .../src/server/agent/agent-sdk-types.ts | 7 + .../src/server/agent/import-sessions.test.ts | 97 +++- .../src/server/agent/import-sessions.ts | 23 +- .../src/server/agent/providers/acp-agent.ts | 3 +- .../agent/providers/claude/agent.test.ts | 56 ++ .../server/agent/providers/claude/agent.ts | 59 ++- .../import-session-native-titles.jsonl | 6 + .../agent/providers/codex-app-server-agent.ts | 3 +- .../agent/providers/omp/session-descriptor.ts | 8 +- .../server/agent/providers/opencode-agent.ts | 6 +- .../agent/providers/pi/session-descriptor.ts | 8 +- packages/server/src/server/session.ts | 1 + .../src/server/session.workspaces.test.ts | 45 +- .../workspace-provisioning-service.test.ts | 49 ++ .../workspace-provisioning-service.ts | 18 +- .../server/src/server/websocket-server.ts | 2 + 54 files changed, 2465 insertions(+), 330 deletions(-) create mode 100644 packages/app/e2e/browser/import-session-flow.spec.ts create mode 100644 packages/app/e2e/support/helpers/import-session.ts create mode 100644 packages/app/src/hooks/use-import-session.tsx create mode 100644 packages/server/src/server/agent/providers/claude/test-fixtures/import-session-native-titles.jsonl 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,