Files
paseo/packages/app/e2e/browser/import-session-flow.spec.ts
paseo-bot[bot]andMohamed Boudra 05874e289a Keep relative timestamps current in agent, schedule, and import-session rows (#5340)
* Keep relative timestamps current in agent, schedule, and import-session rows

Agent list rows, schedule rows, and import-session rows formatted their
"5m ago" labels once at render, so an idle row kept its first label until
something else re-rendered it. They now use useTimeAgo, which rides the
same shared ticker the sidebar and desktop tabs already use.

Closes #4625

* Say "just now" for the whole first minute so fresh labels stay honest

The shared ticker wakes once a minute, so a seconds label could only go
stale. The prose and compact formatters now share one age description,
and the schedule spec anchors its clock to the schedule's creation time.

* Run every cleanup in the schedule relative-time spec and name its steps

* Keep relative labels current with focused subscriptions

* Update usage card after relative-time hook rename

* Anchor import-session age spec to fixture time

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 18:00:54 +01:00

310 lines
11 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { copyFile, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { TestInfo } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import { ImportSessionFlow } from "../support/helpers/import-session";
import {
connectNewWorkspaceDaemonClient,
openProjectViaDaemon,
type OpenedProject,
} from "../support/helpers/new-workspace";
import { createTempDirectory, createTempGitRepo } from "../support/helpers/workspace";
const SCREENSHOT_DIRECTORY = path.join(
process.env.HOME ?? tmpdir(),
".paseo/plans/import-session-ux",
);
const claudeConfigDirectory = mkdtempSync(path.join(tmpdir(), "paseo-import-flow-claude-"));
const brokenProvider = "broken-acp";
test.use({
e2eDaemonConfig: {
version: 1,
agents: {
providers: {
codex: { enabled: false },
copilot: { enabled: false },
omp: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
[brokenProvider]: {
extends: "acp",
label: "Broken ACP",
command: ["missing-agent-command", "acp"],
},
},
},
},
e2eDaemonEnvironment: { CLAUDE_CONFIG_DIR: claudeConfigDirectory },
});
interface ImportFlowScenario {
project: OpenedProject;
reuseTarget: OpenedProject;
projectName: string;
projectRoot: string;
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
importSessionTimestamp: number;
repoCleanup(): Promise<void>;
unrelatedCleanup(): Promise<void>;
}
let scenario: ImportFlowScenario;
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
test.setTimeout(120_000);
test.beforeAll(async () => {
await mkdir(SCREENSHOT_DIRECTORY, { recursive: true });
const repo = await createTempGitRepo("isf-", {
originUrl: "https://github.com/paseo-e2e/import-session-fixture.git",
});
const unrelated = await createTempDirectory("isf-other-");
const worktreeDirectory = path.join(repo.path, "worktrees", "review-fix");
await mkdir(path.dirname(worktreeDirectory), { recursive: true });
execFileSync("git", ["worktree", "add", "-b", "review-fix", worktreeDirectory], {
cwd: repo.path,
stdio: "ignore",
});
client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const project = await client.createWorkspace({ source: { kind: "directory", path: repo.path } });
if (!project.workspace) {
throw new Error(project.error ?? "Failed to create the import-session fixture workspace");
}
const projects = await client.listProjects();
const projectDescriptor = projects.projects.find(
(candidate) => candidate.projectId === project.workspace?.projectId,
);
if (!projectDescriptor?.projectKey) {
throw new Error("Fixture workspace has no project key");
}
const openedProject: OpenedProject = {
workspaceId: project.workspace.id,
projectId: project.workspace.projectId,
projectKey: projectDescriptor.projectKey,
projectDisplayName: project.workspace.projectDisplayName,
workspaceName: project.workspace.name,
workspaceDirectory: project.workspace.workspaceDirectory,
};
const reuseTarget = await openProjectViaDaemon(client, unrelated.path);
const importSessionId = "fixture-custom-title";
const importSessionTimestamp = await seedClaudeSessions({
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
});
scenario = {
project: openedProject,
reuseTarget,
projectName: project.workspace.projectDisplayName,
projectRoot: repo.path,
worktreeDirectory,
unrelatedDirectory: unrelated.path,
importSessionId,
importSessionTimestamp,
repoCleanup: repo.cleanup,
unrelatedCleanup: unrelated.cleanup,
};
});
test.afterAll(async () => {
await client?.removeProject(scenario?.project.projectId).catch(() => undefined);
await client?.removeProject(scenario?.reuseTarget.projectId).catch(() => undefined);
await client?.close().catch(() => undefined);
await scenario?.repoCleanup().catch(() => undefined);
await scenario?.unrelatedCleanup().catch(() => undefined);
await rm(claudeConfigDirectory, { recursive: true, force: true });
});
test("an open Import Session row keeps its age current", async ({ page }) => {
await page.clock.install({ time: scenario.importSessionTimestamp + 60_000 });
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await flow.revealMobileEntryPoint();
await flow.openGlobally();
const row = page.getByTestId(`import-session-session-claude-${scenario.importSessionId}`);
await expect(row).toContainText("1m ago");
await page.clock.fastForward("03:00");
await expect(row).toContainText("4m ago");
});
test("captures the compact import-session journey", async ({ page }, testInfo) => {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, { width: 390, height: 844 });
await test.step("the mobile sidebar exposes import in its footer", async () => {
await flow.revealMobileEntryPoint();
await capture(page, testInfo, "01-mobile-sidebar-footer.png");
});
await test.step("the host-wide sheet is newest first and fits its provider filter", async () => {
await flow.openGlobally();
await flow.expectScope("Sessions on", false);
await flow.expectRows({
first: [scenario.importSessionId, "fixture-worktree", "fixture-unrelated"],
folders: [
[scenario.importSessionId, scenario.projectName],
["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`],
["fixture-unrelated", scenario.reuseTarget.projectDisplayName],
],
});
await flow.expectProviderError("Broken ACP");
await flow.expectProviderFilterFits(390);
await flow.revealSession("fixture-unrelated");
await capture(page, testInfo, "02-mobile-sheet-unscoped.png");
});
await test.step("search narrows across the fixture corpus", async () => {
await flow.search("invoice");
await capture(page, testInfo, "03-mobile-search-narrowed.png");
});
await test.step("load more grows the result set and then disappears", async () => {
await flow.resetSearch();
await capture(page, testInfo, "04-mobile-load-more-visible.png");
await flow.loadMore();
await capture(page, testInfo, "05-mobile-load-more-complete.png");
});
await test.step("one provider fails inline and Retry settles", async () => {
await flow.retryProvider(brokenProvider, "Broken ACP");
await capture(page, testInfo, "06-mobile-provider-error-retry.png");
});
await test.step("the selected row imports and opens the hydrated transcript", async () => {
await flow.importSession(scenario.importSessionId);
await flow.expectTranscript("Review the invoice migration", "The fixture transcript is ready.");
await capture(page, testInfo, "08-mobile-agent-after-import.png");
});
await test.step("workspace actions start scoped and can widen to the host", async () => {
await flow.openFromWorkspaceHeader();
await capture(page, testInfo, "09-mobile-workspace-scoped.png");
await flow.showAll();
await flow.expectRows({
folders: [["fixture-unrelated", scenario.reuseTarget.projectDisplayName]],
});
await capture(page, testInfo, "10-mobile-workspace-show-all.png");
await flow.importSession("fixture-root-03");
await flow.expectImportedIntoWorkspace(
scenario.reuseTarget.workspaceId,
"Review fixture item 3",
);
});
});
test("captures the desktop import sheet and command-center entry", async ({ page }, testInfo) => {
const flow = new ImportSessionFlow(page);
await flow.openWorkspace(scenario.project.workspaceId, {
width: 1280,
height: 800,
});
await test.step("desktop shows the flat host-wide sheet", async () => {
await flow.openGlobally();
// The compact test may already have imported the newest fixture row, so this
// asserts recency as an ordering between two rows nothing imports.
await flow.expectRows({
before: ["fixture-worktree", "fixture-unrelated"],
folders: [["fixture-worktree", `${scenario.projectName} · worktrees/review-fix`]],
});
await flow.revealSession("fixture-unrelated");
await capture(page, testInfo, "11-desktop-sheet-unscoped.png");
await flow.close();
});
await test.step("import matches the command but not Home", async () => {
await flow.expectCommandCenterMatch();
await capture(page, testInfo, "12-desktop-command-center-import.png");
});
});
async function capture(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const outputPath = testInfo.outputPath(name);
await page.screenshot({ path: outputPath, fullPage: true });
await copyFile(outputPath, path.join(SCREENSHOT_DIRECTORY, name));
}
async function seedClaudeSessions(input: {
projectRoot: string;
worktreeDirectory: string;
unrelatedDirectory: string;
importSessionId: string;
}): Promise<number> {
const sessions = [
{
cwd: input.projectRoot,
id: input.importSessionId,
title: "Invoice migration plan",
prompt: "Review the invoice migration",
answer: "The fixture transcript is ready.",
},
{
cwd: input.worktreeDirectory,
id: "fixture-worktree",
title: "Review worktree fix",
prompt: "Check the worktree import flow",
},
{
cwd: input.unrelatedDirectory,
id: "fixture-unrelated",
title: "Unrelated directory notes",
prompt: "Review the unrelated directory",
},
...Array.from({ length: 20 }, (_, index) => ({
cwd: [input.projectRoot, input.worktreeDirectory, input.unrelatedDirectory][index % 3]!,
id: `fixture-root-${String(index + 1).padStart(2, "0")}`,
title: `Root session ${String(index + 1).padStart(2, "0")}`,
prompt: index === 7 ? "Investigate invoice rendering" : `Review fixture item ${index + 1}`,
})),
];
const newest = Date.now() - 60_000;
for (const [index, session] of sessions.entries()) {
const projectDirectory = path.join(
claudeConfigDirectory,
"projects",
session.cwd.replace(/[^a-zA-Z0-9]/g, "-"),
);
await mkdir(projectDirectory, { recursive: true });
const sessionPath = path.join(projectDirectory, `${session.id}.jsonl`);
const records = [
{
type: "user",
uuid: `${session.id}-user`,
message: { role: "user", content: session.prompt },
cwd: session.cwd,
sessionId: session.id,
},
...(session.answer
? [
{
type: "assistant",
uuid: `${session.id}-assistant`,
message: {
role: "assistant",
content: [{ type: "text", text: session.answer }],
},
cwd: session.cwd,
sessionId: session.id,
},
]
: []),
{ type: "custom-title", customTitle: session.title, sessionId: session.id },
];
await writeFile(sessionPath, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
const timestamp = new Date(newest - index * 60_000);
await utimes(sessionPath, timestamp, timestamp);
}
return newest;
}