Fix History restore and diff truncation edge cases

This commit is contained in:
Mohamed Boudra
2026-06-18 20:44:57 +07:00
parent b3f81c0bf4
commit 00c57617d5
25 changed files with 717 additions and 127 deletions
+16 -7
View File
@@ -1,18 +1,21 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { expect } from "@playwright/test";
import { test } from "./fixtures"; import { test } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client"; import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo } from "./helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
import { import {
archiveAgentFromDaemon, archiveAgentFromDaemon,
archiveAgentFromSessions, archiveAgentFromSessions,
clickSessionRow, clickSessionRow,
closeWorkspaceAgentTab,
createIdleAgent, createIdleAgent,
expectArchivedAgentFocused,
expectSessionRowArchived, expectSessionRowArchived,
expectSessionRowVisible, expectSessionRowVisible,
expectWorkspaceArchiveOutcome, expectWorkspaceArchiveOutcome,
expectWorkspaceTabHidden, expectWorkspaceTabHidden,
fetchAgentArchivedAt,
expectWorkspaceTabVisible,
openSessions, openSessions,
openWorkspaceWithAgents, openWorkspaceWithAgents,
primeAdditionalPage, 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, { const archived = await createIdleAgent(client, {
cwd: tempRepo.path, cwd: tempRepo.path,
title: `reopen-archived-${randomUUID().slice(0, 8)}`, title: `unarchive-archived-${randomUUID().slice(0, 8)}`,
}); });
const surviving = await createIdleAgent(client, { const surviving = await createIdleAgent(client, {
cwd: tempRepo.path, cwd: tempRepo.path,
title: `reopen-control-${randomUUID().slice(0, 8)}`, title: `unarchive-control-${randomUUID().slice(0, 8)}`,
}); });
await resetSeededPageState(page); await resetSeededPageState(page);
await openWorkspaceWithAgents(page, [archived, surviving]); await openWorkspaceWithAgents(page, [archived, surviving]);
await closeWorkspaceAgentTab(page, archived.id);
await archiveAgentFromDaemon(client, archived.id); await archiveAgentFromDaemon(client, archived.id);
await openSessions(page); await openSessions(page);
await expectSessionRowArchived(page, archived.title); await expectSessionRowArchived(page, archived.title);
await clickSessionRow(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);
}); });
}); });
+63 -1
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { spawn, type ChildProcess, execSync } from "node:child_process"; import { spawn, type ChildProcess, execSync } from "node:child_process";
import { existsSync } from "node:fs"; 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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import net from "node:net"; import net from "node:net";
@@ -154,6 +154,67 @@ async function stopProcess(child: ChildProcess | null): Promise<void> {
}); });
} }
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
async function readSupervisorPidLock(home: string): Promise<number | null> {
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<void> {
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<void> {
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 { function summarizeOpenAiErrorBody(body: string): string {
const trimmed = body.trim(); const trimmed = body.trim();
if (!trimmed) { if (!trimmed) {
@@ -665,6 +726,7 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
stopProcess(metroProcess), stopProcess(metroProcess),
stopProcess(relayProcess), stopProcess(relayProcess),
]); ]);
await stopCurrentDaemonFromPidLock();
daemonProcess = null; daemonProcess = null;
metroProcess = null; metroProcess = null;
relayProcess = null; relayProcess = null;
+23 -1
View File
@@ -93,6 +93,22 @@ export async function archiveAgentFromDaemon(
await client.archiveAgent(agentId); await client.archiveAgent(agentId);
} }
export async function fetchAgentArchivedAt(
client: {
fetchAgent(agentId: string): Promise<{ agent: { archivedAt?: string | null } } | null>;
},
agentId: string,
): Promise<string | null> {
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<void> { export async function primeAdditionalPage(page: Page): Promise<void> {
const seedNonce = randomUUID(); const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload(); const { daemon, preferences } = buildSeededStoragePayload();
@@ -214,7 +230,7 @@ export async function openSessions(page: Page): Promise<void> {
await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), { await expect(page).toHaveURL(new RegExp(`${buildHostSessionsRoute(getServerId())}$`), {
timeout: 30_000, 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, 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 }); await expect(getSessionRowByTitle(page, title)).toContainText("Archived", { timeout: 30_000 });
} }
export async function expectSessionRowNotArchived(page: Page, title: string): Promise<void> {
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
timeout: 30_000,
});
}
export async function clickSessionRow(page: Page, title: string): Promise<void> { export async function clickSessionRow(page: Page, title: string): Promise<void> {
const row = getSessionRowByTitle(page, title); const row = getSessionRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 30_000 }); await expect(row).toBeVisible({ timeout: 30_000 });
+157
View File
@@ -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<number> {
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<boolean> {
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> | boolean,
options: { timeoutMs: number; label: string },
): Promise<void> {
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<void> {
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}`,
});
}
+5 -1
View File
@@ -103,8 +103,12 @@ export async function openProjectViaDaemon(
export async function archiveWorkspaceFromDaemon( export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient, client: NewWorkspaceDaemonClient,
workspaceDirectory: string, workspaceDirectory: string,
options?: { scope?: "workspace" | "worktree" },
): Promise<void> { ): Promise<void> {
const payload = await client.archivePaseoWorktree({ worktreePath: workspaceDirectory }); const payload = await client.archivePaseoWorktree({
worktreePath: workspaceDirectory,
...(options?.scope !== undefined ? { scope: options.scope } : {}),
});
if (payload.error) { if (payload.error) {
throw new Error(payload.error.message); throw new Error(payload.error.message);
} }
+6
View File
@@ -121,6 +121,12 @@ export interface SeedDaemonClient {
timeout?: number, timeout?: number,
): Promise<{ status: string; final?: { lastError?: string | null } | null }>; ): Promise<{ status: string; final?: { lastError?: string | null } | null }>;
archiveAgent(agentId: string): Promise<{ archivedAt: string }>; 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?: { fetchAgentHistory(options?: {
page?: { limit: number }; page?: { limit: number };
}): Promise<{ entries: Array<{ id: string }> }>; }): Promise<{ entries: Array<{ id: string }> }>;
@@ -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<ReturnType<typeof connectSeedClient>>;
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
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 });
});
});
+127
View File
@@ -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<ReturnType<typeof connectSeedClient>>;
let worktreeClient: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
let tempRepo: { path: string; cleanup: () => Promise<void> };
const createdWorktreeDirectories = new Set<string>();
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();
});
});
+91 -110
View File
@@ -15,14 +15,14 @@ import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { formatTimeAgo } from "@/utils/time"; import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents"; import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { Archive } from "lucide-react-native"; import { Archive } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import type { Agent } from "@/stores/session-store";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useQueryClient } from "@tanstack/react-query";
import { agentHistoryQueryKey } from "@/hooks/agent-history-query-key";
interface AgentListProps { interface AgentListProps {
agents: AggregatedAgent[]; agents: AggregatedAgent[];
@@ -49,65 +49,6 @@ type FlatListItem =
| { type: "header"; key: string; section: DateSectionKey } | { type: "header"; key: string; section: DateSectionKey }
| { type: "agent"; key: string; agent: AggregatedAgent }; | { 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 { function deriveDateSectionKey(lastActivityAt: Date): DateSectionKey {
const now = new Date(); const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); 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({ function SessionBadge({
label, label,
icon, icon,
@@ -221,8 +145,9 @@ function SessionRow({
const timeAgo = formatTimeAgo(agent.lastActivityAt); const timeAgo = formatTimeAgo(agent.lastActivityAt);
const agentKey = `${agent.serverId}:${agent.id}`; const agentKey = `${agent.serverId}:${agent.id}`;
const isSelected = selectedAgentId === agentKey; const isSelected = selectedAgentId === agentKey;
const statusLabel = formatStatusLabel(t, agent.status); const projectName = agent.projectPlacement?.projectName ?? "";
const projectPath = shortenPath(agent.cwd); const branch = agent.projectPlacement?.checkout.currentBranch ?? "";
const workspaceName = agent.projectPlacement?.workspaceName ?? "";
const ProviderIcon = getProviderIcon(agent.provider); const ProviderIcon = getProviderIcon(agent.provider);
const pendingPermissionCount = agent.pendingPermissionCount ?? 0; const pendingPermissionCount = agent.pendingPermissionCount ?? 0;
@@ -279,32 +204,61 @@ function SessionRow({
</View> </View>
{isMobile && ( {isMobile && (
<View style={styles.rowMetaRow}> <View style={styles.rowMetaRow}>
<Text style={styles.sessionMetaText} numberOfLines={1}> <Text
{projectPath} style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text> </Text>
<Text style={styles.sessionMetaSeparator}>·</Text> <Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{statusLabel}</Text> <Text
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
>
{workspaceName}
</Text>
<Text style={styles.sessionMetaSeparator}>·</Text> <Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText}>{timeAgo}</Text> <Text style={styles.sessionMetaText}>{timeAgo}</Text>
{agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View> </View>
)} )}
</View> </View>
{!isMobile && ( {!isMobile && (
<> <View style={styles.rowColumns}>
<Text style={styles.columnMeta} numberOfLines={1}> <Text
{projectPath} style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text> </Text>
<Text style={styles.columnMetaFixed}>{statusLabel}</Text> <Text
<Text style={styles.columnMetaFixed}>{timeAgo}</Text> style={styles.columnMeta}
</> numberOfLines={1}
testID={`agent-row-branch-${agent.serverId}-${agent.id}`}
>
{branch}
</Text>
<Text
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-workspace-${agent.serverId}-${agent.id}`}
>
{workspaceName}
</Text>
<Text style={styles.columnMetaFixed} numberOfLines={1}>
{timeAgo}
</Text>
</View>
)} )}
{isMobile && showAttentionIndicator && agent.requiresAttention ? ( {isMobile && showAttentionIndicator && agent.requiresAttention ? (
<View style={styles.rowTrailing}> <View style={styles.rowTrailing}>
@@ -330,6 +284,7 @@ export function AgentList({
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null); const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
const isMobile = useIsCompactFormFactor(); const isMobile = useIsCompactFormFactor();
const { archiveAgent } = useArchiveAgent(); const { archiveAgent } = useArchiveAgent();
const queryClient = useQueryClient();
const actionClient = useSessionStore((state) => const actionClient = useSessionStore((state) =>
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null, actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
@@ -346,17 +301,35 @@ export function AgentList({
const serverId = agent.serverId; const serverId = agent.serverId;
const agentId = agent.id; 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); openAgent();
navigateToAgent({
serverId,
agentId,
pin: Boolean(agent.archivedAt),
});
}, },
[isActionSheetVisible, onAgentSelect], [isActionSheetVisible, onAgentSelect, queryClient],
); );
const handleAgentLongPress = useCallback( const handleAgentLongPress = useCallback(
@@ -561,12 +534,14 @@ const styles = StyleSheet.create((theme) => ({
rowContent: { rowContent: {
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
overflow: "hidden",
}, },
rowTitleRow: { rowTitleRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
flexWrap: "wrap", flexWrap: "nowrap",
gap: theme.spacing[2], gap: theme.spacing[2],
overflow: "hidden",
}, },
providerIconWrap: { providerIconWrap: {
width: theme.iconSize.md, width: theme.iconSize.md,
@@ -594,6 +569,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
sessionTitle: { sessionTitle: {
flexShrink: 1, flexShrink: 1,
minWidth: 0,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
fontWeight: "400", fontWeight: "400",
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -612,13 +588,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
opacity: 0.7, opacity: 0.7,
}, },
rowColumns: {
flexDirection: "row",
alignItems: "center",
flexShrink: 0,
gap: theme.spacing[3],
},
columnMeta: { columnMeta: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
flexShrink: 1, flexShrink: 0,
minWidth: 60, width: 132,
maxWidth: 200,
marginLeft: theme.spacing[4],
}, },
columnMetaFixed: { columnMetaFixed: {
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
@@ -630,6 +610,7 @@ const styles = StyleSheet.create((theme) => ({
badge: { badge: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
flexShrink: 0,
gap: theme.spacing[1], gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
@@ -69,6 +69,7 @@ export async function fetchAgentHistoryPage(input: {
archivedAt: agent.archivedAt ?? null, archivedAt: agent.archivedAt ?? null,
createdAt: agent.createdAt, createdAt: agent.createdAt,
labels: agent.labels, labels: agent.labels,
projectPlacement: agent.projectPlacement,
})), })),
pageInfo: payload.pageInfo, pageInfo: payload.pageInfo,
}; };
@@ -87,6 +87,7 @@ export function useAggregatedAgents(options?: {
archivedAt: agent.archivedAt, archivedAt: agent.archivedAt,
createdAt: agent.createdAt, createdAt: agent.createdAt,
labels: agent.labels, labels: agent.labels,
projectPlacement: agent.projectPlacement,
}; };
const cacheKey = `${serverId}:${agent.id}`; const cacheKey = `${serverId}:${agent.id}`;
const prev = prevAgentsRef.current.get(cacheKey); const prev = prevAgentsRef.current.get(cacheKey);
+1 -1
View File
@@ -291,7 +291,7 @@ describe("translation resources", () => {
}); });
it("includes sessions and agent list keys for the Batch 4H migration", () => { 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.empty).toBe("No sessions yet");
expect(en.sessions.actions.loadMore).toBe("Load more"); expect(en.sessions.actions.loadMore).toBe("Load more");
expect(en.agentList.fallbackTitle).toBe("New session"); expect(en.agentList.fallbackTitle).toBe("New session");
+1 -1
View File
@@ -206,7 +206,7 @@ export const ar: TranslationResources = {
}, },
}, },
sessions: { sessions: {
title: "سجل الوكلاء", title: "السجل",
empty: "لا توجد جلسات بعد", empty: "لا توجد جلسات بعد",
actions: { actions: {
loadMore: "تحميل المزيد", loadMore: "تحميل المزيد",
+1 -1
View File
@@ -205,7 +205,7 @@ export const en = {
}, },
}, },
sessions: { sessions: {
title: "Agent history", title: "History",
empty: "No sessions yet", empty: "No sessions yet",
actions: { actions: {
loadMore: "Load more", loadMore: "Load more",
+1 -1
View File
@@ -209,7 +209,7 @@ export const es: TranslationResources = {
}, },
}, },
sessions: { sessions: {
title: "Historial de agentes", title: "Historial",
empty: "Aún no hay sesiones", empty: "Aún no hay sesiones",
actions: { actions: {
loadMore: "Cargar más", loadMore: "Cargar más",
+1 -1
View File
@@ -209,7 +209,7 @@ export const fr: TranslationResources = {
}, },
}, },
sessions: { sessions: {
title: "Historique des agents", title: "Historique",
empty: "Aucune séance pour l'instant", empty: "Aucune séance pour l'instant",
actions: { actions: {
loadMore: "Charger plus", loadMore: "Charger plus",
+1 -1
View File
@@ -208,7 +208,7 @@ export const ru: TranslationResources = {
}, },
}, },
sessions: { sessions: {
title: "История агентов", title: "История",
empty: "Сеансов пока нет", empty: "Сеансов пока нет",
actions: { actions: {
loadMore: "Загрузить больше", loadMore: "Загрузить больше",
+1 -1
View File
@@ -206,7 +206,7 @@ export const zhCN: TranslationResources = {
}, },
}, },
sessions: { sessions: {
title: "Agent 历史", title: "历史",
empty: "还没有会话", empty: "还没有会话",
actions: { actions: {
loadMore: "加载更多", loadMore: "加载更多",
@@ -16,6 +16,7 @@ export type AgentDirectoryEntry = Pick<
| "archivedAt" | "archivedAt"
| "createdAt" | "createdAt"
| "labels" | "labels"
| "projectPlacement"
> & { > & {
pendingPermissionCount?: number; pendingPermissionCount?: number;
}; };
@@ -13,6 +13,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
return { return {
projectKey, projectKey,
projectName: deriveProjectName(projectKey), projectName: deriveProjectName(projectKey),
workspaceName: null,
checkout: { checkout: {
cwd: normalizedCwd, cwd: normalizedCwd,
isGit: false, isGit: false,
+1
View File
@@ -2430,6 +2430,7 @@ export const ProjectCheckoutLitePayloadSchema = z.union([
export const ProjectPlacementPayloadSchema = z.object({ export const ProjectPlacementPayloadSchema = z.object({
projectKey: z.string(), projectKey: z.string(),
projectName: z.string(), projectName: z.string(),
workspaceName: z.string().nullable().optional(),
checkout: ProjectCheckoutLitePayloadSchema, checkout: ProjectCheckoutLitePayloadSchema,
}); });
@@ -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"))( test.skipIf(isPlatform("win32"))(
"rejects with UnknownBranchError when the kept branch no longer exists", "rejects with UnknownBranchError when the kept branch no longer exists",
async () => { async () => {
+14
View File
@@ -277,6 +277,7 @@ import {
toWorktreeWireError, toWorktreeWireError,
} from "./worktree-errors.js"; } from "./worktree-errors.js";
import { type WorktreeConfig, createWorktree } from "../utils/worktree.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"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__"; const WORKSPACE_GIT_WATCH_REMOVED_STATE_KEY = "__removed__";
@@ -1689,6 +1690,7 @@ export class Session {
return { return {
projectKey: project.projectId, projectKey: project.projectId,
projectName: resolveProjectDisplayName(project), projectName: resolveProjectDisplayName(project),
workspaceName: resolveWorkspaceDisplayName(workspace),
checkout, 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; let result: WorktreeConfig;
try { try {
result = await createWorktree({ result = await createWorktree({
@@ -1156,6 +1156,40 @@ const x = 1;
expect(metrics.maxConcurrent).toBeLessThanOrEqual(8); 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 () => { it("short-circuits tracked binary files", async () => {
const trackedBinaryPath = join(repoDir, "tracked-blob.bin"); const trackedBinaryPath = join(repoDir, "tracked-blob.bin");
writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00])); writeFileSync(trackedBinaryPath, Buffer.from([0x00, 0xff, 0x10, 0x80, 0x00]));
@@ -2194,6 +2194,10 @@ async function processTrackedChanges(
} }
const diffBytes = Buffer.byteLength(fileDiff.text, "utf8"); const diffBytes = Buffer.byteLength(fileDiff.text, "utf8");
if (trackedDiffBytes + diffBytes > TOTAL_DIFF_MAX_BYTES) { if (trackedDiffBytes + diffBytes > TOTAL_DIFF_MAX_BYTES) {
trackedPlaceholderByPath.set(fileDiff.path, {
status: "too_large",
stat: trackedNumstatByPath.get(fileDiff.path) ?? null,
});
continue; continue;
} }
trackedDiffBytes += diffBytes; trackedDiffBytes += diffBytes;