diff --git a/packages/app/e2e/archive-tab.spec.ts b/packages/app/e2e/archive-tab.spec.ts index 516914c09..6e785eb9b 100644 --- a/packages/app/e2e/archive-tab.spec.ts +++ b/packages/app/e2e/archive-tab.spec.ts @@ -1,18 +1,21 @@ import { randomUUID } from "node:crypto"; +import { expect } from "@playwright/test"; import { test } from "./fixtures"; +import { getServerId } from "./helpers/server-id"; import { connectSeedClient } from "./helpers/seed-client"; import { createTempGitRepo } from "./helpers/workspace"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { archiveAgentFromDaemon, archiveAgentFromSessions, clickSessionRow, - closeWorkspaceAgentTab, createIdleAgent, - expectArchivedAgentFocused, expectSessionRowArchived, expectSessionRowVisible, expectWorkspaceArchiveOutcome, expectWorkspaceTabHidden, + fetchAgentArchivedAt, + expectWorkspaceTabVisible, openSessions, openWorkspaceWithAgents, primeAdditionalPage, @@ -105,25 +108,31 @@ test.describe("Archive tab reconciliation", () => { } }); - test("clicking an archived session reopens its closed tab focused", async ({ page }) => { + test("clicking an archived session unarchives it and opens the agent", async ({ page }) => { const archived = await createIdleAgent(client, { cwd: tempRepo.path, - title: `reopen-archived-${randomUUID().slice(0, 8)}`, + title: `unarchive-archived-${randomUUID().slice(0, 8)}`, }); const surviving = await createIdleAgent(client, { cwd: tempRepo.path, - title: `reopen-control-${randomUUID().slice(0, 8)}`, + title: `unarchive-control-${randomUUID().slice(0, 8)}`, }); await resetSeededPageState(page); await openWorkspaceWithAgents(page, [archived, surviving]); - await closeWorkspaceAgentTab(page, archived.id); await archiveAgentFromDaemon(client, archived.id); await openSessions(page); await expectSessionRowArchived(page, archived.title); await clickSessionRow(page, archived.title); - await expectArchivedAgentFocused(page, archived.id); + await expect + .poll(() => fetchAgentArchivedAt(client, archived.id), { timeout: 30_000 }) + .toBeNull(); + + await expect(page).toHaveURL(buildHostWorkspaceRoute(getServerId(), archived.workspaceId), { + timeout: 30_000, + }); + await expectWorkspaceTabVisible(page, archived.id); }); }); diff --git a/packages/app/e2e/global-setup.ts b/packages/app/e2e/global-setup.ts index 23f9eb3e0..26dc99dad 100644 --- a/packages/app/e2e/global-setup.ts +++ b/packages/app/e2e/global-setup.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { spawn, type ChildProcess, execSync } from "node:child_process"; import { existsSync } from "node:fs"; -import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import net from "node:net"; @@ -154,6 +154,67 @@ async function stopProcess(child: ChildProcess | null): Promise { }); } +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +async function readSupervisorPidLock(home: string): Promise { + try { + const content = await readFile(path.join(home, "paseo.pid"), "utf8"); + const parsed = JSON.parse(content) as { pid?: unknown }; + return typeof parsed.pid === "number" ? parsed.pid : null; + } catch { + return null; + } +} + +async function stopProcessByPid(pid: number): Promise { + if (!isProcessRunning(pid)) { + return; + } + + try { + process.kill(pid, "SIGTERM"); + } catch { + return; + } + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + if (!isProcessRunning(pid)) { + return; + } + await sleep(100); + } + + if (isProcessRunning(pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + return; + } + } +} + +async function stopCurrentDaemonFromPidLock(): Promise { + if (!paseoHome) { + return; + } + if (process.env.E2E_DAEMON_PORT === "6767") { + throw new Error("Refusing to clean up daemon PID lock for developer daemon port 6767."); + } + + const pid = await readSupervisorPidLock(paseoHome); + if (pid === null) { + return; + } + await stopProcessByPid(pid); +} + function summarizeOpenAiErrorBody(body: string): string { const trimmed = body.trim(); if (!trimmed) { @@ -665,6 +726,7 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise { stopProcess(metroProcess), stopProcess(relayProcess), ]); + await stopCurrentDaemonFromPidLock(); daemonProcess = null; metroProcess = null; relayProcess = null; diff --git a/packages/app/e2e/helpers/archive-tab.ts b/packages/app/e2e/helpers/archive-tab.ts index 68ffac946..f7553d7e9 100644 --- a/packages/app/e2e/helpers/archive-tab.ts +++ b/packages/app/e2e/helpers/archive-tab.ts @@ -93,6 +93,22 @@ export async function archiveAgentFromDaemon( await client.archiveAgent(agentId); } +export async function fetchAgentArchivedAt( + client: { + fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>; + }, + agentId: string, +): Promise { + const result = await client.fetchAgent(agentId); + return result?.agent.archivedAt ?? null; +} + +export function getWorktreeRestoreFeature(client: { + getLastServerInfoMessage(): { features?: { worktreeRestore?: boolean } | null } | null; +}): boolean { + return client.getLastServerInfoMessage()?.features?.worktreeRestore === true; +} + export async function primeAdditionalPage(page: Page): Promise { const seedNonce = randomUUID(); const { daemon, preferences } = buildSeededStoragePayload(); @@ -214,7 +230,7 @@ export async function openSessions(page: Page): Promise { await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), { timeout: 30_000, }); - await expect(page.getByText("Agent history", { exact: true }).last()).toBeVisible({ + await expect(page.getByText("History", { exact: true }).last()).toBeVisible({ timeout: 30_000, }); } @@ -233,6 +249,12 @@ export async function expectSessionRowArchived(page: Page, title: string): Promi await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 }); } +export async function expectSessionRowNotArchived(page: Page, title: string): Promise { + await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", { + timeout: 30_000, + }); +} + export async function clickSessionRow(page: Page, title: string): Promise { const row = getSessionRowByTitle(page, title); await expect(row).toBeVisible({ timeout: 30_000 }); diff --git a/packages/app/e2e/helpers/daemon-restart.ts b/packages/app/e2e/helpers/daemon-restart.ts new file mode 100644 index 000000000..3f5920254 --- /dev/null +++ b/packages/app/e2e/helpers/daemon-restart.ts @@ -0,0 +1,157 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { createRequire } from "node:module"; +import { readFile } from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; + +import { getE2EDaemonPort } from "./daemon-port"; + +/** + * Restarts the isolated E2E daemon against the SAME PASEO_HOME and SAME port so + * persisted state reloads and existing clients can reconnect. This exercises the + * post-restart rehydration path (the daemon rebuilding workspace/agent links + * from disk), which is where the worktree-branch regression lives. + * + * The daemon is owned by Playwright's `globalSetup`, which keeps its child + * handle in module scope we can't reach from a spec. Instead we drive it the + * same way an operator would: read the supervisor PID from + * `$PASEO_HOME/paseo.pid`, SIGTERM it (the supervisor forwards the signal to its + * worker and releases the lock), wait for the port to free, then re-spawn the + * supervisor with the identical environment globalSetup used. The relay and + * Metro processes are untouched, so we reuse their already-published ports. + * + * This NEVER targets the developer daemon: the port comes from + * `getE2EDaemonPort()`, which refuses 6767, and PASEO_HOME is the isolated E2E + * home globalSetup created. + */ + +function getEnvOrThrow(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is not set (expected from Playwright globalSetup).`); + } + return value; +} + +async function readSupervisorPid(paseoHome: string): Promise { + const pidPath = path.join(paseoHome, "paseo.pid"); + const content = await readFile(pidPath, "utf8"); + const parsed = JSON.parse(content) as { pid?: unknown }; + if (typeof parsed.pid !== "number") { + throw new Error(`Malformed PID lock at ${pidPath}: ${content}`); + } + return parsed.pid; +} + +function isPidRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function isPortListening(port: number, host = "127.0.0.1"): Promise { + return new Promise((resolve) => { + const socket = net.connect(port, host, () => { + socket.end(); + resolve(true); + }); + socket.setTimeout(1000, () => { + socket.destroy(); + resolve(false); + }); + socket.on("error", () => resolve(false)); + }); +} + +async function waitUntil( + predicate: () => Promise | boolean, + options: { timeoutMs: number; label: string }, +): Promise { + const deadline = Date.now() + options.timeoutMs; + while (Date.now() < deadline) { + if (await predicate()) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}.`); +} + +function spawnSupervisor(args: { + paseoHome: string; + port: string; + relayPort: string; + metroPort: string; + editorRecordPath: string; +}): ChildProcess { + const serverDir = path.resolve(__dirname, "../../../..", "packages/server"); + // Run the supervisor through the resolved tsx CLI under the current node + // binary. Spawning the `node_modules/.bin/tsx` shim directly is unreliable + // inside the Playwright worker (the shim is a .mjs symlink, not an executable), + // so resolve the CLI module and load it with node. + const tsxCli = createRequire(path.join(serverDir, "package.json")).resolve("tsx/cli"); + + const child = spawn(process.execPath, [tsxCli, "scripts/supervisor-entrypoint.ts", "--dev"], { + cwd: serverDir, + env: { + ...process.env, + PASEO_HOME: args.paseoHome, + PASEO_E2E_EDITOR_RECORD_PATH: args.editorRecordPath, + PASEO_SERVER_ID: "srv_e2e_test_daemon", + PASEO_LISTEN: `0.0.0.0:${args.port}`, + PASEO_RELAY_ENDPOINT: `127.0.0.1:${args.relayPort}`, + PASEO_CORS_ORIGINS: `http://localhost:${args.metroPort}`, + PASEO_NODE_ENV: "development", + NODE_ENV: "development", + }, + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }); + + child.stdout?.on("data", (data: Buffer) => { + for (const line of data.toString().split("\n")) { + if (line.trim()) console.log(`[daemon:restart] ${line.trim()}`); + } + }); + child.stderr?.on("data", (data: Buffer) => { + for (const line of data.toString().split("\n")) { + if (line.trim()) console.error(`[daemon:restart] ${line.trim()}`); + } + }); + + // Detach our handles so the spawned supervisor outlives this spec process and + // is reaped by globalSetup's cleanup (the original process tree), not us. + child.unref(); + return child; +} + +export async function restartTestDaemon(): Promise { + const port = getE2EDaemonPort(); + const paseoHome = getEnvOrThrow("E2E_PASEO_HOME"); + const relayPort = getEnvOrThrow("E2E_RELAY_PORT"); + const metroPort = getEnvOrThrow("E2E_METRO_PORT"); + const editorRecordPath = + process.env.E2E_EDITOR_RECORD_PATH ?? path.join(paseoHome, "editor-open-records.jsonl"); + + const pid = await readSupervisorPid(paseoHome); + process.kill(pid, "SIGTERM"); + + await waitUntil(() => !isPidRunning(pid), { + timeoutMs: 15_000, + label: `supervisor PID ${pid} to exit`, + }); + await waitUntil(async () => !(await isPortListening(Number(port))), { + timeoutMs: 15_000, + label: `port ${port} to free`, + }); + + spawnSupervisor({ paseoHome, port, relayPort, metroPort, editorRecordPath }); + + await waitUntil(async () => isPortListening(Number(port)), { + timeoutMs: 30_000, + label: `restarted daemon to listen on port ${port}`, + }); +} diff --git a/packages/app/e2e/helpers/new-workspace.ts b/packages/app/e2e/helpers/new-workspace.ts index e7cc378f8..59ef0c4f5 100644 --- a/packages/app/e2e/helpers/new-workspace.ts +++ b/packages/app/e2e/helpers/new-workspace.ts @@ -103,8 +103,12 @@ export async function openProjectViaDaemon( export async function archiveWorkspaceFromDaemon( client: NewWorkspaceDaemonClient, workspaceDirectory: string, + options?: { scope?: "workspace" | "worktree" }, ): Promise { - const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory }); + const payload = await client.archivePaseoWorktree({ + worktreePath: workspaceDirectory, + ...(options?.scope !== undefined ? { scope: options.scope } : {}), + }); if (payload.error) { throw new Error(payload.error.message); } diff --git a/packages/app/e2e/helpers/seed-client.ts b/packages/app/e2e/helpers/seed-client.ts index 6f860cb42..d8f8dbd4d 100644 --- a/packages/app/e2e/helpers/seed-client.ts +++ b/packages/app/e2e/helpers/seed-client.ts @@ -121,6 +121,12 @@ export interface SeedDaemonClient { timeout?: number, ): Promise<{ status: string; final?: { lastError?: string | null } | null }>; archiveAgent(agentId: string): Promise<{ archivedAt: string }>; + fetchAgent( + agentId: string, + ): Promise<{ agent: { id: string; archivedAt?: string | null } } | null>; + getLastServerInfoMessage(): { + features?: { worktreeRestore?: boolean } | null; + } | null; fetchAgentHistory(options?: { page?: { limit: number }; }): Promise<{ entries: Array<{ id: string }> }>; diff --git a/packages/app/e2e/worktree-restore-after-restart.spec.ts b/packages/app/e2e/worktree-restore-after-restart.spec.ts new file mode 100644 index 000000000..58be83b47 --- /dev/null +++ b/packages/app/e2e/worktree-restore-after-restart.spec.ts @@ -0,0 +1,95 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { createIdleAgent, expectSessionRowArchived, openSessions } from "./helpers/archive-tab"; +import { restartTestDaemon } from "./helpers/daemon-restart"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { connectSeedClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForSidebarHydration } from "./helpers/workspace-ui"; + +test.describe("Worktree restore after daemon restart", () => { + let client: Awaited>; + let worktreeClient: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + const createdWorktreeDirectories = new Set(); + + test.describe.configure({ retries: 0, timeout: 180_000 }); + + test.beforeEach(async () => { + client = await connectSeedClient(); + worktreeClient = await connectNewWorkspaceDaemonClient(); + tempRepo = await createTempGitRepo("wt-restart-"); + }); + + test.afterEach(async () => { + for (const directory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined); + } + createdWorktreeDirectories.clear(); + await client?.close().catch(() => undefined); + await worktreeClient?.close().catch(() => undefined); + await tempRepo?.cleanup().catch(() => undefined); + }); + + test("after archiving a worktree and restarting the daemon, History shows the worktree branch (not main) before any restore", async ({ + page, + }) => { + const serverId = getServerId(); + + // A paseo worktree is cut on its own branch named after the slug, and the + // worktree workspace is displayed under the same name. These are the values + // the History table cells must show after restore — never "main". + const worktreeSlug = `restart-restore-${randomUUID().slice(0, 8)}`; + + await openProjectViaDaemon(worktreeClient, tempRepo.path); + const worktree = await createWorktreeViaDaemon(worktreeClient, { + cwd: tempRepo.path, + slug: worktreeSlug, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + + const agent = await createIdleAgent(client, { + cwd: worktree.workspaceDirectory, + title: `restart-restore-${randomUUID().slice(0, 8)}`, + }); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + // Archive through the default production path (no scope): the worktree dir is deleted. + await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + + // Bounce the isolated test daemon on the SAME home and port so it rebuilds + // all workspace/agent links from persisted state. Then reconnect both clients. + await client.close().catch(() => undefined); + await worktreeClient.close().catch(() => undefined); + await restartTestDaemon(); + client = await connectSeedClient(); + worktreeClient = await connectNewWorkspaceDaemonClient(); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await expectSessionRowArchived(page, agent.title); + + // KEY ASSERTION: reproduce the screenshot state. Right after the daemon + // restart, with NO restore and NO row click, the rendered History table cells + // (fed by each agent row's projectPlacement via fetch_agent_history) must read + // the worktree branch and the worktree workspace name — never "main". + const branchCell = page.getByTestId(`agent-row-branch-${serverId}-${agent.id}`); + const workspaceCell = page.getByTestId(`agent-row-workspace-${serverId}-${agent.id}`); + + await expect(branchCell).toBeVisible({ timeout: 60_000 }); + await expect(branchCell).toHaveText(worktreeSlug, { timeout: 60_000 }); + await expect(workspaceCell).toHaveText(worktree.workspaceName, { timeout: 60_000 }); + }); +}); diff --git a/packages/app/e2e/worktree-restore.spec.ts b/packages/app/e2e/worktree-restore.spec.ts new file mode 100644 index 000000000..1dbaf709d --- /dev/null +++ b/packages/app/e2e/worktree-restore.spec.ts @@ -0,0 +1,127 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { expect, test } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { + archiveAgentFromDaemon, + createIdleAgent, + expectSessionRowArchived, + fetchAgentArchivedAt, + openSessions, +} from "./helpers/archive-tab"; +import { + archiveWorkspaceFromDaemon, + connectNewWorkspaceDaemonClient, + createWorktreeViaDaemon, + openProjectViaDaemon, +} from "./helpers/new-workspace"; +import { connectSeedClient } from "./helpers/seed-client"; +import { getServerId } from "./helpers/server-id"; +import { createTempGitRepo } from "./helpers/workspace"; +import { waitForSidebarHydration, waitForWorkspaceInSidebar } from "./helpers/workspace-ui"; + +test.describe("Worktree restore", () => { + let client: Awaited>; + let worktreeClient: Awaited>; + let tempRepo: { path: string; cleanup: () => Promise }; + const createdWorktreeDirectories = new Set(); + + test.describe.configure({ timeout: 120_000 }); + + test.beforeEach(async () => { + client = await connectSeedClient(); + worktreeClient = await connectNewWorkspaceDaemonClient(); + tempRepo = await createTempGitRepo("wt-restore-"); + }); + + test.afterEach(async () => { + for (const directory of createdWorktreeDirectories) { + await archiveWorkspaceFromDaemon(worktreeClient, directory).catch(() => undefined); + } + createdWorktreeDirectories.clear(); + await client?.close().catch(() => undefined); + await worktreeClient?.close().catch(() => undefined); + await tempRepo?.cleanup().catch(() => undefined); + }); + + test("archiving an agent, then clicking it in History unarchives it in place (worktree dir untouched)", async ({ + page, + }) => { + const serverId = getServerId(); + await openProjectViaDaemon(worktreeClient, tempRepo.path); + const worktree = await createWorktreeViaDaemon(worktreeClient, { + cwd: tempRepo.path, + slug: `restore-inplace-${randomUUID().slice(0, 8)}`, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + + const agent = await createIdleAgent(client, { + cwd: worktree.workspaceDirectory, + title: `restore-inplace-${randomUUID().slice(0, 8)}`, + }); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + await archiveAgentFromDaemon(client, agent.id); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await expectSessionRowArchived(page, agent.title); + + await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); + + await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + // The History list is a cached react-query snapshot, so the cleared Archived + // badge only renders after a cold refetch. Reload to remount the query fresh. + await page.reload(); + await waitForSidebarHydration(page); + await openSessions(page); + const row = page + .locator('[data-testid^="agent-row-"]') + .filter({ hasText: agent.title }) + .first(); + await expect(row).toBeVisible({ timeout: 30_000 }); + await expect(row).not.toContainText("Archived", { timeout: 30_000 }); + }); + + test("archiving a worktree (dir deleted), then clicking its agent in History recreates the worktree", async ({ + page, + }) => { + const serverId = getServerId(); + await openProjectViaDaemon(worktreeClient, tempRepo.path); + const worktree = await createWorktreeViaDaemon(worktreeClient, { + cwd: tempRepo.path, + slug: `restore-recreate-${randomUUID().slice(0, 8)}`, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + + const agent = await createIdleAgent(client, { + cwd: worktree.workspaceDirectory, + title: `restore-recreate-${randomUUID().slice(0, 8)}`, + }); + expect(existsSync(worktree.workspaceDirectory)).toBe(true); + + // Archive through the default production path the sidebar uses (no explicit + // scope). With the restore prune fix, this default path frees the kept branch + // so the daemon can re-check-out the worktree on restore. + await archiveWorkspaceFromDaemon(worktreeClient, worktree.workspaceDirectory); + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(false); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openSessions(page); + await expectSessionRowArchived(page, agent.title); + + await page.getByTestId(`agent-row-${serverId}-${agent.id}`).click(); + + await expect + .poll(() => existsSync(worktree.workspaceDirectory), { timeout: 30_000 }) + .toBe(true); + await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId }); + await expect.poll(() => fetchAgentArchivedAt(client, agent.id), { timeout: 30_000 }).toBeNull(); + }); +}); diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 3f521758e..4e0501dec 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -15,14 +15,14 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { useIsCompactFormFactor } from "@/constants/layout"; import { formatTimeAgo } from "@/utils/time"; -import { shortenPath } from "@/utils/shorten-path"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { useSessionStore } from "@/stores/session-store"; import { Archive } from "lucide-react-native"; import { getProviderIcon } from "@/components/provider-icons"; import { navigateToAgent } from "@/utils/navigate-to-agent"; -import type { Agent } from "@/stores/session-store"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useQueryClient } from "@tanstack/react-query"; +import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key"; interface AgentListProps { agents: AggregatedAgent[]; @@ -49,65 +49,6 @@ type FlatListItem = | { type: "header"; key: string; section: DateSectionKey } | { type: "agent"; key: string; agent: AggregatedAgent }; -function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent { - return { - serverId: agent.serverId, - id: agent.id, - provider: agent.provider, - status: agent.status, - createdAt: agent.createdAt, - updatedAt: agent.lastActivityAt, - lastUserMessageAt: null, - lastActivityAt: agent.lastActivityAt, - capabilities: { - supportsStreaming: false, - supportsSessionPersistence: false, - supportsDynamicModes: false, - supportsMcpServers: false, - supportsReasoningStream: false, - supportsToolInvocations: false, - }, - currentModeId: null, - availableModes: [], - pendingPermissions: [], - persistence: null, - runtimeInfo: { - provider: agent.provider, - sessionId: null, - }, - title: agent.title, - cwd: agent.cwd, - workspaceId: agent.workspaceId, - model: null, - thinkingOptionId: null, - requiresAttention: agent.requiresAttention, - attentionReason: agent.attentionReason, - attentionTimestamp: agent.attentionTimestamp, - archivedAt: agent.archivedAt, - labels: agent.labels, - parentAgentId: null, - }; -} - -function rememberArchivedAgentDetail(agent: AggregatedAgent) { - if (!agent.archivedAt) { - return; - } - - useSessionStore.getState().setAgentDetails(agent.serverId, (previous) => { - const existing = previous.get(agent.id); - const next = new Map(previous); - next.set(agent.id, { - ...buildHistoricalAgentDetail(agent), - ...existing, - archivedAt: existing?.archivedAt ?? agent.archivedAt, - cwd: existing?.cwd ?? agent.cwd, - workspaceId: existing?.workspaceId ?? agent.workspaceId, - }); - return next; - }); -} - function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); @@ -151,23 +92,6 @@ function formatDateSectionLabel(t: TFunction, section: DateSectionKey): string { } } -function formatStatusLabel(t: TFunction, status: AggregatedAgent["status"]): string { - switch (status) { - case "initializing": - return t("agentList.status.initializing"); - case "idle": - return t("agentList.status.idle"); - case "running": - return t("agentList.status.running"); - case "error": - return t("agentList.status.error"); - case "closed": - return t("agentList.status.closed"); - default: - return status; - } -} - function SessionBadge({ label, icon, @@ -221,8 +145,9 @@ function SessionRow({ const timeAgo = formatTimeAgo(agent.lastActivityAt); const agentKey = `${agent.serverId}:${agent.id}`; const isSelected = selectedAgentId === agentKey; - const statusLabel = formatStatusLabel(t, agent.status); - const projectPath = shortenPath(agent.cwd); + const projectName = agent.projectPlacement?.projectName ?? ""; + const branch = agent.projectPlacement?.checkout.currentBranch ?? ""; + const workspaceName = agent.projectPlacement?.workspaceName ?? ""; const ProviderIcon = getProviderIcon(agent.provider); const pendingPermissionCount = agent.pendingPermissionCount ?? 0; @@ -279,32 +204,61 @@ function SessionRow({ {isMobile && ( - - {projectPath} + + {projectName} · - {statusLabel} + + {branch} + + · + + {workspaceName} + · {timeAgo} - {agent.serverLabel ? ( - <> - · - - {agent.serverLabel} - - - ) : null} )} {!isMobile && ( - <> - - {projectPath} + + + {projectName} - {statusLabel} - {timeAgo} - + + {branch} + + + {workspaceName} + + + {timeAgo} + + )} {isMobile && showAttentionIndicator && agent.requiresAttention ? ( @@ -330,6 +284,7 @@ export function AgentList({ const [actionAgent, setActionAgent] = useState(null); const isMobile = useIsCompactFormFactor(); const { archiveAgent } = useArchiveAgent(); + const queryClient = useQueryClient(); const actionClient = useSessionStore((state) => actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, @@ -346,17 +301,35 @@ export function AgentList({ const serverId = agent.serverId; const agentId = agent.id; + const openAgent = () => { + onAgentSelect?.(); + navigateToAgent({ + serverId, + agentId, + workspaceId: agent.workspaceId, + pin: false, + }); + }; - onAgentSelect?.(); + if (agent.archivedAt) { + const client = useSessionStore.getState().sessions[serverId]?.client ?? null; + if (client) { + void client + .refreshAgent(agentId) + .then(() => { + openAgent(); + return queryClient.invalidateQueries({ + queryKey: agentHistoryQueryKey(serverId), + }); + }) + .catch(() => {}); + } + return; + } - rememberArchivedAgentDetail(agent); - navigateToAgent({ - serverId, - agentId, - pin: Boolean(agent.archivedAt), - }); + openAgent(); }, - [isActionSheetVisible, onAgentSelect], + [isActionSheetVisible, onAgentSelect, queryClient], ); const handleAgentLongPress = useCallback( @@ -561,12 +534,14 @@ const styles = StyleSheet.create((theme) => ({ rowContent: { flex: 1, minWidth: 0, + overflow: "hidden", }, rowTitleRow: { flexDirection: "row", alignItems: "center", - flexWrap: "wrap", + flexWrap: "nowrap", gap: theme.spacing[2], + overflow: "hidden", }, providerIconWrap: { width: theme.iconSize.md, @@ -594,6 +569,7 @@ const styles = StyleSheet.create((theme) => ({ }, sessionTitle: { flexShrink: 1, + minWidth: 0, fontSize: theme.fontSize.sm, fontWeight: "400", color: theme.colors.foreground, @@ -612,13 +588,17 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, opacity: 0.7, }, + rowColumns: { + flexDirection: "row", + alignItems: "center", + flexShrink: 0, + gap: theme.spacing[3], + }, columnMeta: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, - flexShrink: 1, - minWidth: 60, - maxWidth: 200, - marginLeft: theme.spacing[4], + flexShrink: 0, + width: 132, }, columnMetaFixed: { fontSize: theme.fontSize.sm, @@ -630,6 +610,7 @@ const styles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", alignItems: "center", + flexShrink: 0, gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], diff --git a/packages/app/src/hooks/use-agent-history.ts b/packages/app/src/hooks/use-agent-history.ts index 2fa731fb6..fa3fc4d5e 100644 --- a/packages/app/src/hooks/use-agent-history.ts +++ b/packages/app/src/hooks/use-agent-history.ts @@ -69,6 +69,7 @@ export async function fetchAgentHistoryPage(input: { archivedAt: agent.archivedAt ?? null, createdAt: agent.createdAt, labels: agent.labels, + projectPlacement: agent.projectPlacement, })), pageInfo: payload.pageInfo, }; diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 28065fb46..5d013db5e 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -87,6 +87,7 @@ export function useAggregatedAgents(options?: { archivedAt: agent.archivedAt, createdAt: agent.createdAt, labels: agent.labels, + projectPlacement: agent.projectPlacement, }; const cacheKey = `${serverId}:${agent.id}`; const prev = prevAgentsRef.current.get(cacheKey); diff --git a/packages/app/src/i18n/resources.test.ts b/packages/app/src/i18n/resources.test.ts index 4d7c987c6..04e194dfb 100644 --- a/packages/app/src/i18n/resources.test.ts +++ b/packages/app/src/i18n/resources.test.ts @@ -291,7 +291,7 @@ describe("translation resources", () => { }); it("includes sessions and agent list keys for the Batch 4H migration", () => { - expect(en.sessions.title).toBe("Agent history"); + expect(en.sessions.title).toBe("History"); expect(en.sessions.empty).toBe("No sessions yet"); expect(en.sessions.actions.loadMore).toBe("Load more"); expect(en.agentList.fallbackTitle).toBe("New session"); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index f23c59b04..ac47466a6 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -206,7 +206,7 @@ export const ar: TranslationResources = { }, }, sessions: { - title: "سجل الوكلاء", + title: "السجل", empty: "لا توجد جلسات بعد", actions: { loadMore: "تحميل المزيد", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index fa8946b91..7812d65b9 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -205,7 +205,7 @@ export const en = { }, }, sessions: { - title: "Agent history", + title: "History", empty: "No sessions yet", actions: { loadMore: "Load more", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2a17dd216..53a1094f1 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -209,7 +209,7 @@ export const es: TranslationResources = { }, }, sessions: { - title: "Historial de agentes", + title: "Historial", empty: "Aún no hay sesiones", actions: { loadMore: "Cargar más", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index fdbf7880d..a794a8360 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -209,7 +209,7 @@ export const fr: TranslationResources = { }, }, sessions: { - title: "Historique des agents", + title: "Historique", empty: "Aucune séance pour l'instant", actions: { loadMore: "Charger plus", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index f8be82659..fc6c837a4 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -208,7 +208,7 @@ export const ru: TranslationResources = { }, }, sessions: { - title: "История агентов", + title: "История", empty: "Сеансов пока нет", actions: { loadMore: "Загрузить больше", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index de1b40d75..bf28983e8 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -206,7 +206,7 @@ export const zhCN: TranslationResources = { }, }, sessions: { - title: "Agent 历史", + title: "历史", empty: "还没有会话", actions: { loadMore: "加载更多", diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index ab4c94145..19beea5ec 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -16,6 +16,7 @@ export type AgentDirectoryEntry = Pick< | "archivedAt" | "createdAt" | "labels" + | "projectPlacement" > & { pendingPermissionCount?: number; }; diff --git a/packages/app/src/utils/project-placement.ts b/packages/app/src/utils/project-placement.ts index a9ad08361..741def978 100644 --- a/packages/app/src/utils/project-placement.ts +++ b/packages/app/src/utils/project-placement.ts @@ -13,6 +13,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl return { projectKey, projectName: deriveProjectName(projectKey), + workspaceName: null, checkout: { cwd: normalizedCwd, isGit: false, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ef21d804a..652d572bb 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2430,6 +2430,7 @@ export const ProjectCheckoutLitePayloadSchema = z.union([ export const ProjectPlacementPayloadSchema = z.object({ projectKey: z.string(), projectName: z.string(), + workspaceName: z.string().nullable().optional(), checkout: ProjectCheckoutLitePayloadSchema, }); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index e7953d8a1..28e4661f5 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -561,6 +561,76 @@ test.skipIf(isPlatform("win32"))( }, ); +// The default archive path (scope "workspace", worktreePath only) resolves +// repoRoot=null, so deletePaseoWorktree's `git worktree remove`/`prune` is +// skipped: the directory is rm-ed but the admin registration survives, pinning +// the branch as "already checked out". Restore must self-heal by pruning the +// stale registration before recreating, regardless of how it was archived. +test.skipIf(isPlatform("win32"))( + "recreates a worktree whose dir was rm-ed without git worktree remove (stale registration)", + async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + + execFileSync("git", ["branch", "restore-me"], { cwd: repoDir, stdio: "pipe" }); + + const created = await createWorktree({ + cwd: repoDir, + worktreeSlug: "restore-me", + source: { kind: "checkout-branch", branchName: "restore-me" }, + runSetup: false, + paseoHome, + }); + expect(existsSync(created.worktreePath)).toBe(true); + + // Simulate the default-archive teardown: remove the working directory but + // leave the git worktree registration intact. + rmSync(created.worktreePath, { recursive: true, force: true }); + expect(existsSync(created.worktreePath)).toBe(false); + + const worktreeList = execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + stdio: "pipe", + }).toString(); + expect(worktreeList).toContain(created.worktreePath); + + // Recreating without pruning fails with the stale registration pinning the + // branch — this is the case restore must heal. + await expect( + createWorktree({ + cwd: repoDir, + worktreeSlug: "restore-me", + source: { kind: "checkout-branch", branchName: "restore-me" }, + runSetup: false, + paseoHome, + }), + ).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" }); + + // The restore-side prune frees the stale registration; recreate then succeeds. + execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" }); + + const recreated = await createWorktree({ + cwd: repoDir, + worktreeSlug: "restore-me", + source: { kind: "checkout-branch", branchName: "restore-me" }, + runSetup: false, + paseoHome, + }); + + expect(recreated.worktreePath).toBe(created.worktreePath); + expect(existsSync(recreated.worktreePath)).toBe(true); + expect( + execFileSync("git", ["branch", "--show-current"], { + cwd: recreated.worktreePath, + stdio: "pipe", + }) + .toString() + .trim(), + ).toBe("restore-me"); + }, +); + test.skipIf(isPlatform("win32"))( "rejects with UnknownBranchError when the kept branch no longer exists", async () => { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3cfc49e9d..ff316a4a2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -277,6 +277,7 @@ import { toWorktreeWireError, } from "./worktree-errors.js"; import { type WorktreeConfig, createWorktree } from "../utils/worktree.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; @@ -1689,6 +1690,7 @@ export class Session { return { projectKey: project.projectId, projectName: resolveProjectDisplayName(project), + workspaceName: resolveWorkspaceDisplayName(workspace), checkout, }; } @@ -7118,6 +7120,18 @@ export class Session { }); } + // Archiving through the default path (scope "workspace", worktreePath only) + // resolves repoRoot=null, so deletePaseoWorktree's `git worktree remove`/ + // `prune` is skipped and the admin registration survives — pinning the + // branch as "already checked out". Prune here frees any stale registration + // whose working dir is missing (a no-op for live worktrees) so the recreate + // below succeeds regardless of how the worktree was archived. + try { + await runGitCommand(["worktree", "prune"], { cwd: project.rootPath, timeout: 30_000 }); + } catch { + // not critical; git will prune lazily + } + let result: WorktreeConfig; try { result = await createWorktree({ diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 71da496d8..b6b155e07 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1156,6 +1156,40 @@ const x = 1; expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); }); + it("marks tracked files omitted by the total diff budget as too_large", async () => { + for (let i = 1; i <= 4; i += 1) { + writeFileSync(join(repoDir, `budget-${i}.txt`), "old\n"); + } + execFileSync("git", ["add", "."], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add budget files"], { + cwd: repoDir, + }); + + const largeLine = "x".repeat(700_000); + for (let i = 1; i <= 4; i += 1) { + writeFileSync(join(repoDir, `budget-${i}.txt`), `${largeLine}-${i}\n`); + } + + const diff = await getCheckoutDiff(repoDir, { mode: "uncommitted", includeStructured: true }); + + expect( + diff.structured?.map((file) => ({ + path: file.path, + status: file.status, + hunks: file.hunks.length, + })), + ).toEqual([ + { path: "budget-1.txt", status: "ok", hunks: 1 }, + { path: "budget-2.txt", status: "ok", hunks: 1 }, + { path: "budget-3.txt", status: "too_large", hunks: 0 }, + { path: "budget-4.txt", status: "too_large", hunks: 0 }, + ]); + expect(diff.diff).toContain("budget-1.txt"); + expect(diff.diff).toContain("budget-2.txt"); + expect(diff.diff).toContain("# budget-3.txt: diff too large omitted"); + expect(diff.diff).toContain("# budget-4.txt: diff too large omitted"); + }); + it("short-circuits tracked binary files", async () => { const trackedBinaryPath = join(repoDir, "tracked-blob.bin"); writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00])); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 4cfcf4927..3a876a15e 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -2194,6 +2194,10 @@ async function processTrackedChanges( } const diffBytes = Buffer.byteLength(fileDiff.text, "utf8"); if (trackedDiffBytes + diffBytes > TOTAL_DIFF_MAX_BYTES) { + trackedPlaceholderByPath.set(fileDiff.path, { + status: "too_large", + stat: trackedNumstatByPath.get(fileDiff.path) ?? null, + }); continue; } trackedDiffBytes += diffBytes;