Fix History restore and diff truncation edge cases

This commit is contained in:
Mohamed Boudra
2026-06-18 20:44:15 +07:00
parent b3f81c0bf4
commit 00c57617d5
25 changed files with 717 additions and 127 deletions

View File

@@ -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);
});
});

View File

@@ -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<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 {
const trimmed = body.trim();
if (!trimmed) {
@@ -665,6 +726,7 @@ async function performCleanup(shouldRemovePaseoHome: boolean): Promise<void> {
stopProcess(metroProcess),
stopProcess(relayProcess),
]);
await stopCurrentDaemonFromPidLock();
daemonProcess = null;
metroProcess = null;
relayProcess = null;

View File

@@ -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<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> {
const seedNonce = randomUUID();
const { daemon, preferences } = buildSeededStoragePayload();
@@ -214,7 +230,7 @@ export async function openSessions(page: Page): Promise<void> {
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<void> {
await expect(getSessionRowByTitle(page, title)).not.toContainText("Archived", {
timeout: 30_000,
});
}
export async function clickSessionRow(page: Page, title: string): Promise<void> {
const row = getSessionRowByTitle(page, title);
await expect(row).toBeVisible({ timeout: 30_000 });

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}`,
});
}

View File

@@ -103,8 +103,12 @@ export async function openProjectViaDaemon(
export async function archiveWorkspaceFromDaemon(
client: NewWorkspaceDaemonClient,
workspaceDirectory: string,
options?: { scope?: "workspace" | "worktree" },
): 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) {
throw new Error(payload.error.message);
}

View File

@@ -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 }> }>;

View File

@@ -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 });
});
});

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();
});
});

View File

@@ -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({
</View>
{isMobile && (
<View style={styles.rowMetaRow}>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{projectPath}
<Text
style={styles.sessionMetaText}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</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.sessionMetaText}>{timeAgo}</Text>
{agent.serverLabel ? (
<>
<Text style={styles.sessionMetaSeparator}>·</Text>
<Text style={styles.sessionMetaText} numberOfLines={1}>
{agent.serverLabel}
</Text>
</>
) : null}
</View>
)}
</View>
{!isMobile && (
<>
<Text style={styles.columnMeta} numberOfLines={1}>
{projectPath}
<View style={styles.rowColumns}>
<Text
style={styles.columnMeta}
numberOfLines={1}
testID={`agent-row-project-${agent.serverId}-${agent.id}`}
>
{projectName}
</Text>
<Text style={styles.columnMetaFixed}>{statusLabel}</Text>
<Text style={styles.columnMetaFixed}>{timeAgo}</Text>
</>
<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 ? (
<View style={styles.rowTrailing}>
@@ -330,6 +284,7 @@ export function AgentList({
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(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],

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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");

View File

@@ -206,7 +206,7 @@ export const ar: TranslationResources = {
},
},
sessions: {
title: "سجل الوكلاء",
title: "السجل",
empty: "لا توجد جلسات بعد",
actions: {
loadMore: "تحميل المزيد",

View File

@@ -205,7 +205,7 @@ export const en = {
},
},
sessions: {
title: "Agent history",
title: "History",
empty: "No sessions yet",
actions: {
loadMore: "Load more",

View File

@@ -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",

View File

@@ -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",

View File

@@ -208,7 +208,7 @@ export const ru: TranslationResources = {
},
},
sessions: {
title: "История агентов",
title: "История",
empty: "Сеансов пока нет",
actions: {
loadMore: "Загрузить больше",

View File

@@ -206,7 +206,7 @@ export const zhCN: TranslationResources = {
},
},
sessions: {
title: "Agent 历史",
title: "历史",
empty: "还没有会话",
actions: {
loadMore: "加载更多",

View File

@@ -16,6 +16,7 @@ export type AgentDirectoryEntry = Pick<
| "archivedAt"
| "createdAt"
| "labels"
| "projectPlacement"
> & {
pendingPermissionCount?: number;
};

View File

@@ -13,6 +13,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl
return {
projectKey,
projectName: deriveProjectName(projectKey),
workspaceName: null,
checkout: {
cwd: normalizedCwd,
isGit: false,

View File

@@ -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,
});

View File

@@ -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 () => {

View File

@@ -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({

View File

@@ -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]));

View File

@@ -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;