Files
paseo/packages/app/src/components/import-session-sheet-view-model.test.ts
T
Mohamed Boudra ac085d50c6 Make import session one tap away and make the sheet find your session (#4216)
* feat(app): make import session a first-class entry point

* feat(server): improve import session discovery resilience

Search up to 500 persisted candidates per provider and isolate listing failures behind eight-second deadlines so healthy results remain usable. Preserve native persisted title precedence while keeping discovery read-only.

Parsing now continues through each transcript so native titles and the true last prompt are retained; the old early exit made lastPromptPreview equal firstPromptPreview. A cheap record prefilter avoids JSON.parse for unrelated lines during the full scan.

* feat(app): searchable, grouped import session sheet with per-provider errors

The sheet now carries a debounced search field gated on the host's
importSessionSearch feature, grows its per-provider page on demand
(15 -> 45 -> 90 -> 200) instead of capping at the 15 most recent, and groups
unscoped rows by folder under the project name the app knows for that
directory. Worktrees of one project would otherwise all head up as the same
bare name, so a directory below its project root carries the path under that
root beside the name.

A workspace-scoped sheet says so and offers "Show all sessions". Importing a
row from another directory once that is on drops the workspace from the
request, which the daemon would otherwise reject, and routes the user to the
agent's own workspace rather than opening a dead tab in the current one. That
decision lives in resolveImportTarget: a scoped listing is trusted wholesale
because the daemon already matched it realpath-aware, which the client cannot.

A provider that fails now gets its own error row with a Retry that refetches
only that provider, sourced from both a failed request and the daemon's
providerErrors. React Query no longer retries these, so the header spinner
settles once every provider has answered or given up (#2512).

* test(e2e): import session flow with fixture Claude sessions

* fix(app): shorten import sheet scope copy so it fits at phone width

At 390px the scoped subtitle rendered "Sessions in this wor..." — the label,
its action, and the sheet's refresh and close buttons do not share one line at
phone width. The label already sits under the sheet title "Import session", so
"Sessions in this" was repeating context the header had: it becomes "This
workspace" and the action becomes "Show all". The unscoped label still names
the host, which nothing else on the sheet does.

* refactor(app): flat recency-ordered import list with per-row folder label

Grouping by folder buried recency: a busy directory's block of fifteen pushed
a two-minute-old session in a quiet directory below the fold, and the axis a
user is scanning on is when they last touched the session, not where it lives.
The unscoped sheet is now one list in the order aggregateSessionEntries already
produces, and each row carries its folder as a muted third line.

resolveDirectoryLabel stays — it is what keeps two worktrees of one project
apart — and formatDirectoryLabel renders its two parts as the single ellipsised
line a row has room for. A scoped sheet lists one directory, so its rows carry
no folder line at all.

* fix(app): close import sheet before refreshing sessions

Provider-list invalidation waited for every active query before the sheet could close. Mark the disabled picker queries stale after close and navigation so reopening refreshes without delaying the imported agent.

* fix(server): reuse workspaces for session imports

Host-wide imports should join the oldest active workspace for their directory, or restore its archived workspace, instead of minting duplicates. Rollback remains limited to workspace records created by the failed import.

* test(e2e): express the import session journey through a helper DSL

* test(e2e): align sidebar journeys with import entry point
2026-09-02 15:44:44 +02:00

469 lines
15 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { FetchRecentProviderSessionEntry } from "@getpaseo/client/internal/daemon-client";
import {
aggregateSessionEntries,
ALL_FILTER_VALUE,
buildProviderLabelMap,
collectProviderErrorRows,
computeEmptyState,
formatDirectoryLabel,
getPromptPreview,
getSessionTitle,
hasMoreSessions,
nextPageLimit,
PER_PROVIDER_LIMIT,
resolveDirectoryLabel,
resolveImportTarget,
resolveProvidersToFetch,
requiresImportSessionsHostUpgrade,
type SessionsQueryResult,
sumFilteredAlreadyImportedCount,
} from "@/components/import-session-sheet-view-model";
function entry(
overrides: Partial<FetchRecentProviderSessionEntry> = {},
): FetchRecentProviderSessionEntry {
return {
providerId: "claude",
providerLabel: "Claude Code",
providerHandleId: "thread-1",
cwd: "/repo/paseo",
title: null,
firstPromptPreview: null,
lastPromptPreview: null,
lastActivityAt: "2026-04-30T10:00:00.000Z",
...overrides,
};
}
function settled(
data: SessionsQueryResult["data"],
flags?: Partial<Omit<SessionsQueryResult, "data">>,
): SessionsQueryResult {
return {
data,
isError: false,
isLoading: false,
isPending: false,
...flags,
};
}
describe("resolveProvidersToFetch", () => {
it("returns null when the daemon does not support provider snapshots", () => {
expect(resolveProvidersToFetch(false, [{ provider: "claude" }])).toBeNull();
});
it("returns null while snapshot entries have not loaded yet", () => {
expect(resolveProvidersToFetch(true, undefined)).toBeNull();
});
it("returns enabled providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude" },
{ provider: "codex" },
{ provider: "opencode", enabled: false },
{ provider: "z-ai" },
]);
expect(providers).toEqual(["claude", "codex", "z-ai"]);
});
it("returns an empty array when snapshot has no enabled providers", () => {
const providers = resolveProvidersToFetch(true, [
{ provider: "claude", enabled: false },
{ provider: "z-ai", enabled: false },
]);
expect(providers).toEqual([]);
});
});
describe("requiresImportSessionsHostUpgrade", () => {
it("allows home imports on hosts without workspace targeting", () => {
expect(
requiresImportSessionsHostUpgrade({
supportsSnapshot: true,
workspaceId: null,
supportsWorkspaceTarget: false,
}),
).toBe(false);
});
it("requires host support for imports opened from a workspace", () => {
expect(
requiresImportSessionsHostUpgrade({
supportsSnapshot: true,
workspaceId: "ws-current",
supportsWorkspaceTarget: false,
}),
).toBe(true);
expect(
requiresImportSessionsHostUpgrade({
supportsSnapshot: true,
workspaceId: "ws-current",
supportsWorkspaceTarget: true,
}),
).toBe(false);
});
});
describe("buildProviderLabelMap", () => {
it("returns an empty map when snapshot entries are missing", () => {
expect(buildProviderLabelMap(undefined).size).toBe(0);
});
it("indexes labels by provider id, skipping entries without a label", () => {
const labels = buildProviderLabelMap([
{ provider: "claude", label: "Claude Code" },
{ provider: "codex" },
{ provider: "z-ai", label: "Z.AI" },
]);
expect(labels.get("claude")).toBe("Claude Code");
expect(labels.get("codex")).toBeUndefined();
expect(labels.get("z-ai")).toBe("Z.AI");
});
});
describe("aggregateSessionEntries", () => {
it("returns an empty array when no queries have data", () => {
expect(aggregateSessionEntries([settled(undefined)])).toEqual([]);
});
it("dedupes by providerId+providerHandleId across query results", () => {
const result = aggregateSessionEntries([
settled({
entries: [
entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
],
}),
settled({
entries: [
entry({ providerHandleId: "thread-1", lastActivityAt: "2026-04-30T11:00:00.000Z" }),
entry({ providerHandleId: "thread-2", lastActivityAt: "2026-04-30T09:00:00.000Z" }),
],
}),
]);
expect(result.map((e) => e.providerHandleId)).toEqual(["thread-1", "thread-2"]);
});
it("sorts collected entries by lastActivityAt descending", () => {
const result = aggregateSessionEntries([
settled({
entries: [
entry({ providerHandleId: "old", lastActivityAt: "2026-04-29T10:00:00.000Z" }),
entry({ providerHandleId: "new", lastActivityAt: "2026-04-30T10:00:00.000Z" }),
],
}),
]);
expect(result.map((e) => e.providerHandleId)).toEqual(["new", "old"]);
});
});
describe("sumFilteredAlreadyImportedCount", () => {
it("returns 0 when no queries report a filtered count", () => {
expect(sumFilteredAlreadyImportedCount([settled({ entries: [] })])).toBe(0);
});
it("sums the filtered already-imported counts across queries", () => {
const total = sumFilteredAlreadyImportedCount([
settled({ entries: [], filteredAlreadyImportedCount: 2 }),
settled({ entries: [], filteredAlreadyImportedCount: 3 }),
settled(undefined),
]);
expect(total).toBe(5);
});
});
describe("collectProviderErrorRows", () => {
it("returns no rows when no providers are being fetched", () => {
expect(collectProviderErrorRows(null, [], new Map())).toEqual([]);
});
it("returns a row for each provider whose request failed", () => {
const rows = collectProviderErrorRows(
["claude", "codex"],
[settled(undefined, { isError: true }), settled({ entries: [] })],
new Map([["claude", "Claude Code"]]),
);
expect(rows).toEqual([{ provider: "claude", label: "Claude Code" }]);
});
it("returns a row for a provider the daemon reported as failed in a good response", () => {
const rows = collectProviderErrorRows(
["codex"],
[
settled({
entries: [],
providerErrors: [{ provider: "codex", message: "timed out" }],
}),
],
new Map([["codex", "Codex"]]),
);
expect(rows).toEqual([{ provider: "codex", label: "Codex" }]);
});
it("uses provider id when the label map has no entry", () => {
const rows = collectProviderErrorRows(
["codex"],
[settled(undefined, { isError: true })],
new Map(),
);
expect(rows).toEqual([{ provider: "codex", label: "codex" }]);
});
});
describe("nextPageLimit", () => {
it("grows the per-provider limit one step at a time", () => {
expect(nextPageLimit(PER_PROVIDER_LIMIT)).toBe(45);
expect(nextPageLimit(45)).toBe(90);
expect(nextPageLimit(90)).toBe(200);
});
it("stops at the protocol ceiling", () => {
expect(nextPageLimit(200)).toBe(200);
});
});
describe("hasMoreSessions", () => {
it("reports more when a provider filled the requested page", () => {
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 2)).toBe(true);
});
it("reports no more once every provider returned a short page", () => {
expect(hasMoreSessions([settled({ entries: [entry()] })], 2)).toBe(false);
});
it("reports no more at the last page size, however full the page is", () => {
expect(hasMoreSessions([settled({ entries: [entry(), entry()] })], 200)).toBe(false);
});
it("keeps reporting more while the next page replaces placeholder rows", () => {
expect(
hasMoreSessions([settled({ entries: [entry()] }, { isPlaceholderData: true })], 45),
).toBe(true);
});
});
describe("resolveDirectoryLabel", () => {
const projects = [
{ rootPath: "/home/me/paseo", name: "paseo" },
{ rootPath: "/home/me/paseo/packages/app", name: "paseo app" },
];
it("names the project root after the project alone", () => {
expect(resolveDirectoryLabel("/home/me/paseo", projects)).toEqual({ name: "paseo" });
});
it("qualifies a worktree under the project with its path below the root", () => {
expect(
resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]),
).toEqual({ name: "paseo", detail: ".dev/worktrees/abc/zebra" });
});
it("tells two worktrees of the same project apart", () => {
const zebra = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/abc/zebra", [projects[0]!]);
const otter = resolveDirectoryLabel("/home/me/paseo/.dev/worktrees/def/otter", [projects[0]!]);
expect(zebra).not.toEqual(otter);
expect([zebra.detail, otter.detail]).toEqual([
".dev/worktrees/abc/zebra",
".dev/worktrees/def/otter",
]);
});
it("picks the most specific project root containing the directory", () => {
expect(resolveDirectoryLabel("/home/me/paseo/packages/app/src", projects)).toEqual({
name: "paseo app",
detail: "src",
});
});
it("ignores trailing slashes on both sides", () => {
expect(
resolveDirectoryLabel("/home/me/paseo/", [{ rootPath: "/home/me/paseo/", name: "p" }]),
).toEqual({ name: "p" });
});
it("does not match a project root that is only a string prefix", () => {
expect(resolveDirectoryLabel("/home/me/paseo-fork", projects)).toEqual({
name: "/home/me/paseo-fork",
});
});
it("falls back to the path when no project owns the directory", () => {
expect(resolveDirectoryLabel("/tmp/scratch", projects)).toEqual({ name: "/tmp/scratch" });
});
});
describe("formatDirectoryLabel", () => {
it("shows the project name alone at its root", () => {
expect(formatDirectoryLabel({ name: "paseo" })).toBe("paseo");
});
it("appends the path under the root so worktrees of one project read apart", () => {
expect(formatDirectoryLabel({ name: "paseo", detail: ".dev/worktrees/zebra" })).toBe(
"paseo · .dev/worktrees/zebra",
);
});
});
describe("resolveImportTarget", () => {
it("trusts a scoped listing, whose rows the daemon already matched realpath-aware", () => {
expect(
resolveImportTarget({
entryCwd: "/private/repo/paseo",
workspaceCwd: "/repo/paseo",
workspaceId: "ws-1",
isScopedListing: true,
}),
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
});
it("keeps the current workspace for a Show-all row in that workspace's directory", () => {
expect(
resolveImportTarget({
entryCwd: "/repo/paseo/",
workspaceCwd: "/repo/paseo",
workspaceId: "ws-1",
isScopedListing: false,
}),
).toEqual({ workspaceId: "ws-1", crossWorkspace: false });
});
it("drops the workspace for a Show-all row from another directory, which the daemon rejects", () => {
expect(
resolveImportTarget({
entryCwd: "/repo/other",
workspaceCwd: "/repo/paseo",
workspaceId: "ws-1",
isScopedListing: false,
}),
).toEqual({ crossWorkspace: true });
});
it("treats a sheet with no workspace as cross-workspace", () => {
expect(resolveImportTarget({ entryCwd: "/repo/paseo", isScopedListing: false })).toEqual({
crossWorkspace: true,
});
});
});
describe("getSessionTitle", () => {
it("prefers the trimmed title", () => {
expect(getSessionTitle(entry({ title: " Importable " }))).toBe("Importable");
});
it("falls back to the trimmed first prompt preview when title is empty", () => {
expect(getSessionTitle(entry({ title: " ", firstPromptPreview: " Hello " }))).toBe("Hello");
});
it("falls back to Untitled session when both title and first prompt are blank", () => {
expect(getSessionTitle(entry({ title: null, firstPromptPreview: " " }))).toBe(
"Untitled session",
);
});
});
describe("getPromptPreview", () => {
it("prefers the trimmed last prompt preview", () => {
expect(
getPromptPreview(
entry({ lastPromptPreview: " later ", firstPromptPreview: " earlier " }),
),
).toBe("later");
});
it("falls back to the first prompt preview when last is blank", () => {
expect(
getPromptPreview(entry({ lastPromptPreview: " ", firstPromptPreview: " earlier " })),
).toBe("earlier");
});
it("falls back to a placeholder when both prompts are blank", () => {
expect(getPromptPreview(entry({ lastPromptPreview: null, firstPromptPreview: null }))).toBe(
"No prompt preview",
);
});
});
describe("computeEmptyState", () => {
const baseInputs = {
isLoadingSessions: false,
allQueriesErrored: false,
isQueryingProviders: true,
allQueriesSettled: true,
selectedProvider: ALL_FILTER_VALUE,
hasQuery: false,
aggregatedCount: 0,
visibleCount: 0,
totalAlreadyImportedCount: 0,
providerLabelById: new Map<string, string>(),
};
it("hides the empty state while sessions are still loading", () => {
const result = computeEmptyState({ ...baseInputs, isLoadingSessions: true });
expect(result.showEmptyState).toBe(false);
});
it("hides the empty state when every query errored", () => {
const result = computeEmptyState({ ...baseInputs, allQueriesErrored: true });
expect(result.showEmptyState).toBe(false);
});
it("hides the empty state until every provider query has settled", () => {
const result = computeEmptyState({ ...baseInputs, allQueriesSettled: false });
expect(result.showEmptyState).toBe(false);
});
it("hides the empty state when there are visible entries", () => {
const result = computeEmptyState({
...baseInputs,
aggregatedCount: 2,
visibleCount: 2,
});
expect(result.showEmptyState).toBe(false);
});
it("shows the default no-sessions message when nothing is loaded and nothing is filtered", () => {
const result = computeEmptyState(baseInputs);
expect(result).toEqual({
showEmptyState: true,
emptyStateTitle: "No recent sessions to import.",
});
});
it("says nothing matched when a query narrowed the list to nothing", () => {
const result = computeEmptyState({
...baseInputs,
hasQuery: true,
totalAlreadyImportedCount: 4,
});
expect(result.emptyStateTitle).toBe("No sessions match your search.");
});
it("shows the already-imported message when imported entries were filtered out", () => {
const result = computeEmptyState({
...baseInputs,
totalAlreadyImportedCount: 4,
});
expect(result.emptyStateTitle).toBe("All recent sessions are already imported.");
});
it("shows a provider-scoped message when a filter hides aggregated entries", () => {
const result = computeEmptyState({
...baseInputs,
selectedProvider: "claude",
aggregatedCount: 3,
providerLabelById: new Map([["claude", "Claude Code"]]),
});
expect(result.emptyStateTitle).toBe("No Claude Code sessions found.");
});
it("falls back to the provider id when the filtered provider lacks a label", () => {
const result = computeEmptyState({
...baseInputs,
selectedProvider: "z-ai",
aggregatedCount: 1,
});
expect(result.emptyStateTitle).toBe("No z-ai sessions found.");
});
});