mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 20:35:02 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd9e9d87f | ||
|
|
6e2fee462f | ||
|
|
cec92d0be6 | ||
|
|
b407990699 | ||
|
|
fc36438f03 | ||
|
|
0fb6473a4b | ||
|
|
a730ad1924 |
@@ -186,7 +186,8 @@ 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` covers Android; iOS has no native test harness.
|
||||
`VisibleContentPositionClampTest` and `SelectableTextTouchTest` cover 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
|
||||
@@ -206,5 +207,16 @@ React Native 0.88 skips the adjustment for a removed anchor
|
||||
and skip its correction while the user drags ([user scroll](#user-scroll)). This one has no React
|
||||
Native counterpart; it stays while the timeline uses the pin.
|
||||
|
||||
**Touched text (Android).** Chat text is selectable, and Android focuses selectable text when it is
|
||||
touched. React Native scrolls a newly focused view fully into view, so tapping a link or a word, or
|
||||
long-pressing to select, in a message partly off screen moved the timeline by up to a screen.
|
||||
`PaseoScrollView` skips that scroll when the focused view is selectable, non-editable text in touch
|
||||
mode. The text keeps focus, and a streaming reply's new text collapses its selection to the start,
|
||||
which asks to reveal the reply's first line; that request is skipped too while nothing is selected.
|
||||
A change in the timeline's height, such as the composer below it growing, also reveals the focused
|
||||
view, and is skipped the same way. Inputs, focus moved by a keyboard, and a selection the user drags
|
||||
still scroll into view. The inverted timeline guarded this with a rule tied to the inversion, which
|
||||
went with it; keep this one while chat text is selectable.
|
||||
|
||||
Remove the content clamp and removed-anchor overrides once the installed React Native includes
|
||||
their fixes.
|
||||
|
||||
+4
-4
@@ -186,10 +186,10 @@ This is the Android counterpart of the iOS local-simulator flow in [development.
|
||||
## Scroll view corrections
|
||||
|
||||
`modules/paseo-scroll` replaces React Native's vertical scroll view with `PaseoScrollView`, which
|
||||
keeps React Native's scroll manager interface and corrects `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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
cd packages/app/android
|
||||
|
||||
@@ -95,10 +95,11 @@ 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. 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.
|
||||
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.
|
||||
|
||||
## Resume behavior
|
||||
|
||||
|
||||
@@ -787,9 +787,13 @@ test.describe("Composer autocomplete", () => {
|
||||
await input.press("ArrowDown");
|
||||
await input.press("ArrowDown");
|
||||
await input.press("h");
|
||||
await expect(popover.getByText("/help", { exact: true }).first()).toBeVisible();
|
||||
// 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 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,27 +5,34 @@ 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";
|
||||
|
||||
async function seedRiskyWorktree(
|
||||
client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>,
|
||||
worktreeDirectory: string,
|
||||
): Promise<void> {
|
||||
type DaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
async function seedRiskyWorktree(client: DaemonClient, 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,
|
||||
@@ -55,35 +62,189 @@ async function seedRiskyWorktree(
|
||||
const dirtyFile = path.join(worktreeDirectory, "DIRTY.md");
|
||||
await writeFile(dirtyFile, "# dirty\n");
|
||||
|
||||
const refreshed = await client.checkoutRefresh(worktreeDirectory);
|
||||
await refreshCheckout(client, worktreeDirectory);
|
||||
}
|
||||
|
||||
async function refreshCheckout(client: DaemonClient, directory: string): Promise<void> {
|
||||
const refreshed = await client.checkoutRefresh(directory);
|
||||
if (!refreshed.success) {
|
||||
throw new Error(`Failed to refresh checkout for ${worktreeDirectory}`);
|
||||
throw new Error(`Failed to refresh checkout for ${directory}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
// 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(
|
||||
page: Page,
|
||||
workspaceId: string,
|
||||
answer: "accept" | "dismiss",
|
||||
openArchive: () => Promise<void>,
|
||||
): Promise<Dialog> {
|
||||
let warning: Dialog | undefined;
|
||||
page.once("dialog", (dialog) => {
|
||||
warning = dialog;
|
||||
void (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
const warning = page.waitForEvent("dialog", { timeout: 10_000 }).then(async (dialog) => {
|
||||
await (answer === "accept" ? dialog.accept() : dialog.dismiss());
|
||||
return dialog;
|
||||
});
|
||||
await clickArchiveWorkspaceMenuItem(page, workspaceId);
|
||||
if (!warning) {
|
||||
throw new Error("Expected an archive confirmation dialog, but none was shown.");
|
||||
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;
|
||||
}
|
||||
return warning;
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
let client: Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
let client: DaemonClient;
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
const createdWorktreeDirectories = new Set<string>();
|
||||
|
||||
@@ -103,36 +264,53 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
await tempRepo?.cleanup().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("a risky workspace archive is gated by confirmation and removes its worktree after acceptance", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await openProjectViaDaemon(client, tempRepo.path);
|
||||
async function createWorktree(slug: string) {
|
||||
const project = await openProjectViaDaemon(client, tempRepo.path);
|
||||
const worktree = await createWorktreeViaDaemon(client, {
|
||||
cwd: tempRepo.path,
|
||||
slug: `archive-risk-${Date.now()}`,
|
||||
slug: `${slug}-${Date.now()}`,
|
||||
});
|
||||
createdWorktreeDirectories.add(worktree.workspaceDirectory);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
return { ...worktree, project };
|
||||
}
|
||||
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
|
||||
async function openSidebarRow(page: Page, workspaceId: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await waitForWorkspaceInSidebar(page, { serverId, workspaceId: worktree.workspaceId });
|
||||
await waitForWorkspaceInSidebar(page, { serverId: getServerId(), workspaceId });
|
||||
}
|
||||
|
||||
const firstWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "dismiss");
|
||||
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 firstWarning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
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 expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${worktree.workspaceId}`),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
|
||||
const secondWarning = await clickArchiveAndAnswerWarning(page, worktree.workspaceId, "accept");
|
||||
const secondWarning = await answerArchiveWarning(page, "accept", () =>
|
||||
clickArchiveWorkspaceMenuItem(page, worktree.workspaceId),
|
||||
);
|
||||
expect(secondWarning.message()).toContain("Uncommitted changes");
|
||||
expect(secondWarning.message()).toContain("1 unpushed commit");
|
||||
|
||||
@@ -143,4 +321,128 @@ test.describe("Workspace archive risk warning for worktree backing", () => {
|
||||
|
||||
createdWorktreeDirectories.delete(worktree.workspaceDirectory);
|
||||
});
|
||||
|
||||
test("the row's archive button warns from the host's git state while the row has none, and Cancel keeps the worktree", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk-uncached");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
const relay = await relayDaemon(page, { workspaceId: worktree.workspaceId });
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
expect(relay.descriptorRewrites()).toBeGreaterThan(0);
|
||||
|
||||
const warning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(warning.message()).toContain("Uncommitted changes");
|
||||
expect(warning.message()).toContain("1 unpushed commit");
|
||||
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(relay.archiveRequests()).toBe(0);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "DIRTY.md"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a worktree holding only new files warns when the repository hides untracked files", async ({
|
||||
page,
|
||||
}) => {
|
||||
execSync("git config status.showUntrackedFiles no", { cwd: tempRepo.path, stdio: "ignore" });
|
||||
const worktree = await createWorktree("archive-risk-untracked");
|
||||
await writeFile(path.join(worktree.workspaceDirectory, "NEW.md"), "# not yet added\n");
|
||||
await refreshCheckout(client, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
|
||||
const warning = await answerArchiveWarning(page, "dismiss", () =>
|
||||
clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
expect(warning.message()).toContain(`Archive "${worktree.workspaceName}"?`);
|
||||
expect(warning.message()).toContain("Uncommitted changes");
|
||||
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(existsSync(path.join(worktree.workspaceDirectory, "NEW.md"))).toBe(true);
|
||||
});
|
||||
|
||||
test("a failed git read shows its error and keeps the worktree", async ({ page }) => {
|
||||
const worktree = await createWorktree("archive-risk-read-failure");
|
||||
await seedRiskyWorktree(client, worktree.workspaceDirectory);
|
||||
const relay = await relayDaemon(page, {
|
||||
workspaceId: worktree.workspaceId,
|
||||
failCheckoutStatusFor: worktree.workspaceDirectory,
|
||||
});
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
|
||||
await clickRowArchiveButton(page, worktree.workspaceId);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveText("git status failed");
|
||||
await expectWorktreeKept(page, worktree);
|
||||
expect(relay.archiveRequests()).toBe(0);
|
||||
expect(existsSync(worktree.workspaceDirectory)).toBe(true);
|
||||
});
|
||||
|
||||
// Archives the open worktree with `startArchive`, opens the project's main workspace while the
|
||||
// host's checkout status answer is held, then lets the answer through.
|
||||
async function archiveWhileOpeningAnotherWorkspace(
|
||||
page: Page,
|
||||
slug: string,
|
||||
startArchive: (worktree: { workspaceId: string }) => Promise<void>,
|
||||
): Promise<string> {
|
||||
const serverId = getServerId();
|
||||
const worktree = await createWorktree(slug);
|
||||
const statusRequest = await holdCheckoutStatusRequest(page, worktree.workspaceDirectory);
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(serverId, worktree.workspaceId));
|
||||
|
||||
statusRequest.arm();
|
||||
await startArchive(worktree);
|
||||
await statusRequest.waitForHeld();
|
||||
await workspaceRow(page, worktree.project.workspaceId).click();
|
||||
const openedRoute = buildHostWorkspaceRoute(serverId, worktree.project.workspaceId);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
|
||||
statusRequest.release();
|
||||
await expect(undoButton(page)).toBeVisible();
|
||||
// The redirect runs before the archive request, so once the host has archived the worktree
|
||||
// any redirect has already happened.
|
||||
await expect
|
||||
.poll(() => hostListsWorkspace(worktree.workspaceId), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
return openedRoute;
|
||||
}
|
||||
|
||||
test("opening another workspace while the host answers keeps it open after a row archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
|
||||
page,
|
||||
"archive-risk-switch-row",
|
||||
(worktree) => clickRowArchiveButton(page, worktree.workspaceId),
|
||||
);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
});
|
||||
|
||||
test("opening another workspace while the host answers keeps it open after a git actions archive", async ({
|
||||
page,
|
||||
}) => {
|
||||
const openedRoute = await archiveWhileOpeningAnotherWorkspace(
|
||||
page,
|
||||
"archive-risk-switch-git",
|
||||
() => gitActionsArchiveButton(page).click(),
|
||||
);
|
||||
await expectAppRoute(page, openedRoute);
|
||||
});
|
||||
|
||||
test("archiving the open worktree from its git actions leaves it for a new workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await createWorktree("archive-risk-git-redirect");
|
||||
await openSidebarRow(page, worktree.workspaceId);
|
||||
await workspaceRow(page, worktree.workspaceId).click();
|
||||
await expectAppRoute(page, buildHostWorkspaceRoute(getServerId(), worktree.workspaceId));
|
||||
|
||||
await gitActionsArchiveButton(page).click();
|
||||
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/new");
|
||||
await expectWorkspaceAbsentFromSidebar(page, worktree.workspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
+54
-1
@@ -1,10 +1,12 @@
|
||||
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;
|
||||
@@ -12,7 +14,7 @@ import com.facebook.react.views.scroll.ReactScrollView;
|
||||
import com.facebook.react.views.scroll.ReactScrollViewHelper;
|
||||
|
||||
/**
|
||||
* React Native's vertical scroll view with two corrections. Java, because the content-position
|
||||
* React Native's vertical scroll view with five corrections. Java, because the content-position
|
||||
* config type is internal to React Native's Kotlin module.
|
||||
*/
|
||||
final class PaseoScrollView extends ReactScrollView {
|
||||
@@ -27,6 +29,7 @@ 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);
|
||||
@@ -51,6 +54,56 @@ 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) {
|
||||
|
||||
@@ -87,7 +87,6 @@ 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";
|
||||
@@ -1252,8 +1251,9 @@ function WorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
diffStat: workspace.diffStat,
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
@@ -38,7 +38,6 @@ 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";
|
||||
@@ -656,8 +655,9 @@ function StatusWorkspaceRowWithMenu({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
name: workspace.name,
|
||||
...toWorktreeArchiveRisk(workspace),
|
||||
diffStat: workspace.diffStat,
|
||||
onArchiveStarted: redirectAfterArchive,
|
||||
onSetHiding: setIsHidingWorkspace,
|
||||
});
|
||||
|
||||
@@ -291,21 +291,13 @@ 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: () => {
|
||||
if (!activeWorkspaceSelection) {
|
||||
return;
|
||||
}
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId,
|
||||
workspaceId: activeWorkspaceSelection.workspaceId,
|
||||
activeWorkspaceSelection,
|
||||
});
|
||||
onArchiveStarted: (archived) => {
|
||||
redirectIfArchivingActiveWorkspace({ ...archived, activeWorkspaceSelection });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,87 @@
|
||||
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,3 +1,4 @@
|
||||
import type { CheckoutStatusClient } from "@/git/checkout-status-cache";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -49,6 +50,21 @@ 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,17 +1,21 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useRef } 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 } from "@/workspace/workspace-archive";
|
||||
import {
|
||||
archiveWorkspaceOptimistically,
|
||||
type WorkspaceArchiveTarget,
|
||||
} from "@/workspace/workspace-archive";
|
||||
|
||||
function purgeArchivedWorkspaceState(input: { serverId: string; workspaceId: string }): void {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey(input);
|
||||
@@ -24,12 +28,13 @@ export interface ArchiveWorkspaceInput {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
workspaceDirectory: string;
|
||||
name: string;
|
||||
isDirty?: boolean | null;
|
||||
aheadOfOrigin?: number | null;
|
||||
/** Labels the warning's line counts. Whether to warn comes from the host's git state. */
|
||||
diffStat?: { additions: number; deletions: number } | null;
|
||||
warningLabels?: WorktreeArchiveWarningLabels;
|
||||
onArchiveStarted: () => void;
|
||||
/** Runs once the archive starts, with the workspace being archived. */
|
||||
onArchiveStarted: (workspace: WorkspaceArchiveTarget) => void;
|
||||
onSetHiding?: (hiding: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -42,9 +47,8 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
serverId,
|
||||
workspaceId,
|
||||
workspaceKind,
|
||||
workspaceDirectory,
|
||||
name,
|
||||
isDirty,
|
||||
aheadOfOrigin,
|
||||
diffStat,
|
||||
warningLabels = DEFAULT_WORKTREE_ARCHIVE_WARNING_LABELS,
|
||||
onArchiveStarted,
|
||||
@@ -52,6 +56,11 @@ 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);
|
||||
@@ -62,7 +71,7 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
const workspace = { serverId, workspaceId };
|
||||
onSetHiding?.(true);
|
||||
try {
|
||||
onArchiveStarted();
|
||||
onArchiveStartedRef.current(workspace);
|
||||
if (supportsWorkspaceArchiveUndo(serverId)) {
|
||||
await archiveWorkspaceWithUndo({
|
||||
client,
|
||||
@@ -82,35 +91,54 @@ export function useWorkspaceArchive(input: ArchiveWorkspaceInput): WorkspaceArch
|
||||
} finally {
|
||||
onSetHiding?.(false);
|
||||
}
|
||||
}, [onArchiveStarted, onSetHiding, serverId, t, toast, workspaceId]);
|
||||
}, [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]);
|
||||
|
||||
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") {
|
||||
const confirmed = await confirmRiskyWorktreeArchive(
|
||||
{
|
||||
workspaceName: name,
|
||||
isDirty,
|
||||
aheadOfOrigin,
|
||||
diffStat,
|
||||
},
|
||||
warningLabels,
|
||||
);
|
||||
confirmingRef.current = true;
|
||||
let confirmed: boolean;
|
||||
try {
|
||||
confirmed = await confirmWorktreeArchive();
|
||||
} finally {
|
||||
confirmingRef.current = false;
|
||||
}
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await archiveWorkspaceRecord();
|
||||
})();
|
||||
}, [
|
||||
aheadOfOrigin,
|
||||
archiveWorkspaceRecord,
|
||||
diffStat,
|
||||
isDirty,
|
||||
name,
|
||||
warningLabels,
|
||||
workspaceKind,
|
||||
]);
|
||||
}, [archiveWorkspaceRecord, confirmWorktreeArchive, workspaceKind]);
|
||||
|
||||
return {
|
||||
archive,
|
||||
|
||||
@@ -236,6 +236,8 @@ 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:
|
||||
@@ -252,6 +254,11 @@ 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,25 +18,52 @@ const agents = [
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: ID prefix resolves");
|
||||
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");
|
||||
assert.strictEqual(resolveAgentId("def5678", agents), "def5678-other-agent-id");
|
||||
console.log("✓ ID prefix resolves\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 3: exact name match resolves case-insensitively");
|
||||
console.log("Test 5: 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 4: partial name match resolves when unique");
|
||||
console.log("Test 6: 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 5: missing agent returns null");
|
||||
console.log("Test 7: missing agent returns null");
|
||||
assert.strictEqual(resolveAgentId("does not exist", agents), null);
|
||||
console.log("✓ missing agent returns null\n");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -39,6 +40,29 @@ 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[] = [
|
||||
@@ -414,44 +438,48 @@ second line'`,
|
||||
expect(result.attachment.text).toContain("[Assistant] Done.");
|
||||
});
|
||||
|
||||
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 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("forks from a checkpoint when a plugin card was updated after it", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem, ToolCallTimelineItem } 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, so only they can make a checkpoint stale.
|
||||
// A fork copies only these kinds.
|
||||
const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [
|
||||
"user_message",
|
||||
"assistant_message",
|
||||
@@ -275,25 +275,25 @@ function selectForkContextRows(input: {
|
||||
);
|
||||
}
|
||||
const boundarySeq = projectedRows[boundaryIndex].seqEnd;
|
||||
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);
|
||||
// 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)];
|
||||
});
|
||||
|
||||
return {
|
||||
items: projected.map((entry) => entry.item),
|
||||
boundaryCursor,
|
||||
boundaryMessageId,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
|
||||
function trimContextMetadata(value: string | null | undefined): string | null {
|
||||
|
||||
@@ -13457,7 +13457,20 @@ 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"
|
||||
? {
|
||||
@@ -13473,11 +13486,6 @@ 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,
|
||||
@@ -13488,15 +13496,7 @@ 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,3 +14419,39 @@ 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,7 +856,10 @@ 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, object>();
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<
|
||||
AgentSession,
|
||||
{ readonly openedAt: number }
|
||||
>();
|
||||
private readonly collectingRuntimes = new Set<string>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
@@ -2068,7 +2071,7 @@ export class AgentManager {
|
||||
const updatedAt = agent.updatedAt;
|
||||
let identity = this.runtimeCleanupIdentities.get(agent.session);
|
||||
if (!identity) {
|
||||
identity = Object.freeze({});
|
||||
identity = Object.freeze({ openedAt: Date.now() });
|
||||
this.runtimeCleanupIdentities.set(agent.session, identity);
|
||||
}
|
||||
const isEligible = () =>
|
||||
@@ -2081,7 +2084,7 @@ export class AgentManager {
|
||||
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? null,
|
||||
lastActivityAt: updatedAt.getTime(),
|
||||
lastActivityAt: Math.max(updatedAt.getTime(), identity.openedAt),
|
||||
isEligible,
|
||||
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
|
||||
closeIfEligible: async (revalidate) => {
|
||||
@@ -4342,6 +4345,7 @@ 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,6 +496,58 @@ 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" });
|
||||
@@ -1690,6 +1742,11 @@ 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();
|
||||
@@ -1724,6 +1781,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
turnId: "second-turn",
|
||||
state,
|
||||
});
|
||||
emit({ type: "session.closed", sessionId: "reusable-child" });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1750,6 +1808,100 @@ 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,6 +1202,8 @@ 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>;
|
||||
@@ -1766,6 +1768,11 @@ 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,
|
||||
@@ -1784,7 +1791,10 @@ class PluginAgentSession implements AgentSession {
|
||||
event: {
|
||||
type: "upsert",
|
||||
id: childId,
|
||||
status: event.type === "session.runtime_failed" || event.error ? "failed" : "completed",
|
||||
status:
|
||||
event.type === "session.runtime_failed" || event.error
|
||||
? "failed"
|
||||
: (this.childTurnOutcomes.get(childId) ?? "completed"),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1802,6 +1812,7 @@ 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();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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,6 +547,48 @@ 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 = {
|
||||
@@ -790,6 +832,11 @@ 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,6 +30,7 @@ import {
|
||||
getCheckoutShortstat,
|
||||
getPullRequestStatus,
|
||||
getCheckoutStatus,
|
||||
getCheckoutWorktreeState,
|
||||
checkoutResolvedBranch,
|
||||
listBranchSuggestions,
|
||||
setCheckoutBaseBranch,
|
||||
@@ -572,6 +573,15 @@ describe("checkout git utilities", () => {
|
||||
expect(message).toBe("update file");
|
||||
});
|
||||
|
||||
it("counts untracked files as dirty when the repository hides them from status", async () => {
|
||||
execFileSync("git", ["config", "status.showUntrackedFiles", "no"], { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "new-file.txt"), "not yet added\n");
|
||||
expect(execFileSync("git", ["status", "--porcelain"], { cwd: repoDir }).toString()).toBe("");
|
||||
|
||||
expect((await getCheckoutStatus(repoDir)).isDirty).toBe(true);
|
||||
expect((await getCheckoutWorktreeState(repoDir, {})).isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it("honors commit signing configuration", async () => {
|
||||
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
|
||||
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
|
||||
|
||||
@@ -1413,12 +1413,17 @@ 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"], {
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
});
|
||||
const { stdout } = await getRunGitCommand(context)(
|
||||
["status", "--porcelain", "--untracked-files=normal"],
|
||||
{
|
||||
cwd,
|
||||
envOverlay: READ_ONLY_GIT_ENV,
|
||||
logger: context?.logger,
|
||||
},
|
||||
);
|
||||
return stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user