mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 20:35:02 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65d0f270ad | ||
|
|
a331a945ee | ||
|
|
88a2a84f65 | ||
|
|
83708e21de | ||
|
|
090a1282c0 | ||
|
|
c431dd49c6 |
@@ -186,8 +186,7 @@ Drop a hunk when the installed LegendList ships its fix.
|
||||
|
||||
`modules/paseo-scroll` replaces React Native's scroll view on both platforms with a subclass:
|
||||
Android's `PaseoScrollView` and iOS's `PaseoScrollViewComponentView`.
|
||||
`VisibleContentPositionClampTest` and `SelectableTextTouchTest` cover Android; iOS has no native
|
||||
test harness.
|
||||
`VisibleContentPositionClampTest` covers Android; iOS has no native test harness.
|
||||
|
||||
**Content clamp.** A mount that shrinks the content while `maintainVisibleContentPosition` is set
|
||||
applies the shrink twice on both platforms: the scroll view clamps its offset to the new end during
|
||||
@@ -207,16 +206,5 @@ React Native 0.88 skips the adjustment for a removed anchor
|
||||
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
|
||||
Native counterpart; it stays while the timeline uses the pin.
|
||||
|
||||
**Touched text (Android).** Chat text is selectable, and Android focuses selectable text when it is
|
||||
touched. React Native scrolls a newly focused view fully into view, so tapping a link or a word, or
|
||||
long-pressing to select, in a message partly off screen moved the timeline by up to a screen.
|
||||
`PaseoScrollView` skips that scroll when the focused view is selectable, non-editable text in touch
|
||||
mode. The text keeps focus, and a streaming reply's new text collapses its selection to the start,
|
||||
which asks to reveal the reply's first line; that request is skipped too while nothing is selected.
|
||||
A change in the timeline's height, such as the composer below it growing, also reveals the focused
|
||||
view, and is skipped the same way. Inputs, focus moved by a keyboard, and a selection the user drags
|
||||
still scroll into view. The inverted timeline guarded this with a rule tied to the inversion, which
|
||||
went with it; keep this one while chat text is selectable.
|
||||
|
||||
Remove the content clamp and removed-anchor overrides once the installed React Native includes
|
||||
their fixes.
|
||||
|
||||
+4
-4
@@ -186,10 +186,10 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
## Scroll view corrections
|
||||
|
||||
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||
keeps React Native's scroll manager interface and corrects how the chat timeline holds its position.
|
||||
[agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections) lists the
|
||||
corrections and the React Native issues they work around. Its instrumentation tests run the scroll
|
||||
view in a real window, mounting through React Native's own mounting manager and injecting touches:
|
||||
keeps React Native's scroll manager interface and corrects `maintainVisibleContentPosition` for the
|
||||
chat timeline. [agent-stream-disclosure.md](agent-stream-disclosure.md#scroll-view-corrections)
|
||||
lists the corrections and the React Native issues they work around. Its instrumentation test,
|
||||
`VisibleContentPositionClampTest`, mounts through React Native's own mounting manager:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
@@ -65,6 +65,10 @@ Two escape hatches in the codebase:
|
||||
Android can detach the IME from an underlying TextInput. Fine for combobox
|
||||
(it has its own input) and tooltip (no input). **Not** fine for autocomplete
|
||||
(the composer's input must stay focused so the user keeps typing).
|
||||
Use `@/components/ui/modal`, not React Native's `Modal`. On Android, React Native lays out a
|
||||
modal's first frames at the screen size minus the status and navigation bars, then grows it to
|
||||
the full edge-to-edge window, so a full-screen backdrop stops short of the bottom and snaps
|
||||
down. The wrapper sizes the content to the window from the first frame.
|
||||
- **`<Portal>` from `@gorhom/portal`** (hover-card, autocomplete-popover) —
|
||||
hoists the React subtree to a fixed mount point whose bounds cover the
|
||||
screen. Same window, same IME, hit-test works because the new parent is
|
||||
@@ -422,9 +426,11 @@ gets that 0 and draws over the navigation bar.
|
||||
|
||||
- A Gorhom sheet reads the inset inside its children, which render in the app's sheet host. See
|
||||
`CompactSheetBody`, `MenuSheetScrollView` and `ComboboxSheetFrame`.
|
||||
- An RN `Modal` passes the call site's context straight through, so put a `SafeAreaProvider`
|
||||
inside it and read the inset below that. The modal is its own window, and the provider measures
|
||||
it. See `attachment-lightbox.tsx`.
|
||||
- An RN `Modal` passes the call site's context straight through, so wrap its content in
|
||||
`WindowSafeArea` from `@/window-layout` and read the inset below that. It provides the insets of
|
||||
the whole window, published by `WindowLayoutProvider`. Do not put a `SafeAreaProvider` inside the
|
||||
modal: on Android it measures the dialog window, whose bottom inset reads 24, then 0, then 24
|
||||
again over the first frames, and everything padded by it jumps. See `attachment-lightbox.tsx`.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
|
||||
@@ -95,11 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate
|
||||
Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position.
|
||||
|
||||
The daemon validates the epoch and locates the projected item at the selected position. A fork
|
||||
includes projected items through that checkpoint. A tool call that started before the checkpoint
|
||||
and changed after it, such as background work that outlives a reply, is copied as still running:
|
||||
the projection keeps only its latest payload, so its state at the checkpoint can't be read back.
|
||||
Plugin cards are not copied. A position from another epoch, or one that is gone, is refused;
|
||||
forking the current context remains available.
|
||||
includes projected items through that checkpoint. If an item the fork copies spans the checkpoint
|
||||
and changed afterward, the daemon refuses that fork with an actionable error: discarded historical
|
||||
payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card
|
||||
updated after the checkpoint does not block it. Forking the current context remains available.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
|
||||
@@ -787,13 +787,9 @@ test.describe("Composer autocomplete", () => {
|
||||
await input.press("ArrowDown");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
// On web the search reads the cursor after paint, so the input's value runs ahead of
|
||||
// the list. Wait on the list itself: /tdd is hidden only while the search is "h", and
|
||||
// back only once the search is "" again.
|
||||
await expect(popover.getByText("/tdd", { exact: true })).toHaveCount(0);
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
await input.press("Backspace");
|
||||
await expect(input).toHaveValue("/");
|
||||
await expect(popover.getByText("/tdd", { exact: true }).first()).toBeVisible();
|
||||
await input.press("Enter");
|
||||
|
||||
await expect(input).toHaveValue("/tdd ");
|
||||
|
||||
@@ -5,34 +5,27 @@ import path from "node:path";
|
||||
import type { Dialog, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import {
|
||||
archiveWorkspaceFromDaemon,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
createWorktreeViaDaemon,
|
||||
openProjectViaDaemon,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import { expectAppRoute } from "../support/helpers/route-assertions";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
clickArchiveWorkspaceMenuItem,
|
||||
expectWorkspaceAbsentFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import {
|
||||
rowArchiveButton,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
} from "../support/helpers/workspace-archive-undo";
|
||||
import {
|
||||
waitForSidebarHydration,
|
||||
waitForWorkspaceInSidebar,
|
||||
} from "../support/helpers/workspace-ui";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string): Promise<void> {
|
||||
async function seedRiskyWorktree(
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
worktreeDirectory: string,
|
||||
): Promise<void> {
|
||||
// The daemon only reports unpushed commits when the branch has a configured
|
||||
// upstream (aheadOfOrigin is computed against `branch.<name>.merge`). Push the
|
||||
// worktree branch at its current head first so it tracks origin with 0 ahead,
|
||||
@@ -62,189 +55,35 @@ async function seedRiskyWorktree(client: DaemonClient, worktreeDirectory: string
|
||||
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
|
||||
await writeFile(dirtyFile, "# dirty\n");
|
||||
|
||||
await refreshCheckout(client, worktreeDirectory);
|
||||
}
|
||||
|
||||
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
|
||||
const refreshed = await client.checkoutRefresh(directory);
|
||||
const refreshed = await client.checkoutRefresh(worktreeDirectory);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout for ${directory}`);
|
||||
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
|
||||
}
|
||||
}
|
||||
|
||||
// The archive confirmation is a synchronous web `window.confirm()` that opens once the app has
|
||||
// read the worktree's git state from the host. Wait for the dialog itself: on web the click does
|
||||
// not resolve until it is answered, so answer it from the event.
|
||||
async function answerArchiveWarning(
|
||||
// The archive confirmation is a synchronous web `window.confirm()`. The click that
|
||||
// opens it does not resolve until the dialog is answered, so the handler must
|
||||
// accept/dismiss inline — awaiting the dialog only *after* the click deadlocks, as
|
||||
// the click waits for an answer that is gated behind that same click.
|
||||
async function clickArchiveAndAnswerWarning(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
answer: "accept" | "dismiss",
|
||||
openArchive: () => Promise<void>,
|
||||
): Promise<Dialog> {
|
||||
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
|
||||
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
return dialog;
|
||||
let warning: Dialog | undefined;
|
||||
page.once("dialog", (dialog) => {
|
||||
warning = dialog;
|
||||
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
});
|
||||
await openArchive();
|
||||
return await warning;
|
||||
}
|
||||
|
||||
// A clean Paseo worktree's primary git action in the workspace header is Archive.
|
||||
function gitActionsArchiveButton(page: Page) {
|
||||
return page.getByTestId("changes-primary-cta").and(page.getByLabel("Archive workspace"));
|
||||
}
|
||||
|
||||
async function clickRowArchiveButton(page: Page, workspaceId: string): Promise<void> {
|
||||
await workspaceRow(page, workspaceId).hover();
|
||||
await rowArchiveButton(page, workspaceId).click();
|
||||
}
|
||||
|
||||
interface SessionEnvelope {
|
||||
type?: unknown;
|
||||
message?: {
|
||||
type?: unknown;
|
||||
workspaceId?: unknown;
|
||||
cwd?: unknown;
|
||||
payload?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
function parseSessionEnvelope(message: string): SessionEnvelope | null {
|
||||
try {
|
||||
return JSON.parse(message) as SessionEnvelope;
|
||||
} catch {
|
||||
return null;
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
if (!warning) {
|
||||
throw new Error("Expected an archive confirmation dialog, but none was shown.");
|
||||
}
|
||||
}
|
||||
|
||||
function removeGitStatusFromDescriptors(value: unknown, workspaceId: string): number {
|
||||
if (Array.isArray(value)) {
|
||||
return value.reduce<number>(
|
||||
(count, item) => count + removeGitStatusFromDescriptors(item, workspaceId),
|
||||
0,
|
||||
);
|
||||
}
|
||||
if (!value || typeof value !== "object") return 0;
|
||||
const record = value as Record<string, unknown>;
|
||||
let count = 0;
|
||||
if (record.id === workspaceId && "workspaceDirectory" in record) {
|
||||
delete record.gitRuntime;
|
||||
record.diffStat = null;
|
||||
count += 1;
|
||||
}
|
||||
for (const child of Object.values(record)) {
|
||||
count += removeGitStatusFromDescriptors(child, workspaceId);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays the app's connection to the daemon. Every workspace descriptor for `workspaceId` reaches
|
||||
* the app without git status, as the host sends it before its first checkout snapshot of the
|
||||
* worktree, so the sidebar row has nothing cached to warn from. `failCheckoutStatusFor` turns the
|
||||
* host's checkout status answer for that directory into a git error.
|
||||
*/
|
||||
async function relayDaemon(
|
||||
page: Page,
|
||||
input: { workspaceId: string; failCheckoutStatusFor?: string },
|
||||
) {
|
||||
let descriptorRewrites = 0;
|
||||
let archiveRequests = 0;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
if (typeof message === "string") {
|
||||
const request = parseSessionEnvelope(message)?.message;
|
||||
if (
|
||||
request?.type === "archive_workspace_request" &&
|
||||
request.workspaceId === input.workspaceId
|
||||
) {
|
||||
archiveRequests += 1;
|
||||
}
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => {
|
||||
if (typeof message !== "string") {
|
||||
browser.send(message);
|
||||
return;
|
||||
}
|
||||
const envelope = parseSessionEnvelope(message);
|
||||
const response = envelope?.message;
|
||||
if (
|
||||
input.failCheckoutStatusFor &&
|
||||
response?.type === "checkout_status_response" &&
|
||||
response.payload?.cwd === input.failCheckoutStatusFor
|
||||
) {
|
||||
response.payload = {
|
||||
...response.payload,
|
||||
isGit: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
isDirty: null,
|
||||
baseRef: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
error: { code: "UNKNOWN", message: "git status failed" },
|
||||
};
|
||||
browser.send(JSON.stringify(envelope));
|
||||
return;
|
||||
}
|
||||
if (!envelope || !message.includes(input.workspaceId)) {
|
||||
browser.send(message);
|
||||
return;
|
||||
}
|
||||
descriptorRewrites += removeGitStatusFromDescriptors(envelope, input.workspaceId);
|
||||
browser.send(JSON.stringify(envelope));
|
||||
});
|
||||
});
|
||||
return {
|
||||
descriptorRewrites: () => descriptorRewrites,
|
||||
archiveRequests: () => archiveRequests,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the app's next checkout status request for `directory` once armed, so a test can act
|
||||
* while the archive waits for the host's answer.
|
||||
*/
|
||||
async function holdCheckoutStatusRequest(page: Page, directory: string) {
|
||||
let armed = false;
|
||||
let held: (() => void) | null = null;
|
||||
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
|
||||
const server = browser.connectToServer();
|
||||
browser.onMessage((message) => {
|
||||
const request = typeof message === "string" ? parseSessionEnvelope(message)?.message : null;
|
||||
if (
|
||||
armed &&
|
||||
!held &&
|
||||
request?.type === "checkout_status_request" &&
|
||||
request.cwd === directory
|
||||
) {
|
||||
held = () => server.send(message);
|
||||
return;
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
server.onMessage((message) => browser.send(message));
|
||||
});
|
||||
return {
|
||||
arm: () => {
|
||||
armed = true;
|
||||
},
|
||||
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
|
||||
release: () => {
|
||||
if (!held) throw new Error("No checkout status request is held");
|
||||
held();
|
||||
},
|
||||
};
|
||||
return warning;
|
||||
}
|
||||
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: DaemonClient;
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
@@ -264,53 +103,36 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
async function createWorktree(slug: string) {
|
||||
const project = await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `${slug}-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
return { ...worktree, project };
|
||||
}
|
||||
|
||||
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
|
||||
}
|
||||
|
||||
async function expectWorktreeKept(page: Page, worktree: { workspaceId: string }) {
|
||||
await expect(workspaceRow(page, worktree.workspaceId)).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function hostListsWorkspace(workspaceId: string): Promise<boolean> {
|
||||
const { entries } = await client.fetchWorkspaces();
|
||||
return entries.some((entry) => entry.id === workspaceId);
|
||||
}
|
||||
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `archive-risk-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
const firstWarning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
|
||||
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
|
||||
expect(firstWarning.type()).toBe("confirm");
|
||||
expect(firstWarning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(firstWarning.message()).toContain("Uncommitted changes");
|
||||
expect(firstWarning.message()).toContain("1 unpushed commit");
|
||||
|
||||
await expectWorktreeKept(page, worktree);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
const secondWarning = await answerArchiveWarning(page, "accept", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
|
||||
expect(secondWarning.message()).toContain("Uncommitted changes");
|
||||
expect(secondWarning.message()).toContain("1 unpushed commit");
|
||||
|
||||
@@ -321,128 +143,4 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
|
||||
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
|
||||
});
|
||||
|
||||
test("the row's archive button warns from the host's git state while the row has none, and Cancel keeps the worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk-uncached");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
const relay = await relayDaemon(page, { workspaceId: worktree.workspaceId });
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
expect(relay.descriptorRewrites()).toBeGreaterThan(0);
|
||||
|
||||
const warning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(warning.message()).toContain("Uncommitted changes");
|
||||
expect(warning.message()).toContain("1 unpushed commit");
|
||||
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(relay.archiveRequests()).toBe(0);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "DIRTY.md"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a worktree holding only new files warns when the repository hides untracked files", async ({
|
||||
page,
|
||||
}) => {
|
||||
execSync("git config status.showUntrackedFiles no", { cwd: tempRepo.path, stdio: "ignore" });
|
||||
const worktree = await createWorktree("archive-risk-untracked");
|
||||
await writeFile(path.join(worktree.workspaceDirectory, "NEW.md"), "# not yet added\n");
|
||||
await refreshCheckout(client, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
|
||||
const warning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(warning.message()).toContain("Uncommitted changes");
|
||||
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "NEW.md"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a failed git read shows its error and keeps the worktree", async ({ page }) => {
|
||||
const worktree = await createWorktree("archive-risk-read-failure");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
const relay = await relayDaemon(page, {
|
||||
workspaceId: worktree.workspaceId,
|
||||
failCheckoutStatusFor: worktree.workspaceDirectory,
|
||||
});
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
|
||||
await clickRowArchiveButton(page, worktree.workspaceId);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText("git status failed");
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(relay.archiveRequests()).toBe(0);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
});
|
||||
|
||||
// Archives the open worktree with `startArchive`, opens the project's main workspace while the
|
||||
// host's checkout status answer is held, then lets the answer through.
|
||||
async function archiveWhileOpeningAnotherWorkspace(
|
||||
page: Page,
|
||||
slug: string,
|
||||
startArchive: (worktree: { workspaceId: string }) => Promise<void>,
|
||||
): Promise<string> {
|
||||
const serverId = getServerId();
|
||||
const worktree = await createWorktree(slug);
|
||||
const statusRequest = await holdCheckoutStatusRequest(page, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(serverId, worktree.workspaceId));
|
||||
|
||||
statusRequest.arm();
|
||||
await startArchive(worktree);
|
||||
await statusRequest.waitForHeld();
|
||||
await workspaceRow(page, worktree.project.workspaceId).click();
|
||||
const openedRoute = buildHostWorkspaceRoute(serverId, worktree.project.workspaceId);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
|
||||
statusRequest.release();
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
// The redirect runs before the archive request, so once the host has archived the worktree
|
||||
// any redirect has already happened.
|
||||
await expect
|
||||
.poll(() => hostListsWorkspace(worktree.workspaceId), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
return openedRoute;
|
||||
}
|
||||
|
||||
test("opening another workspace while the host answers keeps it open after a row archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
|
||||
page,
|
||||
"archive-risk-switch-row",
|
||||
(worktree) => clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
});
|
||||
|
||||
test("opening another workspace while the host answers keeps it open after a git actions archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
|
||||
page,
|
||||
"archive-risk-switch-git",
|
||||
() => gitActionsArchiveButton(page).click(),
|
||||
);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
});
|
||||
|
||||
test("archiving the open worktree from its git actions leaves it for a new workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk-git-redirect");
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), worktree.workspaceId));
|
||||
|
||||
await gitActionsArchiveButton(page).click();
|
||||
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
-282
@@ -1,282 +0,0 @@
|
||||
package sh.paseo.scroll
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.os.SystemClock
|
||||
import android.text.InputType
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewConfiguration
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputConnection
|
||||
import android.widget.EditText
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.facebook.react.views.scroll.ReactScrollView
|
||||
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||
import com.facebook.react.views.view.ReactViewGroup
|
||||
import com.facebook.soloader.SoLoader
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
/**
|
||||
* Touches on chat text in the timeline's scroll view. Chat text is selectable, so a tap or a long
|
||||
* press focuses it, and the focus may not scroll the timeline. The touches are injected into a real
|
||||
* window, so touch mode, focus and the selection controller run as on device.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SelectableTextTouchTest {
|
||||
private val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
private var activity: Activity? = null
|
||||
|
||||
@After fun finish() {
|
||||
activity?.finish()
|
||||
}
|
||||
|
||||
// A reply whose top is on screen and whose bottom is below the viewport.
|
||||
@Test fun tappingTextThatRunsBelowTheViewportKeepsTheOffset() {
|
||||
val timeline = openTimeline(edge = Edge.TOP)
|
||||
|
||||
timeline.tap(visibleY = EDGE_Y + 100)
|
||||
|
||||
assertTrue("The tap focuses the text", timeline.textHasFocus())
|
||||
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
|
||||
}
|
||||
|
||||
// A reply whose bottom is on screen and whose top is above the viewport, like a link at its end.
|
||||
@Test fun tappingTextThatStartsAboveTheViewportKeepsTheOffset() {
|
||||
val timeline = openTimeline(edge = Edge.BOTTOM)
|
||||
|
||||
timeline.tap(visibleY = EDGE_Y - 100)
|
||||
|
||||
assertTrue("The tap focuses the text", timeline.textHasFocus())
|
||||
assertEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
|
||||
}
|
||||
|
||||
// A reply that is still streaming gets new text while the user's tap holds its focus.
|
||||
@Test fun aStreamingUpdateToTappedTextKeepsTheOffset() {
|
||||
val timeline = openTimeline(edge = Edge.BOTTOM)
|
||||
timeline.tap(visibleY = EDGE_Y - 100)
|
||||
assertTrue("The tap focuses the text", timeline.textHasFocus())
|
||||
|
||||
timeline.updateText("$CHAT_TEXT\nOne more line of the reply.")
|
||||
|
||||
assertEquals("Offset after the update", OFFSET, timeline.settledScrollY())
|
||||
}
|
||||
|
||||
// The timeline changes height while tapped text holds focus, as when the composer above the
|
||||
// keyboard loses its queued message.
|
||||
@Test fun aTimelineResizeAfterATapKeepsTheOffset() {
|
||||
val timeline = openTimeline(edge = Edge.TOP)
|
||||
timeline.tap(visibleY = EDGE_Y + 100)
|
||||
assertTrue("The tap focuses the text", timeline.textHasFocus())
|
||||
|
||||
timeline.resize(height = VIEWPORT + 200)
|
||||
|
||||
assertEquals("Offset after the resize", OFFSET, timeline.settledScrollY())
|
||||
}
|
||||
|
||||
@Test fun longPressSelectsAWordAndKeepsTheOffset() {
|
||||
val timeline = openTimeline(edge = Edge.TOP)
|
||||
|
||||
val word = timeline.wordAt(visibleY = EDGE_Y + 100)
|
||||
|
||||
timeline.longPress(visibleY = EDGE_Y + 100)
|
||||
|
||||
assertEquals("Selected text", word, timeline.selectedText())
|
||||
assertEquals("Offset after the long press", OFFSET, timeline.settledScrollY())
|
||||
}
|
||||
|
||||
// Inputs in the timeline, such as an agent's question form, still scroll into view to be typed in.
|
||||
@Test fun tappingAnInputThatRunsBelowTheViewportRevealsIt() {
|
||||
val timeline = openTimeline(edge = Edge.TOP, editable = true)
|
||||
|
||||
timeline.tap(visibleY = EDGE_Y + 100)
|
||||
|
||||
assertTrue("The tap focuses the input", timeline.textHasFocus())
|
||||
assertNotEquals("Offset after the tap", OFFSET, timeline.settledScrollY())
|
||||
}
|
||||
|
||||
private inner class Timeline(private val scroll: ReactScrollView, private val text: TextView) {
|
||||
fun scrollTo(y: Int) {
|
||||
instrumentation.runOnMainSync { scroll.scrollTo(0, y) }
|
||||
awaitFrames(2)
|
||||
}
|
||||
|
||||
fun tap(visibleY: Int) {
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
|
||||
touch(downTime, MotionEvent.ACTION_UP, visibleY)
|
||||
}
|
||||
|
||||
fun longPress(visibleY: Int) {
|
||||
val downTime = SystemClock.uptimeMillis()
|
||||
touch(downTime, MotionEvent.ACTION_DOWN, visibleY)
|
||||
SystemClock.sleep(ViewConfiguration.getLongPressTimeout() * 2L)
|
||||
touch(downTime, MotionEvent.ACTION_UP, visibleY)
|
||||
}
|
||||
|
||||
fun resize(height: Int) {
|
||||
instrumentation.runOnMainSync {
|
||||
scroll.layoutParams = scroll.layoutParams.apply { this.height = height }
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces the text and lays it out again at its top, the way a streaming reply mounts.
|
||||
fun updateText(value: String) {
|
||||
instrumentation.runOnMainSync {
|
||||
text.setText(value)
|
||||
text.measure(exact(WIDTH), unspecified())
|
||||
text.layout(0, text.top, WIDTH, text.top + text.measuredHeight)
|
||||
}
|
||||
}
|
||||
|
||||
fun textHasFocus(): Boolean {
|
||||
awaitFrames(2)
|
||||
var focused = false
|
||||
instrumentation.runOnMainSync { focused = text.isFocused }
|
||||
return focused
|
||||
}
|
||||
|
||||
fun selectedText(): String {
|
||||
awaitFrames(2)
|
||||
var selected = ""
|
||||
instrumentation.runOnMainSync {
|
||||
selected = text.text.substring(text.selectionStart, text.selectionEnd)
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
/** The word under [visibleY] at the current offset. */
|
||||
fun wordAt(visibleY: Int): String {
|
||||
var word = ""
|
||||
instrumentation.runOnMainSync {
|
||||
val chars = text.text
|
||||
val y = scroll.scrollY + visibleY - text.top
|
||||
val offset = text.getOffsetForPosition(TOUCH_X - text.left, y.toFloat())
|
||||
var start = offset
|
||||
var end = offset
|
||||
while (start > 0 && chars[start - 1].isLetterOrDigit()) start--
|
||||
while (end < chars.length && chars[end].isLetterOrDigit()) end++
|
||||
word = chars.substring(start, end)
|
||||
}
|
||||
assertTrue("The touch lands on a word", word.isNotEmpty())
|
||||
return word
|
||||
}
|
||||
|
||||
fun settledScrollY(): Int {
|
||||
var previous = scrollY()
|
||||
repeat(60) {
|
||||
awaitFrames(3)
|
||||
val current = scrollY()
|
||||
if (current == previous) return current
|
||||
previous = current
|
||||
}
|
||||
throw AssertionError("Offset never settled")
|
||||
}
|
||||
|
||||
private fun scrollY(): Int {
|
||||
var y = 0
|
||||
instrumentation.runOnMainSync { y = scroll.scrollY }
|
||||
return y
|
||||
}
|
||||
|
||||
// Injected through the window like a finger, which puts the window in touch mode.
|
||||
private fun touch(downTime: Long, action: Int, visibleY: Int) {
|
||||
val location = IntArray(2)
|
||||
instrumentation.runOnMainSync { scroll.getLocationOnScreen(location) }
|
||||
val x = location[0] + TOUCH_X
|
||||
val y = location[1] + visibleY.toFloat()
|
||||
val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, x, y, 0)
|
||||
instrumentation.sendPointerSync(event)
|
||||
event.recycle()
|
||||
}
|
||||
|
||||
fun awaitFrames(count: Int) {
|
||||
repeat(count) {
|
||||
instrumentation.waitForIdleSync()
|
||||
SystemClock.sleep(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which edge of the text is on screen, at [EDGE_Y]; the other is past the viewport. */
|
||||
private enum class Edge { TOP, BOTTOM }
|
||||
|
||||
/** A timeline scrolled to [OFFSET] with one block of chat text taller than the viewport. */
|
||||
private fun openTimeline(edge: Edge, editable: Boolean = false): Timeline {
|
||||
val started =
|
||||
instrumentation.startActivitySync(
|
||||
Intent(instrumentation.targetContext, Activity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
)
|
||||
activity = started
|
||||
lateinit var timeline: Timeline
|
||||
instrumentation.runOnMainSync {
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(started)
|
||||
SoLoader.init(started, OpenSourceMergedSoMapping)
|
||||
val reactContext = MountingReactContext(started.applicationContext)
|
||||
val context = ThemedReactContext(reactContext, started)
|
||||
val manager =
|
||||
PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS)
|
||||
as ReactScrollViewManager
|
||||
val scroll = manager.createViewInstance(context)
|
||||
val content = ReactViewGroup(context)
|
||||
val text =
|
||||
if (editable) {
|
||||
// Selectable, as React Native's TextInput makes itself when attached, so only being a text
|
||||
// editor tells it apart from chat text. No input connection: building one runs React
|
||||
// Native's focus search through Fabric, which this context does not run.
|
||||
object : EditText(context) {
|
||||
override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? = null
|
||||
}.apply {
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_MULTI_LINE
|
||||
setTextIsSelectable(true)
|
||||
}
|
||||
} else {
|
||||
// How enriched-markdown and React Native's <Text selectable> set up chat text.
|
||||
TextView(context).apply { setTextIsSelectable(true) }
|
||||
}
|
||||
text.setText(CHAT_TEXT)
|
||||
content.addView(text)
|
||||
scroll.addView(content)
|
||||
started.setContentView(
|
||||
FrameLayout(started).apply { addView(scroll, FrameLayout.LayoutParams(WIDTH, VIEWPORT)) }
|
||||
)
|
||||
// Fabric lays out React content; nothing in a React view group lays out its children itself.
|
||||
content.layout(0, 0, WIDTH, CONTENT_HEIGHT)
|
||||
text.measure(exact(WIDTH), unspecified())
|
||||
assertTrue("The text is taller than the viewport", text.measuredHeight > VIEWPORT)
|
||||
val top = if (edge == Edge.TOP) OFFSET + EDGE_Y else OFFSET + EDGE_Y - text.measuredHeight
|
||||
text.layout(0, top, WIDTH, top + text.measuredHeight)
|
||||
timeline = Timeline(scroll, text)
|
||||
}
|
||||
timeline.awaitFrames(2)
|
||||
timeline.scrollTo(OFFSET)
|
||||
assertEquals("Starts at the offset", OFFSET, timeline.settledScrollY())
|
||||
return timeline
|
||||
}
|
||||
|
||||
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||
|
||||
private fun unspecified() = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||
|
||||
private companion object {
|
||||
const val WIDTH = 600
|
||||
const val TOUCH_X = WIDTH / 2f
|
||||
const val VIEWPORT = 800
|
||||
const val CONTENT_HEIGHT = 6000
|
||||
const val OFFSET = 2400
|
||||
// Below the plain activity's action bar, which overlays the top of the window.
|
||||
const val EDGE_Y = 500
|
||||
val CHAT_TEXT = List(30) { "Line $it of a reply in the chat timeline." }.joinToString("\n")
|
||||
}
|
||||
}
|
||||
+1
-54
@@ -1,12 +1,10 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import android.graphics.Rect;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.view.ViewTreeObserver;
|
||||
import android.widget.TextView;
|
||||
import androidx.annotation.Nullable;
|
||||
import com.facebook.react.R;
|
||||
import com.facebook.react.views.scroll.MaintainVisibleScrollPositionHelper;
|
||||
@@ -14,7 +12,7 @@ import com.facebook.react.views.scroll.ReactScrollView;
|
||||
import com.facebook.react.views.scroll.ReactScrollViewHelper;
|
||||
|
||||
/**
|
||||
* React Native's vertical scroll view with five corrections. Java, because the content-position
|
||||
* React Native's vertical scroll view with two corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
@@ -29,7 +27,6 @@ final class PaseoScrollView extends ReactScrollView {
|
||||
private @Nullable MaintainVisibleScrollPositionHelper.Config visibleContentPosition = null;
|
||||
private boolean isClampPending = false;
|
||||
private boolean isUserDragging = false;
|
||||
private boolean isFocusRevealSkipped = false;
|
||||
|
||||
PaseoScrollView(Context context) {
|
||||
super(context);
|
||||
@@ -54,56 +51,6 @@ final class PaseoScrollView extends ReactScrollView {
|
||||
super.scrollToPreservingMomentum(x, y);
|
||||
}
|
||||
|
||||
// Chat text is selectable (enriched-markdown's text views and <Text selectable>), and selectable
|
||||
// text takes focus when touched: a tap on a link, a word, or a long-press to select. React Native
|
||||
// scrolls a newly focused child fully into view, so touching a reply that is partly off screen
|
||||
// scrolled the timeline by up to a screen under the user's finger. Text the user touched is
|
||||
// already where they are looking, so its focus does not scroll. Inputs, and focus moved by a
|
||||
// keyboard, still scroll into view.
|
||||
@Override
|
||||
public void requestChildFocus(View child, View focused) {
|
||||
if (isTouchedReadOnlyText(focused)) {
|
||||
requestChildFocusWithoutScroll(child, focused);
|
||||
return;
|
||||
}
|
||||
super.requestChildFocus(child, focused);
|
||||
}
|
||||
|
||||
// Focused text asks to reveal its selection whenever the selection changes. A streaming reply
|
||||
// replaces its text, which collapses the selection to the start, so a reply that kept focus from
|
||||
// a touch scrolled the timeline back to its first line on the next update. Read-only text has no
|
||||
// caret, so a collapsed selection has nothing to reveal. A selection the user drags still does.
|
||||
@Override
|
||||
public boolean requestChildRectangleOnScreen(View child, Rect rectangle, boolean immediate) {
|
||||
View focused = findFocus();
|
||||
if (isTouchedReadOnlyText(focused) && !((TextView) focused).hasSelection()) return false;
|
||||
return super.requestChildRectangleOnScreen(child, rectangle, immediate);
|
||||
}
|
||||
|
||||
// When the scroll view changes height, Android scrolls the focused view into view again. Touched
|
||||
// chat text keeps focus, so the composer growing or shrinking below the timeline realigned it.
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
isFocusRevealSkipped = isTouchedReadOnlyText(findFocus());
|
||||
try {
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
} finally {
|
||||
isFocusRevealSkipped = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int computeScrollDeltaToGetChildRectOnScreen(Rect rect) {
|
||||
if (isFocusRevealSkipped) return 0;
|
||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect);
|
||||
}
|
||||
|
||||
private static boolean isTouchedReadOnlyText(@Nullable View view) {
|
||||
if (!(view instanceof TextView) || !view.isInTouchMode()) return false;
|
||||
TextView text = (TextView) view;
|
||||
return text.isTextSelectable() && !text.onCheckIsTextEditor();
|
||||
}
|
||||
|
||||
// The drag begins where React Native emits onScrollBeginDrag.
|
||||
@Override
|
||||
protected void handleInterceptedTouchEvent(MotionEvent ev) {
|
||||
|
||||
@@ -39,6 +39,21 @@ function createDeferred<T>() {
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a pending lookup and waits until React Query has delivered the result. It delivers on a
|
||||
* timer, which would otherwise fire after the test, when jsdom is gone.
|
||||
*/
|
||||
async function resolveLookupAndDeliver(
|
||||
lookup: ReturnType<typeof createDeferred<DirectorySuggestionResult>>,
|
||||
result: DirectorySuggestionResult,
|
||||
): Promise<void> {
|
||||
await act(async () => {
|
||||
lookup.resolve(result);
|
||||
await lookup.promise;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
interface OpenedFile {
|
||||
target: InlinePathTarget;
|
||||
disposition: OpenFileDisposition;
|
||||
@@ -322,11 +337,12 @@ describe("useFileLink", () => {
|
||||
act(() => {
|
||||
result.current.setWorkspaceRoot("/Users/test/other");
|
||||
});
|
||||
deferred.resolve(resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]));
|
||||
await resolveLookupAndDeliver(
|
||||
deferred,
|
||||
resolvedSuggestions([{ path: "docs/dumm.md", kind: "file" }]),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(getDirectorySuggestions).toHaveBeenCalledTimes(1);
|
||||
expect(openedFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { persistAttachmentFromBlob } from "@/attachments/service";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { type AttachmentPreviewUrl, useAttachmentPreviewUrl } from "./use-attachment-preview-url";
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
let latest: AttachmentPreviewUrl | null = null;
|
||||
|
||||
function PreviewProbe({ attachment }: { attachment: AttachmentMetadata | null }) {
|
||||
latest = useAttachmentPreviewUrl(attachment);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function render(attachment: AttachmentMetadata | null) {
|
||||
await act(async () => root.render(<PreviewProbe attachment={attachment} />));
|
||||
}
|
||||
|
||||
async function waitForReady(): Promise<string> {
|
||||
await vi.waitFor(() => expect(latest?.status).toBe("ready"));
|
||||
if (latest?.status !== "ready") throw new Error("preview never resolved");
|
||||
return latest.url;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("React", React);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
latest = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("never hands back a URL it already released when the same attachment returns", async () => {
|
||||
const attachment = await persistAttachmentFromBlob({
|
||||
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
|
||||
mimeType: "image/png",
|
||||
fileName: "shot.png",
|
||||
});
|
||||
|
||||
await render(attachment);
|
||||
const released = await waitForReady();
|
||||
await render(null);
|
||||
await render(attachment);
|
||||
|
||||
expect(latest).not.toEqual({ status: "ready", url: released });
|
||||
expect(await waitForReady()).not.toBe(released);
|
||||
});
|
||||
|
||||
it("retries a failed attachment from loading when it returns", async () => {
|
||||
const stored = await persistAttachmentFromBlob({
|
||||
blob: new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" }),
|
||||
mimeType: "image/png",
|
||||
fileName: "shot.png",
|
||||
});
|
||||
const missing = { ...stored, id: "missing-attachment", storageKey: "missing-attachment" };
|
||||
|
||||
await render(missing);
|
||||
await vi.waitFor(() => expect(latest?.status).toBe("failed"));
|
||||
await render(null);
|
||||
await render(missing);
|
||||
|
||||
expect(latest).toEqual({ status: "loading" });
|
||||
});
|
||||
@@ -2,62 +2,75 @@ import { useEffect, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { releaseAttachmentPreviewUrl, resolveAttachmentPreviewUrl } from "@/attachments/service";
|
||||
|
||||
export type AttachmentPreviewUrl =
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; url: string }
|
||||
| { status: "failed" };
|
||||
|
||||
const LOADING: AttachmentPreviewUrl = { status: "loading" };
|
||||
const FAILED: AttachmentPreviewUrl = { status: "failed" };
|
||||
|
||||
function attachmentKey(attachment: AttachmentMetadata | null | undefined): string | null {
|
||||
if (!attachment) return null;
|
||||
return [attachment.id, attachment.storageType, attachment.storageKey, attachment.mimeType].join(
|
||||
"\u0000",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a displayable URL for a stored attachment. Stays `loading` until the URL for the
|
||||
* current attachment resolves, so a caller never shows a failure, or the previous attachment's
|
||||
* URL, while the next one is still resolving. A missing attachment stays `loading`.
|
||||
*/
|
||||
export function useAttachmentPreviewUrl(
|
||||
attachment: AttachmentMetadata | null | undefined,
|
||||
): string | null {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const activeAttachmentRef = useRef<AttachmentMetadata | null>(null);
|
||||
): AttachmentPreviewUrl {
|
||||
const [resolved, setResolved] = useState<{
|
||||
key: string;
|
||||
preview: AttachmentPreviewUrl;
|
||||
} | null>(null);
|
||||
const attachmentRef = useRef(attachment);
|
||||
attachmentRef.current = attachment;
|
||||
|
||||
const id = attachment?.id;
|
||||
const storageType = attachment?.storageType;
|
||||
const storageKey = attachment?.storageKey;
|
||||
const mimeType = attachment?.mimeType;
|
||||
const key = attachmentKey(attachment);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let currentUrl: string | null = null;
|
||||
const current = attachmentRef.current;
|
||||
|
||||
activeAttachmentRef.current = current ?? null;
|
||||
if (!current) {
|
||||
setUrl(null);
|
||||
if (!current || key === null) {
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
let currentUrl: string | null = null;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveAttachmentPreviewUrl(current);
|
||||
const url = await resolveAttachmentPreviewUrl(current);
|
||||
if (disposed) {
|
||||
await releaseAttachmentPreviewUrl({ attachment: current, url: resolved });
|
||||
await releaseAttachmentPreviewUrl({ attachment: current, url });
|
||||
return;
|
||||
}
|
||||
currentUrl = resolved;
|
||||
setUrl(resolved);
|
||||
currentUrl = url;
|
||||
setResolved({ key, preview: { status: "ready", url } });
|
||||
} catch (error) {
|
||||
console.error("[attachments] Failed to resolve preview URL", {
|
||||
attachmentId: current.id,
|
||||
error,
|
||||
});
|
||||
if (!disposed) {
|
||||
setUrl(null);
|
||||
setResolved({ key, preview: FAILED });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
const activeAttachment = activeAttachmentRef.current;
|
||||
if (!currentUrl || !activeAttachment) {
|
||||
return;
|
||||
if (currentUrl) {
|
||||
void releaseAttachmentPreviewUrl({ attachment: current, url: currentUrl });
|
||||
}
|
||||
void releaseAttachmentPreviewUrl({
|
||||
attachment: activeAttachment,
|
||||
url: currentUrl,
|
||||
});
|
||||
// A result lives only as long as its lookup. Kept, the same attachment coming back would
|
||||
// read a released URL, or an old failure, until the next lookup lands.
|
||||
setResolved((previous) => (previous?.key === key ? null : previous));
|
||||
};
|
||||
}, [id, storageType, storageKey, mimeType]);
|
||||
}, [key]);
|
||||
|
||||
return url;
|
||||
return resolved !== null && resolved.key === key ? resolved.preview : LOADING;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { persistAttachmentFromBlob } from "@/attachments/service";
|
||||
import { SafeAreaProvider } from "react-native-safe-area-context";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { WindowLayoutProvider } from "@/window-layout";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
|
||||
interface FrameSample {
|
||||
image: { x: number; y: number; width: number; height: number } | null;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<number> {
|
||||
return new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
}
|
||||
|
||||
function createPng(width: number, height: number): Promise<Blob> {
|
||||
const canvas = new OffscreenCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 2D context unavailable");
|
||||
context.fillStyle = "#3a7";
|
||||
context.fillRect(0, 0, width, height);
|
||||
return canvas.convertToBlob({ type: "image/png" });
|
||||
}
|
||||
|
||||
function noop() {}
|
||||
|
||||
function isPainted(element: Element): boolean {
|
||||
for (let node: Element | null = element; node; node = node.parentElement) {
|
||||
if (Number(getComputedStyle(node).opacity) === 0) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What the next paint will show: the visible lightbox image box, and whether the error is up. */
|
||||
function sampleFrame(): FrameSample {
|
||||
const image = document.querySelector('[data-testid="attachment-lightbox-image"]');
|
||||
const rect = image?.getBoundingClientRect();
|
||||
const visible = image && rect && rect.width > 0 && rect.height > 0 && isPainted(image);
|
||||
return {
|
||||
image: visible
|
||||
? {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height),
|
||||
}
|
||||
: null,
|
||||
error: (document.body.textContent ?? "").includes("Couldn't load image"),
|
||||
};
|
||||
}
|
||||
|
||||
/** Samples every frame from the open until the image has held one box for 20 frames. */
|
||||
async function recordOpening(open: () => void): Promise<FrameSample[]> {
|
||||
const samples: FrameSample[] = [];
|
||||
open();
|
||||
let stableFrames = 0;
|
||||
for (let frame = 0; frame < 300 && stableFrames < 20; frame += 1) {
|
||||
await nextFrame();
|
||||
const sample = sampleFrame();
|
||||
const previous = samples.at(-1);
|
||||
stableFrames =
|
||||
sample.image && JSON.stringify(sample.image) === JSON.stringify(previous?.image)
|
||||
? stableFrames + 1
|
||||
: 0;
|
||||
samples.push(sample);
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal("React", React);
|
||||
await i18n.changeLanguage("en");
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function renderLightbox(source: ImageLightboxSource | null) {
|
||||
root.render(
|
||||
<SafeAreaProvider>
|
||||
<WindowLayoutProvider>
|
||||
<AttachmentLightbox source={source} onClose={noop} />
|
||||
</WindowLayoutProvider>
|
||||
</SafeAreaProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectOpeningPaintsOnlyTheFittedImage(source: ImageLightboxSource) {
|
||||
const samples = await recordOpening(() => renderLightbox(source));
|
||||
const fitted = samples.at(-1)?.image;
|
||||
|
||||
expect(fitted, "the image never settled").toBeTruthy();
|
||||
expect(samples.filter((sample) => sample.error)).toEqual([]);
|
||||
expect(
|
||||
samples.filter(
|
||||
(sample) => sample.image && JSON.stringify(sample.image) !== JSON.stringify(fitted),
|
||||
),
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
describe("AttachmentLightbox opening in the browser", () => {
|
||||
it("paints the fitted attachment image as its first image frame", async () => {
|
||||
const metadata = await persistAttachmentFromBlob({
|
||||
blob: await createPng(2400, 1600),
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "attachment", metadata });
|
||||
});
|
||||
|
||||
it("paints only the fitted image when the same attachment opens again", async () => {
|
||||
const metadata = await persistAttachmentFromBlob({
|
||||
blob: await createPng(2400, 1600),
|
||||
mimeType: "image/png",
|
||||
fileName: "screenshot.png",
|
||||
});
|
||||
const source: ImageLightboxSource = { type: "attachment", metadata };
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage(source);
|
||||
await act(async () => renderLightbox(null));
|
||||
await expectOpeningPaintsOnlyTheFittedImage(source);
|
||||
});
|
||||
|
||||
it("shows an image the browser reports no natural size for", async () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0"></svg>';
|
||||
const uri = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
|
||||
});
|
||||
|
||||
it("paints the fitted timeline image as its first image frame", async () => {
|
||||
const uri = URL.createObjectURL(await createPng(2400, 1600));
|
||||
|
||||
await expectOpeningPaintsOnlyTheFittedImage({ type: "uri", uri });
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { AttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { AttachmentLightbox, type ImageLightboxSource } from "./attachment-lightbox";
|
||||
|
||||
const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
@@ -38,9 +39,9 @@ const { theme, imageMetadata, useAttachmentPreviewUrlMock } = vi.hoisted(() => {
|
||||
return {
|
||||
theme: hoistedTheme,
|
||||
imageMetadata: hoistedImageMetadata,
|
||||
useAttachmentPreviewUrlMock: vi.fn<(metadata: AttachmentMetadata | null) => string | null>(
|
||||
() => "blob:preview",
|
||||
),
|
||||
useAttachmentPreviewUrlMock: vi.fn<
|
||||
(metadata: AttachmentMetadata | null) => AttachmentPreviewUrl
|
||||
>(() => ({ status: "ready", url: "blob:preview" })),
|
||||
};
|
||||
});
|
||||
const attachmentSource: ImageLightboxSource = { type: "attachment", metadata: imageMetadata };
|
||||
@@ -73,10 +74,13 @@ vi.mock("react-i18next", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("react-native-safe-area-context", () => ({
|
||||
SafeAreaProvider: ({ children }: { children?: React.ReactNode }) => children,
|
||||
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
|
||||
}));
|
||||
|
||||
vi.mock("@/window-layout", () => ({
|
||||
WindowSafeArea: ({ children }: { children?: React.ReactNode }) => children,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-gesture-handler", () => ({
|
||||
GestureHandlerRootView: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-testid": "gesture-handler-root" }, children),
|
||||
@@ -151,7 +155,7 @@ beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
useAttachmentPreviewUrlMock.mockReturnValue("blob:preview");
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "ready", url: "blob:preview" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -229,14 +233,22 @@ describe("AttachmentLightbox", () => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows error text when the preview URL resolves to null", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue(null);
|
||||
it("shows error text when the preview URL fails to resolve", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "failed" });
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").toContain("Couldn't load image");
|
||||
});
|
||||
|
||||
it("shows neither the image nor an error while the preview URL resolves", () => {
|
||||
useAttachmentPreviewUrlMock.mockReturnValue({ status: "loading" });
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={vi.fn()} />);
|
||||
|
||||
expect(queryByTestId("attachment-lightbox-image")).toBeNull();
|
||||
expect(document.body.textContent ?? "").not.toContain("Couldn't load image");
|
||||
});
|
||||
|
||||
it("closes on Escape key on web", () => {
|
||||
const onClose = vi.fn();
|
||||
render(<AttachmentLightbox source={attachmentSource} onClose={onClose} />);
|
||||
|
||||
@@ -4,17 +4,21 @@ import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { X } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import {
|
||||
type AttachmentPreviewUrl,
|
||||
useAttachmentPreviewUrl,
|
||||
} from "@/attachments/use-attachment-preview-url";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { SPACING } from "@/styles/theme";
|
||||
import { WindowChromeRootRegion } from "@/utils/desktop-window";
|
||||
import { ZoomableImage } from "@/components/zoomable-viewport/image";
|
||||
import type { ViewportSize } from "@/components/zoomable-viewport/geometry";
|
||||
import { useGlobalOverlayLayer } from "@/lib/overlay-root";
|
||||
import { WindowSafeArea } from "@/window-layout";
|
||||
|
||||
export type ImageLightboxSource =
|
||||
| { type: "attachment"; metadata: AttachmentMetadata }
|
||||
@@ -31,10 +35,13 @@ const LIGHTBOX_FIT = { padding: SPACING[4], maxWidth: 960, maxHeight: 640 };
|
||||
export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const metadata = source?.type === "attachment" ? source.metadata : null;
|
||||
const attachmentUrl = useAttachmentPreviewUrl(metadata);
|
||||
const url = source?.type === "uri" ? source.uri : attachmentUrl;
|
||||
const attachmentPreview = useAttachmentPreviewUrl(metadata);
|
||||
const image: AttachmentPreviewUrl =
|
||||
source?.type === "uri" ? { status: "ready", url: source.uri } : attachmentPreview;
|
||||
const url = image.status === "ready" ? image.url : null;
|
||||
const contentSize = source?.type === "uri" ? source.contentSize : undefined;
|
||||
const [errored, setErrored] = useState(false);
|
||||
const failed = errored || image.status === "failed";
|
||||
const modalLayer = useGlobalOverlayLayer("modal", source !== null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -76,14 +83,12 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasError = errored || !url;
|
||||
|
||||
return (
|
||||
<Modal transparent animationType="fade" statusBarTranslucent visible onRequestClose={onClose}>
|
||||
<ModalRoot style={styles.root}>
|
||||
{/* The modal is its own window: its insets are the window's, not the ones left where the
|
||||
image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<SafeAreaProvider>
|
||||
{/* The modal draws over the whole window: its insets are the window's, not the ones left
|
||||
where the image was opened (inside a sheet tab, the bottom one is already spent). */}
|
||||
<WindowSafeArea>
|
||||
<WindowChromeRootRegion corners="both">
|
||||
<View ref={setOverlayScope} style={styles.root}>
|
||||
<Pressable
|
||||
@@ -94,9 +99,10 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
style={styles.backdrop}
|
||||
/>
|
||||
<LightboxImageArea>
|
||||
{hasError ? (
|
||||
{failed ? (
|
||||
<Text style={styles.errorText}>{t("message.attachments.imageLoadFailed")}</Text>
|
||||
) : (
|
||||
) : null}
|
||||
{!failed && url ? (
|
||||
<ZoomableImage
|
||||
accessibilityLabel={t("composer.attachments.openImage")}
|
||||
actions={actions}
|
||||
@@ -108,11 +114,11 @@ export function AttachmentLightbox({ source, onClose }: AttachmentLightboxProps)
|
||||
testID="attachment-lightbox"
|
||||
uri={url}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</LightboxImageArea>
|
||||
</View>
|
||||
</WindowChromeRootRegion>
|
||||
</SafeAreaProvider>
|
||||
</WindowSafeArea>
|
||||
</ModalRoot>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -133,9 +133,10 @@ export function AttachmentLabel({ icon, title, subtitle }: AttachmentLabelProps)
|
||||
|
||||
/** Square image preview pill body. */
|
||||
export function AttachmentThumbnail({ metadata }: { metadata: AttachmentMetadata }) {
|
||||
const uri = useAttachmentPreviewUrl(metadata);
|
||||
const source = useMemo(() => ({ uri: uri ?? "" }), [uri]);
|
||||
if (!uri) {
|
||||
const preview = useAttachmentPreviewUrl(metadata);
|
||||
const uri = preview.status === "ready" ? preview.url : null;
|
||||
const source = useMemo(() => (uri ? { uri } : null), [uri]);
|
||||
if (!source) {
|
||||
return <View style={styles.thumbnailPlaceholder} />;
|
||||
}
|
||||
return <Image source={source} style={styles.thumbnail} />;
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
import { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
@@ -1251,9 +1252,8 @@ function WorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
name: workspace.name,
|
||||
diffStat: workspace.diffStat,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useSidebarWorkspaceRename } from "@/workspace-rename";
|
||||
import { useWorkspaceClipboardActions } from "@/hooks/use-workspace-clipboard-actions";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import { useWorkspaceArchive } from "@/workspace/use-workspace-archive";
|
||||
import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -655,9 +656,8 @@ function StatusWorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
name: workspace.name,
|
||||
diffStat: workspace.diffStat,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Modal as NativeModal, type ModalProps as NativeModalProps } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Modal as NativeModal,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
type ModalProps as NativeModalProps,
|
||||
} from "react-native";
|
||||
import { useDismissibleOverlay } from "@/components/ui/overlay-dismissal";
|
||||
|
||||
// React Native defaults iPhone modals to portrait even when the app supports rotation.
|
||||
@@ -17,7 +23,23 @@ type ModalProps = Omit<NativeModalProps, "onRequestClose"> & {
|
||||
function noClose() {}
|
||||
|
||||
export function Modal(props: ModalProps) {
|
||||
const { visible = true, onRequestClose } = props;
|
||||
const { visible = true, onRequestClose, style } = props;
|
||||
useDismissibleOverlay(visible && onRequestClose !== undefined, onRequestClose ?? noClose);
|
||||
return <NativeModal supportedOrientations={orientations} {...props} />;
|
||||
const windowFrame = useAndroidWindowFrame();
|
||||
const containerStyle = useMemo(() => [windowFrame, style], [windowFrame, style]);
|
||||
return <NativeModal supportedOrientations={orientations} {...props} style={containerStyle} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* On Android, React Native lays out a modal's first frames at the screen size minus the status and
|
||||
* navigation bars, then grows it to the full window once the dialog reports its size. The app draws
|
||||
* edge-to-edge, so the dialog always covers the whole window: size the content to the window from
|
||||
* the first frame, or a full-screen backdrop stops short of the bottom and then snaps down.
|
||||
*/
|
||||
function useAndroidWindowFrame() {
|
||||
const { width, height } = useWindowDimensions();
|
||||
return useMemo(
|
||||
() => (Platform.OS === "android" ? { flex: 0, width, height } : null),
|
||||
[width, height],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Image } from "react-native";
|
||||
import type { ViewportSize } from "./geometry";
|
||||
|
||||
/** Resolves the image's natural size; rejects when the image cannot load or has no size. */
|
||||
export function measureImage(uri: string): Promise<ViewportSize> {
|
||||
return new Promise((resolve, reject) => {
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (width > 0 && height > 0) resolve({ width, height });
|
||||
else reject(new Error("Image has no size"));
|
||||
},
|
||||
reject,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ViewportSize } from "./geometry";
|
||||
|
||||
// The size a browser gives a replaced element that has no intrinsic size of its own.
|
||||
const NO_INTRINSIC_SIZE: ViewportSize = { width: 300, height: 150 };
|
||||
|
||||
/**
|
||||
* Resolves the image's natural size; rejects when the image cannot load. React Native Web's
|
||||
* `Image.getSize` never answers for an image that loads with no natural size (an SVG without
|
||||
* width and height, in some browsers), so the image would never show and never fail.
|
||||
*/
|
||||
export function measureImage(uri: string): Promise<ViewportSize> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new window.Image();
|
||||
image.addEventListener("load", () =>
|
||||
resolve(
|
||||
image.naturalWidth > 0 && image.naturalHeight > 0
|
||||
? { width: image.naturalWidth, height: image.naturalHeight }
|
||||
: NO_INTRINSIC_SIZE,
|
||||
),
|
||||
);
|
||||
image.addEventListener("error", () => reject(new Error("Image failed to load")));
|
||||
image.src = uri;
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Image, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { ViewportFitOptions, ViewportSize } from "./geometry";
|
||||
import { measureImage } from "./image-size";
|
||||
import { ZoomableViewport } from "./index";
|
||||
import type { ZoomableViewportAction } from "./types";
|
||||
|
||||
@@ -34,42 +35,33 @@ export function ZoomableImage({
|
||||
testID = "zoomable-image",
|
||||
wheelActivation = "always",
|
||||
}: ZoomableImageProps) {
|
||||
const [loadedSize, setLoadedSize] = useState<ViewportSize | null>(null);
|
||||
const resolvedSize = contentSize ?? loadedSize;
|
||||
const [loaded, setLoaded] = useState<{ uri: string; size: ViewportSize } | null>(null);
|
||||
const resolvedSize = contentSize ?? (loaded?.uri === uri ? loaded.size : null);
|
||||
const source = useMemo(() => ({ uri }), [uri]);
|
||||
const [paintedUri, setPaintedUri] = useState<string | null>(null);
|
||||
const handleLoad = useCallback(() => setPaintedUri(uri), [uri]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contentSize) return;
|
||||
let active = true;
|
||||
setLoadedSize(null);
|
||||
Image.getSize(
|
||||
uri,
|
||||
(width, height) => {
|
||||
if (active && width > 0 && height > 0) setLoadedSize({ width, height });
|
||||
},
|
||||
() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const size = await measureImage(uri);
|
||||
if (active) setLoaded({ uri, size });
|
||||
} catch {
|
||||
if (active) onError?.();
|
||||
},
|
||||
);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [contentSize, onError, uri]);
|
||||
|
||||
// Paint nothing until the image can be fitted: an unfitted image fills the whole area for the
|
||||
// frames before the size arrives, then jumps to its fitted box. Then keep the viewport, toolbar
|
||||
// included, hidden until the image itself has loaded, so they appear together.
|
||||
if (!resolvedSize) {
|
||||
return (
|
||||
<View style={[styles.root, style]} testID={testID}>
|
||||
<Image
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
accessibilityRole="image"
|
||||
onError={onError}
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
testID={`${testID}-image`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
return <View style={[styles.root, style]} testID={testID} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -81,12 +73,15 @@ export function ZoomableImage({
|
||||
maxScale={maxScale}
|
||||
minScale={minScale}
|
||||
onPressOutsideContent={onPressOutsideContent}
|
||||
style={style}
|
||||
style={[style, paintedUri !== uri && styles.loading]}
|
||||
testID={testID}
|
||||
wheelActivation={wheelActivation}
|
||||
>
|
||||
<Image
|
||||
// Android fades a freshly decoded image in over 300ms; it appears with its toolbar instead.
|
||||
fadeDuration={0}
|
||||
onError={onError}
|
||||
onLoad={handleLoad}
|
||||
resizeMode="contain"
|
||||
source={source}
|
||||
style={styles.image}
|
||||
@@ -99,4 +94,5 @@ export function ZoomableImage({
|
||||
const styles = StyleSheet.create(() => ({
|
||||
root: { flex: 1, minHeight: 0 },
|
||||
image: { width: "100%", height: "100%" },
|
||||
loading: { opacity: 0 },
|
||||
}));
|
||||
|
||||
@@ -291,13 +291,21 @@ function useWorkspaceScreenArchiveController({
|
||||
serverId,
|
||||
workspaceId: workspaceDescriptor?.id ?? "",
|
||||
workspaceKind: workspaceDescriptor?.workspaceKind ?? "directory",
|
||||
workspaceDirectory: workspaceDescriptor?.workspaceDirectory ?? "",
|
||||
name: workspaceDescriptor?.name ?? branchLabel,
|
||||
isDirty: archiveRisk.isDirty,
|
||||
aheadOfOrigin: archiveRisk.aheadOfOrigin,
|
||||
diffStat: workspaceDescriptor?.diffStat ?? null,
|
||||
warningLabels: getWorktreeArchiveWarningLabels(t),
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
onArchiveStarted: (archived) => {
|
||||
redirectIfArchivingActiveWorkspace({ ...archived, activeWorkspaceSelection });
|
||||
onArchiveStarted: () => {
|
||||
if (!activeWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId,
|
||||
workspaceId: activeWorkspaceSelection.workspaceId,
|
||||
activeWorkspaceSelection,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,87 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CheckoutStatusClient, CheckoutStatusPayload } from "@/git/checkout-status-cache";
|
||||
import {
|
||||
buildWorktreeArchiveConfirmationMessage,
|
||||
buildWorktreeArchiveRiskReasons,
|
||||
readWorktreeArchiveRisk,
|
||||
toWorktreeArchiveRisk,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
|
||||
const WORKTREE = "/home/me/.paseo/worktrees/repo/feature";
|
||||
|
||||
function hostAnswering(status: CheckoutStatusPayload): CheckoutStatusClient & { asked: string[] } {
|
||||
const asked: string[] = [];
|
||||
return {
|
||||
asked,
|
||||
getCheckoutStatus: async (cwd) => {
|
||||
asked.push(cwd);
|
||||
return status;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NOT_GIT = {
|
||||
cwd: WORKTREE,
|
||||
requestId: "status",
|
||||
isGit: false,
|
||||
isPaseoOwnedWorktree: false,
|
||||
repoRoot: null,
|
||||
currentBranch: null,
|
||||
isDirty: null,
|
||||
baseRef: null,
|
||||
aheadBehind: null,
|
||||
aheadOfOrigin: null,
|
||||
behindOfOrigin: null,
|
||||
hasRemote: false,
|
||||
remoteUrl: null,
|
||||
error: null,
|
||||
} satisfies CheckoutStatusPayload;
|
||||
|
||||
describe("reading a worktree's archive risk from its host", () => {
|
||||
it("reads uncommitted changes and unpushed commits from the worktree's checkout status", async () => {
|
||||
const host = hostAnswering({
|
||||
cwd: WORKTREE,
|
||||
requestId: "status",
|
||||
isGit: true,
|
||||
isPaseoOwnedWorktree: true,
|
||||
repoRoot: WORKTREE,
|
||||
mainRepoRoot: "/home/me/repo",
|
||||
currentBranch: "feature",
|
||||
isDirty: true,
|
||||
baseRef: "main",
|
||||
aheadBehind: { ahead: 2, behind: 0 },
|
||||
aheadOfOrigin: 2,
|
||||
behindOfOrigin: 0,
|
||||
hasRemote: true,
|
||||
remoteUrl: "git@example.com:me/repo.git",
|
||||
error: null,
|
||||
});
|
||||
|
||||
expect(await readWorktreeArchiveRisk(host, WORKTREE)).toEqual({
|
||||
isDirty: true,
|
||||
aheadOfOrigin: 2,
|
||||
});
|
||||
expect(host.asked).toEqual([WORKTREE]);
|
||||
});
|
||||
|
||||
it("finds nothing to lose in a directory that is no longer a git checkout", async () => {
|
||||
expect(await readWorktreeArchiveRisk(hostAnswering(NOT_GIT), WORKTREE)).toEqual({
|
||||
isDirty: false,
|
||||
aheadOfOrigin: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects with the host's error when it can't read the worktree", async () => {
|
||||
const host = hostAnswering({
|
||||
...NOT_GIT,
|
||||
error: { code: "UNKNOWN", message: "git status failed" },
|
||||
});
|
||||
|
||||
await expect(readWorktreeArchiveRisk(host, WORKTREE)).rejects.toThrow("git status failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace archive warning for worktree backing", () => {
|
||||
it("does not require a confirmation for clean and pushed worktrees", () => {
|
||||
expect(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { CheckoutStatusClient } from "@/git/checkout-status-cache";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -50,21 +49,6 @@ export const DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS: WorktreeArchiveWarningLabe
|
||||
: i18n.t("workspace.git.actions.archiveWarning.unpushedCommits", { count }),
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the worktree's uncommitted changes and unpushed commits from its host. A directory the
|
||||
* host no longer reads as a git checkout has neither. Rejects when the host can't read it.
|
||||
*/
|
||||
export async function readWorktreeArchiveRisk(
|
||||
client: CheckoutStatusClient,
|
||||
workspaceDirectory: string,
|
||||
): Promise<Pick<WorktreeArchiveRisk, "isDirty" | "aheadOfOrigin">> {
|
||||
const status = await client.getCheckoutStatus(workspaceDirectory);
|
||||
if (status.error) {
|
||||
throw new Error(status.error.message);
|
||||
}
|
||||
return { isDirty: status.isDirty ?? false, aheadOfOrigin: status.aheadOfOrigin };
|
||||
}
|
||||
|
||||
export function toWorktreeArchiveRisk(input: WorktreeArchiveRiskInput): WorktreeArchiveRisk {
|
||||
return {
|
||||
isDirty: input.archiveHasUncommittedChanges,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { useWindowDimensions } from "react-native";
|
||||
import type { EdgeInsets } from "react-native-safe-area-context";
|
||||
import type { TopBand, WindowLayout } from "./model";
|
||||
|
||||
export const WindowLayoutContext = createContext<WindowLayout | null>(null);
|
||||
@@ -7,6 +8,12 @@ export const WindowLayoutContext = createContext<WindowLayout | null>(null);
|
||||
/** Published by `WindowLayoutProvider` beside the safe-area insets it derives from it. */
|
||||
export const TopBandContext = createContext<TopBand | null>(null);
|
||||
|
||||
/**
|
||||
* The insets of the whole window, published by `WindowLayoutProvider` before any surface (a
|
||||
* sheet tab, for one) narrows the insets its content reads.
|
||||
*/
|
||||
export const WindowInsetsContext = createContext<EdgeInsets | null>(null);
|
||||
|
||||
export function useWindowLayout(): WindowLayout | null {
|
||||
const layout = useContext(WindowLayoutContext);
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
@@ -2,13 +2,19 @@ import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { StyleSheet, View, type NativeSyntheticEvent, type ViewProps } from "react-native";
|
||||
import { requireNativeViewManager } from "expo-modules-core";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { TopBandContext, WindowLayoutContext, useWindowLayout } from "./context";
|
||||
import {
|
||||
TopBandContext,
|
||||
WindowInsetsContext,
|
||||
WindowLayoutContext,
|
||||
useWindowLayout,
|
||||
} from "./context";
|
||||
import { resolveSafeAreaInsets, resolveTopBand, type WindowLayout } from "./model";
|
||||
|
||||
export { useWindowLayout } from "./context";
|
||||
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
|
||||
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
|
||||
export { useTopBand } from "./context";
|
||||
export { WindowSafeArea } from "./window-safe-area";
|
||||
export {
|
||||
resolveDesktopDivisionLayout,
|
||||
resolveWorkspaceActionPlacement,
|
||||
@@ -56,7 +62,9 @@ function WindowSafeAreaInsets({ children }: { children: ReactNode }) {
|
||||
);
|
||||
return (
|
||||
<TopBandContext.Provider value={topBand}>
|
||||
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
|
||||
<WindowInsetsContext.Provider value={insets}>
|
||||
<SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>
|
||||
</WindowInsetsContext.Provider>
|
||||
</TopBandContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { WindowInsetsContext } from "./context";
|
||||
|
||||
export { useWindowLayout } from "./context";
|
||||
export { WindowLayoutActions, WindowLayoutActionsHost } from "./actions";
|
||||
export { WindowLayoutSurface, WindowLayoutDivisionGap } from "./surface";
|
||||
export { useTopBand } from "./context";
|
||||
export { WindowSafeArea } from "./window-safe-area";
|
||||
export {
|
||||
resolveDesktopDivisionLayout,
|
||||
resolveWorkspaceActionPlacement,
|
||||
@@ -12,5 +15,6 @@ export {
|
||||
export type { DesktopDivisionLayout, TopBand, WindowLayout, WindowLayoutRect } from "./model";
|
||||
|
||||
export function WindowLayoutProvider({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
const insets = useSafeAreaInsets();
|
||||
return <WindowInsetsContext.Provider value={insets}>{children}</WindowInsetsContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Text } from "react-native";
|
||||
import { SafeAreaInsetsContext, useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { WindowLayoutProvider, WindowSafeArea } from "@/window-layout";
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
const windowInsets = { top: 52, right: 0, bottom: 24, left: 0 };
|
||||
const sheetTabInsets = { ...windowInsets, bottom: 0 };
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
afterEach(() => {
|
||||
act(() => root.render(null));
|
||||
});
|
||||
|
||||
function InsetsProbe({ testID }: { testID: string }) {
|
||||
const insets = useSafeAreaInsets();
|
||||
return <Text testID={testID}>{JSON.stringify(insets)}</Text>;
|
||||
}
|
||||
|
||||
it("gives an overlay opened inside a sheet tab the window's insets", () => {
|
||||
act(() =>
|
||||
root.render(
|
||||
<SafeAreaInsetsContext.Provider value={windowInsets}>
|
||||
<WindowLayoutProvider>
|
||||
<SafeAreaInsetsContext.Provider value={sheetTabInsets}>
|
||||
<InsetsProbe testID="sheet-tab" />
|
||||
<WindowSafeArea>
|
||||
<InsetsProbe testID="overlay" />
|
||||
</WindowSafeArea>
|
||||
</SafeAreaInsetsContext.Provider>
|
||||
</WindowLayoutProvider>
|
||||
</SafeAreaInsetsContext.Provider>,
|
||||
),
|
||||
);
|
||||
|
||||
const insetsOf = (testID: string) =>
|
||||
JSON.parse(container.querySelector(`[data-testid="${testID}"]`)?.textContent ?? "null");
|
||||
expect(insetsOf("sheet-tab")).toEqual(sheetTabInsets);
|
||||
expect(insetsOf("overlay")).toEqual(windowInsets);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useContext, type ReactNode } from "react";
|
||||
import { SafeAreaInsetsContext } from "react-native-safe-area-context";
|
||||
import { WindowInsetsContext } from "./context";
|
||||
|
||||
/**
|
||||
* Gives an overlay that draws over the whole window, such as an RN `Modal`, the window's insets
|
||||
* instead of the ones its call site was given. Read them below it with `useSafeAreaInsets()`.
|
||||
*/
|
||||
export function WindowSafeArea({ children }: { children: ReactNode }) {
|
||||
const insets = useContext(WindowInsetsContext);
|
||||
if (!insets) {
|
||||
throw new Error("WindowSafeArea must render inside WindowLayoutProvider");
|
||||
}
|
||||
return <SafeAreaInsetsContext.Provider value={insets}>{children}</SafeAreaInsetsContext.Provider>;
|
||||
}
|
||||
@@ -1,21 +1,17 @@
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
|
||||
readWorktreeArchiveRisk,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
} from "@/git/worktree-archive-warning";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
import { archiveWorkspaceWithUndo, supportsWorkspaceArchiveUndo } from "@/workspace/archive-undo";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
type WorkspaceArchiveTarget,
|
||||
} from "@/workspace/workspace-archive";
|
||||
import { archiveWorkspaceOptimistically } from "@/workspace/workspace-archive";
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
|
||||
@@ -28,13 +24,12 @@ export interface ArchiveWorkspaceInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
workspaceDirectory: string;
|
||||
name: string;
|
||||
/** Labels the warning's line counts. Whether to warn comes from the host's git state. */
|
||||
isDirty?: boolean | null;
|
||||
aheadOfOrigin?: number | null;
|
||||
diffStat?: { additions: number; deletions: number } | null;
|
||||
warningLabels?: WorktreeArchiveWarningLabels;
|
||||
/** Runs once the archive starts, with the workspace being archived. */
|
||||
onArchiveStarted: (workspace: WorkspaceArchiveTarget) => void;
|
||||
onArchiveStarted: () => void;
|
||||
onSetHiding?: (hiding: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -47,8 +42,9 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceKind,
|
||||
workspaceDirectory,
|
||||
name,
|
||||
isDirty,
|
||||
aheadOfOrigin,
|
||||
diffStat,
|
||||
warningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
|
||||
onArchiveStarted,
|
||||
@@ -56,11 +52,6 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
} = input;
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const confirmingRef = useRef(false);
|
||||
// The archive starts after the host answers and you confirm. By then you may have opened another
|
||||
// workspace, so the redirect reads the selection from the latest render.
|
||||
const onArchiveStartedRef = useRef(onArchiveStarted);
|
||||
onArchiveStartedRef.current = onArchiveStarted;
|
||||
|
||||
const archiveWorkspaceRecord = useCallback(async () => {
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
@@ -71,7 +62,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
const workspace = { serverId, workspaceId };
|
||||
onSetHiding?.(true);
|
||||
try {
|
||||
onArchiveStartedRef.current(workspace);
|
||||
onArchiveStarted();
|
||||
if (supportsWorkspaceArchiveUndo(serverId)) {
|
||||
await archiveWorkspaceWithUndo({
|
||||
client,
|
||||
@@ -91,54 +82,35 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
} finally {
|
||||
onSetHiding?.(false);
|
||||
}
|
||||
}, [onSetHiding, serverId, t, toast, workspaceId]);
|
||||
|
||||
// The host removes a worktree it archives, uncommitted work included. Ask it what that would
|
||||
// lose: the sidebar row's git status is empty until the row receives it.
|
||||
const confirmWorktreeArchive = useCallback(async (): Promise<boolean> => {
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
toast.error(t("sidebar.workspace.toasts.hostDisconnected"));
|
||||
return false;
|
||||
}
|
||||
const risk = await readWorktreeArchiveRisk(client, workspaceDirectory).catch(
|
||||
(error: unknown) => {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sidebar.workspace.toasts.archiveFailed"),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
if (!risk) {
|
||||
return false;
|
||||
}
|
||||
return await confirmRiskyWorktreeArchive(
|
||||
{ workspaceName: name, diffStat, ...risk },
|
||||
warningLabels,
|
||||
);
|
||||
}, [diffStat, name, serverId, t, toast, warningLabels, workspaceDirectory]);
|
||||
}, [onArchiveStarted, onSetHiding, serverId, t, toast, workspaceId]);
|
||||
|
||||
const archive = useCallback(() => {
|
||||
// A second press while the host answers or the warning is open would ask twice.
|
||||
if (confirmingRef.current) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
if (workspaceKind === "worktree") {
|
||||
confirmingRef.current = true;
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirmWorktreeArchive();
|
||||
} finally {
|
||||
confirmingRef.current = false;
|
||||
}
|
||||
const confirmed = await confirmRiskyWorktreeArchive(
|
||||
{
|
||||
workspaceName: name,
|
||||
isDirty,
|
||||
aheadOfOrigin,
|
||||
diffStat,
|
||||
},
|
||||
warningLabels,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await archiveWorkspaceRecord();
|
||||
})();
|
||||
}, [archiveWorkspaceRecord, confirmWorktreeArchive, workspaceKind]);
|
||||
}, [
|
||||
aheadOfOrigin,
|
||||
archiveWorkspaceRecord,
|
||||
diffStat,
|
||||
isDirty,
|
||||
name,
|
||||
warningLabels,
|
||||
workspaceKind,
|
||||
]);
|
||||
|
||||
return {
|
||||
archive,
|
||||
|
||||
@@ -236,8 +236,6 @@ interface AgentLike {
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
const FULL_AGENT_UUID_PATTERN = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* Resolve an agent ID from a partial ID or name.
|
||||
* Supports:
|
||||
@@ -254,11 +252,6 @@ export function resolveAgentId(idOrName: string, agents: AgentLike[]): string |
|
||||
|
||||
const query = idOrName.toLowerCase();
|
||||
|
||||
// A complete agent UUID is an exact identity, never a name search fallback.
|
||||
if (FULL_AGENT_UUID_PATTERN.test(idOrName)) {
|
||||
return agents.find((a) => a.id.toLowerCase() === query)?.id ?? null;
|
||||
}
|
||||
|
||||
// Try exact ID match first
|
||||
const exactMatch = agents.find((a) => a.id === idOrName);
|
||||
if (exactMatch) {
|
||||
|
||||
@@ -18,52 +18,25 @@ const agents = [
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: full UUID resolves exactly, case-insensitively");
|
||||
const exactId = "9eac296c-4d60-4189-a0b5-ae71dd9c6ad9";
|
||||
const agentsWithNameCollision = [
|
||||
{ id: exactId, title: "target agent" },
|
||||
{
|
||||
id: "c9a97e5d-4a92-4646-a016-53032146d0a1",
|
||||
title: `worker ${exactId}`,
|
||||
},
|
||||
];
|
||||
assert.strictEqual(resolveAgentId(exactId.toUpperCase(), agentsWithNameCollision), exactId);
|
||||
console.log("✓ full UUID resolves exactly, case-insensitively\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3: missing full UUID does not match another agent name");
|
||||
const missingId = "9eac296c-4d60-4189-a0b5-ae71dd9c6ad9";
|
||||
const nameCollision = [
|
||||
{
|
||||
id: "c9a97e5d-4a92-4646-a016-53032146d0a1",
|
||||
title: `worker ${missingId}`,
|
||||
},
|
||||
];
|
||||
assert.strictEqual(resolveAgentId(missingId, nameCollision), null);
|
||||
console.log("✓ missing full UUID does not match another agent name\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 4: ID prefix resolves");
|
||||
console.log("Test 2: ID prefix resolves");
|
||||
assert.strictEqual(resolveAgentId("def5678", agents), "def5678-other-agent-id");
|
||||
console.log("✓ ID prefix resolves\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 5: exact name match resolves case-insensitively");
|
||||
console.log("Test 3: exact name match resolves case-insensitively");
|
||||
assert.strictEqual(resolveAgentId("fix archive flow", agents), "def5678-other-agent-id");
|
||||
console.log("✓ exact name match resolves case-insensitively\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 6: partial name match resolves when unique");
|
||||
console.log("Test 4: partial name match resolves when unique");
|
||||
assert.strictEqual(resolveAgentId("review", agents), "987zyxw-third-agent-id");
|
||||
console.log("✓ partial name match resolves when unique\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 7: missing agent returns null");
|
||||
console.log("Test 5: missing agent returns null");
|
||||
assert.strictEqual(resolveAgentId("does not exist", agents), null);
|
||||
console.log("✓ missing agent returns null\n");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
import { formatAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
function toolCallItem(params: {
|
||||
callId: string;
|
||||
@@ -40,29 +39,6 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
|
||||
};
|
||||
}
|
||||
|
||||
/** Rows as the daemon hands them to a fork: projected by the timeline store. */
|
||||
function storeTimeline(items: readonly AgentTimelineItem[]) {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1");
|
||||
for (const item of items) store.append("agent-1", item);
|
||||
const { epoch, rows } = store.fetch("agent-1", { limit: 0 });
|
||||
return { epoch, rows };
|
||||
}
|
||||
|
||||
function backgroundTaskItem(input: { status: "running" | "completed"; log: string }) {
|
||||
return toolCallItem({
|
||||
callId: "background-task",
|
||||
name: "Task",
|
||||
status: input.status,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Audit the migrations",
|
||||
log: input.log,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("curateAgentActivity", () => {
|
||||
it("keeps reasoning before the assistant text that followed it", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
@@ -438,48 +414,44 @@ second line'`,
|
||||
expect(result.attachment.text).toContain("[Assistant] Done.");
|
||||
});
|
||||
|
||||
it("forks at a response that background work outlives, by message id", () => {
|
||||
const { rows } = storeTimeline([
|
||||
{ type: "user_message", text: "Audit in the background", messageId: "user-1" },
|
||||
backgroundTaskItem({ status: "running", log: "scanning" }),
|
||||
{ type: "assistant_message", text: "Audit started.", messageId: "assistant-1" },
|
||||
backgroundTaskItem({ status: "completed", log: "FINAL REPORT after the response" }),
|
||||
{ type: "user_message", text: "Any findings?", messageId: "user-2" },
|
||||
]);
|
||||
expect(rows[1]).toMatchObject({ seqStart: 2, seqEnd: 4 });
|
||||
|
||||
const result = buildAgentForkContextAttachment({ boundaryMessageId: "assistant-1", rows });
|
||||
|
||||
expect(result.itemCount).toBe(3);
|
||||
expect(result.boundaryMessageId).toBe("assistant-1");
|
||||
expect(result.attachment.text).toContain(
|
||||
"[User] Audit in the background\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
|
||||
);
|
||||
expect(result.attachment.text).not.toContain("FINAL REPORT");
|
||||
expect(result.attachment.text).not.toContain("Any findings?");
|
||||
});
|
||||
|
||||
it("forks at a response that background work outlives, by timeline cursor", () => {
|
||||
const { epoch, rows } = storeTimeline([
|
||||
{ type: "user_message", text: "Audit in the background", messageId: "user-1" },
|
||||
backgroundTaskItem({ status: "running", log: "scanning" }),
|
||||
{ type: "assistant_message", text: "Audit started." },
|
||||
backgroundTaskItem({ status: "completed", log: "FINAL REPORT after the response" }),
|
||||
{ type: "assistant_message", text: "The audit finished." },
|
||||
]);
|
||||
|
||||
const result = buildAgentForkContextAttachment({
|
||||
cursorBoundary: { timelineEpoch: epoch, cursor: { epoch, seq: 3 } },
|
||||
rows,
|
||||
});
|
||||
|
||||
expect(result.boundaryCursor).toEqual({ epoch, seq: 3 });
|
||||
expect(result.itemCount).toBe(3);
|
||||
expect(result.attachment.text).toContain(
|
||||
"[User] Audit in the background\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
|
||||
);
|
||||
expect(result.attachment.text).not.toContain("FINAL REPORT");
|
||||
expect(result.attachment.text).not.toContain("The audit finished.");
|
||||
it("rejects a checkpoint whose projected tool state changed later", () => {
|
||||
expect(() =>
|
||||
buildAgentForkContextAttachment({
|
||||
boundaryMessageId: "assistant-1",
|
||||
rows: [
|
||||
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
|
||||
row(
|
||||
2,
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "before boundary",
|
||||
},
|
||||
}),
|
||||
),
|
||||
row(3, {
|
||||
type: "assistant_message",
|
||||
text: "Partial result.",
|
||||
messageId: "assistant-1",
|
||||
}),
|
||||
row(
|
||||
4,
|
||||
toolCallItem({
|
||||
callId: "terminal-1",
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "after boundary",
|
||||
},
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
).toThrow("Fork from a later completed response");
|
||||
});
|
||||
|
||||
it("forks from a checkpoint when a plugin card was updated after it", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentMessage } from "@getpaseo/protocol/agent-message";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
|
||||
@@ -13,7 +13,7 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
|
||||
const MAX_FORK_CONTEXT_CHARS = 100_000;
|
||||
const MAX_FORK_METADATA_CHARS = 1_000;
|
||||
const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n";
|
||||
// A fork copies only these kinds.
|
||||
// A fork copies only these kinds, so only they can make a checkpoint stale.
|
||||
const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
|
||||
"user_message",
|
||||
"assistant_message",
|
||||
@@ -275,25 +275,25 @@ function selectForkContextRows(input: {
|
||||
);
|
||||
}
|
||||
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
|
||||
// Only tool calls and plugin cards fold later updates into an earlier row, and a fork doesn't
|
||||
// copy plugin cards. So the only row that spans the checkpoint and still reaches the fork is a
|
||||
// tool call.
|
||||
const items = projectedRows.flatMap((row) => {
|
||||
if (row.seqEnd <= boundarySeq) return [row.item];
|
||||
if (row.seqStart > boundarySeq || row.item.type !== "tool_call") return [];
|
||||
return [toolCallAtCheckpoint(row.item)];
|
||||
});
|
||||
if (
|
||||
projectedRows.some(
|
||||
(row) =>
|
||||
FORK_CONTEXT_KINDS.includes(row.item.type) &&
|
||||
row.seqStart <= boundarySeq &&
|
||||
row.seqEnd > boundarySeq,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"This checkpoint changed after it was created. Fork from a later completed response instead.",
|
||||
);
|
||||
}
|
||||
const projected = projectedRows.filter((row) => row.seqEnd <= boundarySeq);
|
||||
|
||||
return { items, boundaryCursor, boundaryMessageId };
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool call that started before the checkpoint and changed after it, such as background work
|
||||
* that outlives a reply. The projection keeps only its latest payload, so the fork copies it as
|
||||
* still running.
|
||||
*/
|
||||
function toolCallAtCheckpoint(item: ToolCallTimelineItem): ToolCallTimelineItem {
|
||||
return { ...item, status: "running", error: null };
|
||||
return {
|
||||
items: projected.map((entry) => entry.item),
|
||||
boundaryCursor,
|
||||
boundaryMessageId,
|
||||
};
|
||||
}
|
||||
|
||||
function trimContextMetadata(value: string | null | undefined): string | null {
|
||||
|
||||
@@ -13457,20 +13457,7 @@ function idleCleanupHarness() {
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const commands: AgentPromptInput[] = [];
|
||||
const prompts: AgentPromptInput[] = [];
|
||||
const sessions: AgentSession[] = [];
|
||||
const probes: Array<(() => Promise<boolean>) | undefined> = [];
|
||||
const closeEntered = deferred<void>();
|
||||
let closeGate: Promise<void> | undefined;
|
||||
let closes = 0;
|
||||
const recordPrompts = (session: AgentSession) => {
|
||||
const index = sessions.length;
|
||||
session.canStopRuntime = async () => probes[index]?.() ?? false;
|
||||
session.close = async () => {
|
||||
closes++;
|
||||
closeEntered.resolve();
|
||||
await closeGate;
|
||||
};
|
||||
sessions.push(session);
|
||||
session.tryHandleOutOfBand = (prompt) =>
|
||||
prompt === "/maintenance"
|
||||
? {
|
||||
@@ -13486,6 +13473,11 @@ function idleCleanupHarness() {
|
||||
};
|
||||
return session;
|
||||
};
|
||||
const sessions: AgentSession[] = [];
|
||||
const probes: Array<(() => Promise<boolean>) | undefined> = [];
|
||||
const closeEntered = deferred<void>();
|
||||
let closeGate: Promise<void> | undefined;
|
||||
let closes = 0;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
@@ -13496,7 +13488,15 @@ function idleCleanupHarness() {
|
||||
return recordPrompts(session);
|
||||
}
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const index = sessions.length;
|
||||
const session: AgentSession = await super.createSession(config);
|
||||
session.canStopRuntime = async () => probes[index]?.() ?? false;
|
||||
session.close = async () => {
|
||||
closes++;
|
||||
closeEntered.resolve();
|
||||
await closeGate;
|
||||
};
|
||||
sessions.push(session);
|
||||
return recordPrompts(session);
|
||||
}
|
||||
})();
|
||||
@@ -14419,39 +14419,3 @@ test("workspace archive preserves a loaded history agent stamp and stamps cascad
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("idle cleanup gives a newly restored runtime a fresh idle window while keeping historical updatedAt", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const created = await h.create();
|
||||
h.probes[0] = async () => true;
|
||||
h.probes[1] = async () => true;
|
||||
await h.manager.closeAgent(created.id);
|
||||
await h.storage.flush();
|
||||
const historicalUpdatedAt = new Date(Date.now() - 180 * 60_000);
|
||||
const existingRecord = await h.storage.get(created.id);
|
||||
expect(existingRecord).not.toBeNull();
|
||||
const staleRecord: StoredAgentRecord = {
|
||||
...existingRecord!,
|
||||
updatedAt: historicalUpdatedAt.toISOString(),
|
||||
lastActivityAt: historicalUpdatedAt.toISOString(),
|
||||
};
|
||||
await h.storage.upsert(staleRecord);
|
||||
await h.storage.flush();
|
||||
|
||||
const closesBeforeRestore = h.closes;
|
||||
const restored = await h.manager.resumeAgentFromPersistence(
|
||||
created.persistence!,
|
||||
{ cwd: created.cwd },
|
||||
created.id,
|
||||
{ updatedAt: historicalUpdatedAt },
|
||||
);
|
||||
expect(restored.updatedAt.getTime()).toBe(historicalUpdatedAt.getTime());
|
||||
|
||||
// Sweeping 1 minute after restoring an old agent must NOT immediately kill the newly opened runtime.
|
||||
expect((await h.sweep({ now: h.later(1) })).collected).toEqual([]);
|
||||
expect(h.closes).toBe(closesBeforeRestore);
|
||||
|
||||
// Sweeping 61 minutes after restoration collects the runtime as expected.
|
||||
expect((await h.sweep({ now: h.later(61) })).collected).toHaveLength(1);
|
||||
expect(h.closes).toBe(closesBeforeRestore + 1);
|
||||
});
|
||||
|
||||
@@ -856,10 +856,7 @@ export class AgentManager {
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly historySessions = new WeakSet<AgentSession>();
|
||||
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<
|
||||
AgentSession,
|
||||
{ readonly openedAt: number }
|
||||
>();
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
|
||||
private readonly collectingRuntimes = new Set<string>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
@@ -2071,7 +2068,7 @@ export class AgentManager {
|
||||
const updatedAt = agent.updatedAt;
|
||||
let identity = this.runtimeCleanupIdentities.get(agent.session);
|
||||
if (!identity) {
|
||||
identity = Object.freeze({ openedAt: Date.now() });
|
||||
identity = Object.freeze({});
|
||||
this.runtimeCleanupIdentities.set(agent.session, identity);
|
||||
}
|
||||
const isEligible = () =>
|
||||
@@ -2084,7 +2081,7 @@ export class AgentManager {
|
||||
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? null,
|
||||
lastActivityAt: Math.max(updatedAt.getTime(), identity.openedAt),
|
||||
lastActivityAt: updatedAt.getTime(),
|
||||
isEligible,
|
||||
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
|
||||
closeIfEligible: async (revalidate) => {
|
||||
@@ -4345,7 +4342,6 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
this.runtimeCleanupIdentities.set(session, Object.freeze({ openedAt: Date.now() }));
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
registered = true;
|
||||
// Initialize previousStatus to track transitions
|
||||
|
||||
@@ -496,58 +496,6 @@ async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSes
|
||||
);
|
||||
}
|
||||
|
||||
test("plugin rewind discards child outcomes from the withdrawn branch", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
async handleInput(input, emit) {
|
||||
if (input.type !== "session.open" || input.history !== "replay") return false;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: CAPABILITIES,
|
||||
restoration: "core",
|
||||
persistence: input.persistence,
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({
|
||||
type: "session.opened",
|
||||
sessionId: "rewound-child",
|
||||
parentSessionId: input.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({ type: "session.closed", sessionId: "rewound-child" });
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "rewound-child",
|
||||
parentSessionId: open.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
harness.emit({
|
||||
type: "session.turn",
|
||||
sessionId: "rewound-child",
|
||||
turnId: "withdrawn-turn",
|
||||
state: "failed",
|
||||
});
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
const history = await Array.fromAsync(session.streamHistory());
|
||||
expect(history.findLast((event) => event.type === "provider_subagent")).toMatchObject({
|
||||
event: { type: "upsert", id: "rewound-child", status: "completed" },
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("combined plugin rewind replays the retained native branch", async () => {
|
||||
await withPluginRewindSession({}, async (session) => {
|
||||
await session.revertBoth!({ messageId: "user-B" });
|
||||
@@ -1742,11 +1690,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: outcome },
|
||||
]);
|
||||
harness.emit({ type: "session.closed", sessionId: "reusable-child" });
|
||||
await nextTurn();
|
||||
expect(manager.listProviderSubagents(agent.id)).toMatchObject([
|
||||
{ id: child.id, title: child.title, status: outcome },
|
||||
]);
|
||||
} finally {
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
||||
await plugins.shutdown();
|
||||
@@ -1781,7 +1724,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
turnId: "second-turn",
|
||||
state,
|
||||
});
|
||||
emit({ type: "session.closed", sessionId: "reusable-child" });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1808,100 +1750,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
test("keeps a child's canceled or failed turn when the child closes normally", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: "/workspace",
|
||||
});
|
||||
try {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
const outcomes = [
|
||||
["child-canceled", "canceled"],
|
||||
["child-failed", "failed"],
|
||||
["child-completed", "completed"],
|
||||
] as const;
|
||||
for (const [sessionId, state] of outcomes) {
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId,
|
||||
parentSessionId: open.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: "/workspace",
|
||||
});
|
||||
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state: "started" });
|
||||
harness.emit({ type: "session.turn", sessionId, turnId: "turn", state });
|
||||
harness.emit({ type: "session.closed", sessionId });
|
||||
}
|
||||
|
||||
const lastStatus = (id: string) =>
|
||||
events
|
||||
.flatMap((event) =>
|
||||
event.type === "provider_subagent" &&
|
||||
event.event.type === "upsert" &&
|
||||
event.event.id === id
|
||||
? [event.event.status]
|
||||
: [],
|
||||
)
|
||||
.at(-1);
|
||||
expect(lastStatus("child-canceled")).toBe("canceled");
|
||||
expect(lastStatus("child-failed")).toBe("failed");
|
||||
expect(lastStatus("child-completed")).toBe("completed");
|
||||
} finally {
|
||||
await session.close();
|
||||
registry.replace([]);
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ turns: [], error: false, expected: "completed" },
|
||||
{ turns: ["canceled", "started"], error: false, expected: "completed" },
|
||||
{ turns: ["failed", "started", "completed"], error: false, expected: "completed" },
|
||||
{ turns: ["canceled"], error: true, expected: "failed" },
|
||||
] as const)(
|
||||
"closes a plugin child using its latest turn: $turns, error=$error",
|
||||
async ({ turns, error, expected }) => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
try {
|
||||
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||
provider: harness.registration.id,
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const open = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "close-control",
|
||||
parentSessionId: open.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
for (const state of turns) {
|
||||
harness.emit({ type: "session.turn", sessionId: "close-control", turnId: "turn", state });
|
||||
}
|
||||
harness.emit({
|
||||
type: "session.closed",
|
||||
sessionId: "close-control",
|
||||
...(error ? { error: { message: "Provider failed to close" } } : {}),
|
||||
});
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
type: "provider_subagent",
|
||||
event: { type: "upsert", id: "close-control", status: expected },
|
||||
});
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("gates persistence operations on negotiated provider capabilities", async () => {
|
||||
const harness = createProviderHarness({ capabilities: ["session.persistence"] });
|
||||
|
||||
@@ -1202,8 +1202,6 @@ class PluginAgentSession implements AgentSession {
|
||||
{ session: ProviderRuntimeSession; unsubscribe: () => void }
|
||||
>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
// Outcome of each child's latest finished turn, so a plain close does not report it as completed.
|
||||
private readonly childTurnOutcomes = new Map<string, "completed" | "canceled" | "failed">();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
/** How the opened session differs from the options it was given. */
|
||||
readonly openOptionsPatch: Record<string, JsonValue>;
|
||||
@@ -1768,11 +1766,6 @@ class PluginAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
if (event.type === "session.turn") {
|
||||
if (event.state === "started") {
|
||||
this.childTurnOutcomes.delete(childId);
|
||||
} else {
|
||||
this.childTurnOutcomes.set(childId, event.state);
|
||||
}
|
||||
this.publish({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
@@ -1791,10 +1784,7 @@ class PluginAgentSession implements AgentSession {
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: childId,
|
||||
status:
|
||||
event.type === "session.runtime_failed" || event.error
|
||||
? "failed"
|
||||
: (this.childTurnOutcomes.get(childId) ?? "completed"),
|
||||
status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1812,7 +1802,6 @@ class PluginAgentSession implements AgentSession {
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.childTurnOutcomes.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
cwd = mkdtempSync(path.join(tmpdir(), "fork-background-work-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 30_000);
|
||||
|
||||
test("forks from a reply that background work outlived", async () => {
|
||||
const agent = await ctx.client.createAgent({ provider: "claude", cwd });
|
||||
await ctx.client.sendMessage(agent.id, "Start background work");
|
||||
expect((await ctx.client.waitForFinish(agent.id, 15_000)).status).toBe("idle");
|
||||
await ctx.client.sendMessage(agent.id, "Any findings?");
|
||||
expect((await ctx.client.waitForFinish(agent.id, 15_000)).status).toBe("idle");
|
||||
|
||||
const page = await ctx.client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
const reply = page.entries.find(
|
||||
(entry) => entry.item.type === "assistant_message" && entry.item.text === "Audit started.",
|
||||
);
|
||||
if (reply?.item.type !== "assistant_message" || !reply.item.messageId) {
|
||||
throw new Error("Expected the reply to carry a provider message id");
|
||||
}
|
||||
const task = page.entries.find((entry) => entry.item.type === "tool_call");
|
||||
// The Task row started before the reply and completed after it.
|
||||
expect(task?.item).toMatchObject({ name: "Task", status: "completed" });
|
||||
expect(task?.seqStart).toBeLessThan(reply.seqEnd);
|
||||
expect(task?.seqEnd).toBeGreaterThan(reply.seqEnd);
|
||||
|
||||
const byMessage = await ctx.client.buildAgentForkContext(agent.id, {
|
||||
boundaryMessageId: reply.item.messageId,
|
||||
});
|
||||
const byCursor = await ctx.client.buildAgentForkContext(agent.id, {
|
||||
boundaryCursor: { epoch: page.epoch, seq: reply.seqEnd },
|
||||
});
|
||||
|
||||
for (const fork of [byMessage, byCursor]) {
|
||||
expect(fork.itemCount).toBe(3);
|
||||
expect(fork.attachment?.text).toContain(
|
||||
"[User] Start background work\n[Explore] Audit the migrations\n[Assistant] Audit started.\n",
|
||||
);
|
||||
expect(fork.attachment?.text).not.toContain("Any findings?");
|
||||
}
|
||||
expect(byMessage.boundaryMessageId).toBe(reply.item.messageId);
|
||||
expect(byCursor.boundaryCursor).toEqual({ epoch: page.epoch, seq: reply.seqEnd });
|
||||
}, 60_000);
|
||||
@@ -547,48 +547,6 @@ class FakeAgentSession implements AgentSession {
|
||||
this.notifySubscribers(completed);
|
||||
}
|
||||
|
||||
private async emitEvent(event: AgentStreamEvent): Promise<void> {
|
||||
await this.appendHistoryEvent(event);
|
||||
this.notifySubscribers(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* A background Task that outlives the turn's reply: the Task starts, the assistant replies,
|
||||
* then the Task completes.
|
||||
*/
|
||||
private async emitBackgroundWorkTurn(): Promise<void> {
|
||||
const callId = randomUUID();
|
||||
const task = (status: "running" | "completed", log: string): AgentStreamEvent => ({
|
||||
type: "timeline",
|
||||
provider: this.providerName,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
name: "Task",
|
||||
callId,
|
||||
status,
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Audit the migrations",
|
||||
log,
|
||||
},
|
||||
},
|
||||
});
|
||||
await this.emitEvent(task("running", ""));
|
||||
await this.emitEvent({
|
||||
type: "timeline",
|
||||
provider: this.providerName,
|
||||
item: { type: "assistant_message", text: "Audit started.", messageId: randomUUID() },
|
||||
});
|
||||
await this.emitEvent(task("completed", "Found 2 unsafe migrations."));
|
||||
await this.emitEvent({
|
||||
type: "turn_completed",
|
||||
provider: this.providerName,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
private async emitStressTurn(stress: { count: number; coalesced: boolean }): Promise<void> {
|
||||
for (let index = 0; index < stress.count; index += 1) {
|
||||
const stressUpdate: AgentStreamEvent = {
|
||||
@@ -832,11 +790,6 @@ class FakeAgentSession implements AgentSession {
|
||||
this.notifySubscribers(child);
|
||||
}
|
||||
|
||||
if (textPrompt === "Start background work") {
|
||||
await this.emitBackgroundWorkTurn();
|
||||
return;
|
||||
}
|
||||
|
||||
if (textPrompt.toLowerCase().includes("emit a turn failure")) {
|
||||
const failed: AgentStreamEvent = {
|
||||
type: "turn_failed",
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
getCheckoutShortstat,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutWorktreeState,
|
||||
checkoutResolvedBranch,
|
||||
listBranchSuggestions,
|
||||
setCheckoutBaseBranch,
|
||||
@@ -573,15 +572,6 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("counts untracked files as dirty when the repository hides them from status", async () => {
|
||||
execFileSync("git", ["config", "status.showUntrackedFiles", "no"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "new-file.txt"), "not yet added\n");
|
||||
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
||||
|
||||
expect((await getCheckoutStatus(repoDir)).isDirty).toBe(true);
|
||||
expect((await getCheckoutWorktreeState(repoDir, {})).isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("honors commit signing configuration", async () => {
|
||||
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
|
||||
|
||||
@@ -1413,17 +1413,12 @@ function resolveOperationBaseRef(input: {
|
||||
return input.storedBaseRef ?? input.requestedBaseRef ?? input.resolvedBaseRef;
|
||||
}
|
||||
|
||||
// Untracked files count whatever `status.showUntrackedFiles` says: archive warns from this before
|
||||
// it removes a worktree, and a file nobody added yet is still work to lose.
|
||||
async function isWorkingTreeDirty(cwd: string, context?: CheckoutContext): Promise<boolean> {
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
["status", "--porcelain", "--untracked-files=normal"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
},
|
||||
);
|
||||
const { stdout } = await getRunGitCommand(context)(["status", "--porcelain"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
});
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user