Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 65d0f270ad test(app): name the wait for a file-link lookup's delivery 2026-11-14 17:22:39 +01:00
Mohamed Boudra a331a945ee fix(app): retry a reopened failed attachment preview from loading
The hook kept a failed result after its lookup was torn down, so reopening the same attachment showed "Couldn't load image" until the retry landed. Every cleanup now drops the result for its key.
2026-11-14 17:22:39 +01:00
Mohamed Boudra 88a2a84f65 fix(app): show images the browser reports no natural size for
ZoomableImage waits for the image's size before painting it. React Native Web's Image.getSize never answers for an image that loads with a natural size of 0 (an SVG without width and height in some browsers), so the preview stayed blank with no error. The web build now measures the image itself and falls back to the browser's default replaced-element size, 300x150.
2026-11-14 17:22:38 +01:00
Mohamed Boudra 83708e21de test(app): wait for the stale file-link lookup to land before asserting
The test resolved the lookup and ended. React Query delivers the result on a timer, which could fire after jsdom was torn down ("window is not defined" failed app-tests in CI), and the no-open assertion could run before the stale result arrived at all.
2026-11-14 17:12:25 +01:00
Mohamed Boudra 090a1282c0 fix(app): keep a reopened attachment preview from reading its released URL
Closing a preview releases its URL (revoked on web and desktop), but the hook kept the result for that attachment. Reopening the same attachment returned the revoked URL until the new lookup landed, so the image failed to load and "Couldn't load image" showed for a frame. The result is now dropped when its URL is released. The frame test also counts an image as visible only once it is painted, so a preview that never appears fails.
2026-11-14 17:02:26 +01:00
Mohamed Boudra c431dd49c6 fix(app): open the image preview without a flash
Opening an image preview showed "Couldn't load image", then the image
stretched to the whole window, then a blank frame, before the fitted image.

- The attachment preview URL hook returned null both while resolving and on
  failure, so the lightbox showed the error while the URL resolved. It now
  reports loading, ready, or failed.
- ZoomableImage rendered the image unfitted until its natural size arrived.
  It now renders nothing until it can fit the image, and keeps it hidden until
  it has loaded, so the image appears with its toolbar instead of after it.
- On Android, React Native lays a modal out at the screen minus the system
  bars for its first frames, then grows it, so the backdrop snapped down; the
  Modal wrapper sizes its content to the window from the first frame. The
  lightbox's own SafeAreaProvider measured the dialog window, whose bottom
  inset flips 24, 0, 24; it now reads the window insets via WindowSafeArea.
2026-11-14 16:36:31 +01:00
42 changed files with 676 additions and 1416 deletions
+1 -13
View File
@@ -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
View File
@@ -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
+9 -3
View File
@@ -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
+4 -5
View File
@@ -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);
});
});
@@ -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,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,
});
+25 -3
View File
@@ -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 },
}));
+11 -3
View File
@@ -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();
+10 -2
View File
@@ -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>
);
}
+5 -1
View File
@@ -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,
-7
View File
@@ -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 });
+5 -10
View File
@@ -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;
}